diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml index 9c1fae0b1..236d29944 100644 --- a/.github/actions/setup-haxeshit/action.yml +++ b/.github/actions/setup-haxeshit/action.yml @@ -3,21 +3,46 @@ description: "sets up haxe shit, using HMM!" runs: using: "composite" steps: - - uses: funkincrew/ci-haxe@v3 + - name: Install Haxe lol + uses: funkincrew/ci-haxe@v3.1.0 with: haxe-version: 4.3.3 - name: Config haxelib run: | - haxelib config + haxelib --never install haxelib 4.1.0 --global + haxelib --never deleterepo || true + haxelib --never newrepo + echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV" + haxelib --never git haxelib https://github.com/HaxeFoundation/haxelib.git master shell: bash - - name: Installing Haxe lol + - name: Gather debug info + run: | + cat << EOF >> "$GITHUB_STEP_SUMMARY" + ## haxe + - version: \`$(haxe -version)\` + - exe: \`$(which haxe)\` + ## haxelib + - version: \`$(haxelib version)\` + - exe: \`$(which haxelib)\` + - path: \`$HAXEPATH\` + ### local + - config: \`$(haxelib config)\` + - path: \`$(haxelib path haxelib || true)\` + ### global + - config: \`$(haxelib config --global)\` + - path: \`$(haxelib path haxelib --global || true)\` + ### system + - version: \`$(haxelib --system version)\` + - local: \`$(haxelib --system config)\` + - global: \`$(haxelib --system config --global)\` + EOF + shell: bash + - name: Install hmm + # hmm only supports global installs run: | - haxe -version - haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git master - haxelib version haxelib --global install hmm shell: bash - - name: dependency install cache + - name: Restore cached dependencies id: cache-hmm uses: actions/cache@v4 with: diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index 8ea3b16f3..49bab1ac1 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -8,71 +8,80 @@ jobs: runs-on: [self-hosted, linux] container: ubuntu:23.10 steps: - - name: prepare container + - name: Install tools missing in container run: | apt update - apt install sudo git curl unzip -y - git config --global --add safe.directory $GITHUB_WORKSPACE - - name: get token from gh app + apt install -y sudo git curl unzip + - name: Fix git config on posix runner + run: | + git config --global --add safe.directory ${{ github.workspace }} + - name: Get checkout token uses: actions/create-github-app-token@v1 id: app_token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PEM }} owner: ${{ github.repository_owner }} - - name: checkout repo + - name: Checkout repo uses: funkincrew/ci-checkout@v6 with: submodules: 'recursive' token: ${{ steps.app_token.outputs.token }} - - uses: ./.github/actions/setup-haxeshit - - name: gather game dependencies + - name: Install Haxe, dependencies + uses: ./.github/actions/setup-haxeshit + - name: Install native dependencies run: | - sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev - - name: build game + apt install -y \ + libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \ + libgl-dev libgl1-mesa-dev \ + libasound2-dev + - name: Build game run: | haxelib run lime build html5 -release --times -DGITHUB_BUILD - ls - - uses: ./.github/actions/upload-itch + - name: Upload build artifacts + uses: ./.github/actions/upload-itch with: butler-key: ${{ secrets.BUTLER_API_KEY}} build-dir: export/release/html5/bin target: html5 create-nightly-win: - runs-on: windows-latest + runs-on: [self-hosted, windows] + defaults: + run: + shell: bash steps: - - name: get token from gh app + - name: Get checkout token uses: actions/create-github-app-token@v1 id: app_token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PEM }} owner: ${{ github.repository_owner }} - - name: checkout repo + - name: Checkout repo uses: funkincrew/ci-checkout@v6 with: submodules: 'recursive' token: ${{ steps.app_token.outputs.token }} - - uses: ./.github/actions/setup-haxeshit - - name: Make HXCPP cache dir + - name: Install Haxe, dependencies + uses: ./.github/actions/setup-haxeshit + - name: Setup build cache run: | - mkdir -p ${{ runner.temp }}\hxcpp_cache + mkdir -p ${{ runner.temp }}/hxcpp_cache - name: Restore build cache id: cache-build-win uses: actions/cache@v4 with: path: | - .haxelib export - ${{ runner.temp }}\hxcpp_cache - key: ${{ runner.os }}-build-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }} - - name: build game + ${{ runner.temp }}/hxcpp_cache + key: ${{ runner.os }}-build-win-${{ github.ref_name }} + - name: Build game run: | - haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER -DGITHUB_BUILD - dir + haxelib run lime build windows -v -release -DGITHUB_BUILD env: HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache" - - uses: ./.github/actions/upload-itch + - name: Upload build artifacts + uses: ./.github/actions/upload-itch with: butler-key: ${{ secrets.BUTLER_API_KEY }} build-dir: export/release/windows/bin @@ -80,78 +89,42 @@ jobs: create-nightly-mac: runs-on: [self-hosted, macos] steps: - - name: prepare container + - name: Fix git config on posix runner run: | - git config --global --add safe.directory $GITHUB_WORKSPACE - - name: get token from gh app + git config --global --add safe.directory ${{ github.workspace }} + - name: Get checkout token uses: actions/create-github-app-token@v1 id: app_token with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PEM }} owner: ${{ github.repository_owner }} - - name: checkout repo + - name: Checkout repo uses: funkincrew/ci-checkout@v6 with: submodules: 'recursive' token: ${{ steps.app_token.outputs.token }} - - uses: ./.github/actions/setup-haxeshit - - name: Make HXCPP cache dir + - name: Install Haxe, dependencies + uses: ./.github/actions/setup-haxeshit + - name: Setup build cache run: | mkdir -p ${{ runner.temp }}/hxcpp_cache - - name: restore build cache + - name: Restore build cache id: cache-build-win uses: actions/cache@v4 with: path: | - .haxelib export ${{ runner.temp }}/hxcpp_cache - key: ${{ runner.os }}-build-mac-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }} + key: ${{ runner.os }}-build-mac-${{ github.ref_name }} - name: Build game run: | haxelib run lime build macos -release --times -DGITHUB_BUILD - ls env: HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache" - - uses: ./.github/actions/upload-itch + - name: Upload build artifacts + uses: ./.github/actions/upload-itch with: butler-key: ${{ secrets.BUTLER_API_KEY}} build-dir: export/release/macos/bin target: macos - -# test-unit-win: -# needs: create-nightly-win -# runs-on: windows-latest -# steps: -# - name: get token from gh app -# uses: actions/create-github-app-token@v1 -# id: app_token -# with: -# app-id: ${{ vars.APP_ID }} -# private-key: ${{ secrets.APP_PEM }} -# owner: ${{ github.repository_owner }} -# - name: checkout repo -# uses: funkincrew/ci-checkout@v6 -# with: -# submodules: 'recursive' -# token: ${{ steps.app_token.outputs.token }} -# - name: Make HXCPP cache dir -# run: | -# mkdir -p ${{ runner.temp }}\hxcpp_cache -# - name: Restore build cache -# id: cache-build-win -# uses: actions/cache@v4 -# with: -# path: | -# .haxelib -# export -# ${{ runner.temp }}\hxcpp_cache -# key: ${{ runner.os }}-test-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }} -# - uses: ./.github/actions/setup-haxeshit -# - name: Run unit tests -# run: | -# cd ./tests/unit/ -# ./start-win-native.bat -# env: -# HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache" diff --git a/.gitignore b/.gitignore index b2fe731ea..b6a415fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ .DS_STORE .haxelib/ +.vs/ APIStuff.hx dump/ export/ RECOVER_*.fla shitAudio/ +.build_time +.swp diff --git a/.gitmodules b/.gitmodules index 17c3cc026..452c0089b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,10 +1,6 @@ [submodule "assets"] path = assets - url = https://github.com/FunkinCrew/Funkin-history-rewrite-assets - branch = master - update = merge + url = https://github.com/FunkinCrew/Funkin-Assets-secret [submodule "art"] path = art - url = https://github.com/FunkinCrew/Funkin-history-rewrite-art - branch = master - update = merge + url = https://github.com/FunkinCrew/Funkin-Art-secret diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..2e132c06f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +# Ignore artifacts +export + +# Ignore all JSONS in the images folder (including FlxAnimate JSONs) +assets/preload/images +assets/shared/images +assets/weekend1/images + +# Don't ignore data files +# TODO: These don't work. +!assets/preload/data/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 2468e883a..84af3a3fd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -71,6 +71,7 @@ "haxe.displayPort": "auto", "haxe.enableCompilationServer": false, + "haxe.enableServerView": true, "haxe.displayServer": { "arguments": ["-v"] }, @@ -84,7 +85,7 @@ }, "projectManager.git.baseFolders": ["./"], - "haxecheckstyle.sourceFolders": ["src", "Source"], + "haxecheckstyle.sourceFolders": ["src", "source"], "haxecheckstyle.externalSourceRoots": [], "haxecheckstyle.configurationFile": "checkstyle.json", "haxecheckstyle.codeSimilarityBufferSize": 100, @@ -93,7 +94,7 @@ { "label": "Windows / Debug", "target": "windows", - "args": ["-debug"] + "args": ["-debug", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug", @@ -103,7 +104,7 @@ { "label": "Windows / Debug (FlxAnimate Test)", "target": "windows", - "args": ["-debug", "-DANIMATE"] + "args": ["-debug", "-DANIMATE", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (FlxAnimate Test)", @@ -113,7 +114,7 @@ { "label": "Windows / Debug (Straight to Freeplay)", "target": "windows", - "args": ["-debug", "-DFREEPLAY"] + "args": ["-debug", "-DFREEPLAY", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (Straight to Freeplay)", @@ -123,7 +124,11 @@ { "label": "Windows / Debug (Straight to Play - Bopeebo Normal)", "target": "windows", - "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"] + "args": [ + "-debug", + "-DSONG=bopeebo -DDIFFICULTY=normal", + "-DFORCE_DEBUG_VERSION" + ] }, { "label": "HashLink / Debug (Straight to Play - Bopeebo Normal)", @@ -133,7 +138,7 @@ { "label": "Windows / Debug (Conversation Test)", "target": "windows", - "args": ["-debug", "-DDIALOGUE"] + "args": ["-debug", "-DDIALOGUE", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (Conversation Test)", @@ -143,7 +148,7 @@ { "label": "Windows / Debug (Straight to Chart Editor)", "target": "windows", - "args": ["-debug", "-DCHARTING"] + "args": ["-debug", "-DCHARTING", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (Straight to Chart Editor)", @@ -153,7 +158,7 @@ { "label": "Windows / Debug (Straight to Animation Editor)", "target": "windows", - "args": ["-debug", "-DANIMDEBUG"] + "args": ["-debug", "-DANIMDEBUG", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (Straight to Animation Editor)", @@ -163,7 +168,7 @@ { "label": "Windows / Debug (Latency Test)", "target": "windows", - "args": ["-debug", "-DLATENCY"] + "args": ["-debug", "-DLATENCY", "-DFORCE_DEBUG_VERSION"] }, { "label": "HashLink / Debug (Latency Test)", @@ -173,7 +178,17 @@ { "label": "Windows / Debug (Waveform Test)", "target": "windows", - "args": ["-debug", "-DWAVEFORM"] + "args": ["-debug", "-DWAVEFORM", "-DFORCE_DEBUG_VERSION"] + }, + { + "label": "Windows / Release", + "target": "windows", + "args": ["-release"] + }, + { + "label": "Windows / Release (GitHub Actions)", + "target": "windows", + "args": ["-release", "-DGITHUB_BUILD"] }, { "label": "HashLink / Debug (Waveform Test)", @@ -183,12 +198,12 @@ { "label": "HTML5 / Debug", "target": "html5", - "args": ["-debug"] + "args": ["-debug", "-DFORCE_DEBUG_VERSION"] }, { "label": "HTML5 / Debug (Watch)", "target": "html5", - "args": ["-debug", "-watch"] + "args": ["-debug", "-watch", "-DFORCE_DEBUG_VERSION"] } ], "cmake.configureOnOpen": false, diff --git a/Project.xml b/Project.xml index c58153575..ffc8382a4 100644 --- a/Project.xml +++ b/Project.xml @@ -22,8 +22,8 @@ - - + +
@@ -53,28 +53,28 @@
- - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + + + + @@ -108,7 +111,7 @@ - + @@ -194,7 +197,7 @@ -
+
- +
- +
diff --git a/assets b/assets index e76470d66..526743915 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit e76470d66794bf3796f7ace84f2121b698e37a30 +Subproject commit 52674391511577300cdb8c08df293ea72099aa82 diff --git a/checkstyle.json b/checkstyle.json index 445f82b42..5300d94ad 100644 --- a/checkstyle.json +++ b/checkstyle.json @@ -23,10 +23,10 @@ }, { "props": { - "allowSingleArgParens": false, - "allowReturn": false, - "allowFunction": false, - "allowCurlyBody": false + "allowSingleArgParens": true, + "allowReturn": true, + "allowFunction": true, + "allowCurlyBody": true }, "type": "ArrowFunction" }, @@ -79,8 +79,8 @@ { "props": { "ignoreExtern": true, - "format": "^[A-Z][A-Z0-9]*(_[A-Z0-9_]+)*$", - "tokens": [] + "format": "^[a-z][A-Z][A-Z0-9]*(_[A-Z0-9_]+)*$", + "tokens": ["INLINE", "NOTINLINE"] }, "type": "ConstantName" }, @@ -178,13 +178,7 @@ "fieldType": "BOTH", "requireReturn": true, "ignoreOverride": true, - "tokens": [ - "ABSTRACT_DEF", - "CLASS_DEF", - "ENUM_DEF", - "INTERFACE_DEF", - "TYPEDEF_DEF" - ], + "tokens": ["ABSTRACT_DEF", "CLASS_DEF", "ENUM_DEF", "INTERFACE_DEF"], "modifier": "PUBLIC", "excludeNames": ["new", "toString"] }, @@ -198,10 +192,6 @@ }, "type": "FileLength" }, - { - "props": {}, - "type": "Final" - }, { "props": { "option": "upperCase" @@ -375,7 +365,7 @@ }, { "props": { - "max": 1 + "max": 4 }, "type": "NestedIfDepth" }, @@ -387,7 +377,7 @@ }, { "props": { - "option": "nullDefault" + "option": "questionMark" }, "type": "NullableParameter" }, @@ -396,7 +386,6 @@ "oldFunctionTypePolicy": "none", "unaryOpPolicy": "none", "intervalOpPolicy": "none", - "newFunctionTypePolicy": "around", "ternaryOpPolicy": "around", "boolOpPolicy": "around", @@ -589,19 +578,7 @@ }, { "props": { - "ignoreEnumAbstractValues": true - }, - "type": "Type" - }, - { - "props": { - "tokens": [ - "ABSTRACT_DEF", - "CLASS_DEF", - "ENUM_DEF", - "INTERFACE_DEF", - "TYPEDEF_DEF" - ] + "tokens": ["CLASS_DEF", "ENUM_DEF", "INTERFACE_DEF", "TYPEDEF_DEF"] }, "type": "TypeDocComment" }, @@ -630,12 +607,6 @@ }, "type": "UnusedLocalVar" }, - { - "props": { - "severity": "IGNORE" - }, - "type": "VariableInitialisation" - }, { "props": { "typeHintPolicy": "enforce_all", diff --git a/hmm.json b/hmm.json index dbb9d646e..6da12d535 100644 --- a/hmm.json +++ b/hmm.json @@ -11,7 +11,7 @@ "name": "flixel", "type": "git", "dir": null, - "ref": "4d054bd10b05bb1309a0ba3427ffa5378e0b4b99", + "ref": "5823c46b4e7410372d58b99f8d5c52fc18b3cb3d", "url": "https://github.com/FunkinCrew/flixel" }, { @@ -54,14 +54,14 @@ "name": "haxeui-core", "type": "git", "dir": null, - "ref": "8a7846b", + "ref": "0212d8fdfcafeb5f0d5a41e1ddba8ff21d0e183b", "url": "https://github.com/haxeui/haxeui-core" }, { "name": "haxeui-flixel", "type": "git", "dir": null, - "ref": "e9f880522e27134b29df4067f82df7d7e5237b70", + "ref": "63a906a6148958dbfde8c7b48d90b0693767fd95", "url": "https://github.com/haxeui/haxeui-flixel" }, { @@ -73,8 +73,8 @@ "name": "hxCodec", "type": "git", "dir": null, - "ref": "c8c47e706ad82a423783006ed901b6d93c89a421", - "url": "https://github.com/polybiusproxy/hxCodec" + "ref": "387e1665d6feb5762358134f168e6ebfe46acec8", + "url": "https://github.com/FunkinCrew/hxCodec" }, { "name": "hxcpp", @@ -146,7 +146,7 @@ "name": "polymod", "type": "git", "dir": null, - "ref": "d5a3b8995f64d20b95f844454e8c3b38c3d3a9fa", + "ref": "8553b800965f225bb14c7ab8f04bfa9cdec362ac", "url": "https://github.com/larsiusprime/polymod" }, { diff --git a/hxformat.json b/hxformat.json index cacbbf810..88a528e34 100644 --- a/hxformat.json +++ b/hxformat.json @@ -1,31 +1,30 @@ -{ - "disableFormatting": false, - - "indentation": { - "character": " ", - "tabWidth": 2, - "indentCaseLabels": true - }, - - "lineEnds": { - "anonFunctionCurly": { - "emptyCurly": "break", - "leftCurly": "after", - "rightCurly": "both" - }, - "leftCurly": "both", - "rightCurly": "both" - }, - - "sameLine": { - "ifBody": "same", - "ifElse": "next", - "doWhile": "next", - "tryBody": "next", - "tryCatch": "next" - }, - - "whitespace": { - "switchPolicy": "around" - } -} +{ + "disableFormatting": false, + + "indentation": { + "character": " ", + "tabWidth": 2, + "indentCaseLabels": true + }, + "lineEnds": { + "anonFunctionCurly": { + "emptyCurly": "break", + "leftCurly": "after", + "rightCurly": "both" + }, + "leftCurly": "both", + "rightCurly": "both" + }, + + "sameLine": { + "ifBody": "same", + "ifElse": "next", + "doWhile": "next", + "tryBody": "next", + "tryCatch": "next" + }, + + "whitespace": { + "switchPolicy": "around" + } +} diff --git a/source/Main.hx b/source/Main.hx index a40fda29d..f4c5d9eb2 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -11,9 +11,11 @@ import openfl.display.Sprite; import openfl.events.Event; import openfl.Lib; import openfl.media.Video; -import funkin.util.CLIUtil; import openfl.net.NetStream; +/** + * The main class which initializes HaxeFlixel and starts the game in its initial state. + */ class Main extends Sprite { var gameWidth:Int = 1280; // Width of the game in pixels (might be less / more in actual pixels depending on your zoom). @@ -76,35 +78,45 @@ class Main extends Sprite var netStream:NetStream; var overlay:Sprite; + /** + * A frame counter displayed at the top left. + */ public static var fpsCounter:FPS; + + /** + * A RAM counter displayed at the top left. + */ public static var memoryCounter:MemoryCounter; function setupGame():Void { - /** - * The `zoom` argument of FlxGame was removed in the dev branch of Flixel, - * since it was considered confusing and unintuitive. - * If you want to change how the game scales when you resize the window, - * you can use `FlxG.scaleMode`. - * -Eric - */ - initHaxeUI(); + // addChild gets called by the user settings code. fpsCounter = new FPS(10, 3, 0xFFFFFF); - // addChild(fpsCounter); // Handled by Preferences.init + #if !html5 + // addChild gets called by the user settings code. + // TODO: disabled on HTML5 (todo: find another method that works?) memoryCounter = new MemoryCounter(10, 13, 0xFFFFFF); - // addChild(memoryCounter); #end // George recommends binding the save before FlxGame is created. Save.load(); + var game:FlxGame = new FlxGame(gameWidth, gameHeight, initialState, framerate, framerate, skipSplash, startFullscreen); - addChild(new FlxGame(gameWidth, gameHeight, initialState, framerate, framerate, skipSplash, startFullscreen)); + // FlxG.game._customSoundTray wants just the class, it calls new from + // create() in there, which gets called when it's added to stage + // which is why it needs to be added before addChild(game) here + @:privateAccess + game._customSoundTray = funkin.ui.options.FunkinSoundTray; + + addChild(game); #if hxcpp_debug_server trace('hxcpp_debug_server is enabled! You can now connect to the game with a debugger.'); + #else + trace('hxcpp_debug_server is disabled! This build does not support debugging.'); #end } diff --git a/source/Postbuild.hx b/source/Postbuild.hx index d48b670a4..405d0400b 100644 --- a/source/Postbuild.hx +++ b/source/Postbuild.hx @@ -1,11 +1,40 @@ package source; // Yeah, I know... +import sys.FileSystem; +import sys.io.File; + +/** + * A script which executes after the game is built. + */ class Postbuild { - static function main() - { - trace('Postbuild'); + static inline final BUILD_TIME_FILE:String = '.build_time'; - // TODO: Maybe put a 'Build took X seconds' message here? + static function main():Void + { + printBuildTime(); + } + + static function printBuildTime():Void + { + // get buildEnd before fs operations since they are blocking + var end:Float = Sys.time(); + if (FileSystem.exists(BUILD_TIME_FILE)) + { + var fi:sys.io.FileInput = File.read(BUILD_TIME_FILE); + var start:Float = fi.readDouble(); + fi.close(); + + sys.FileSystem.deleteFile(BUILD_TIME_FILE); + + var buildTime:Float = roundToTwoDecimals(end - start); + + trace('Build took: ${buildTime} seconds'); + } + } + + static function roundToTwoDecimals(value:Float):Float + { + return Math.round(value * 100) / 100; } } diff --git a/source/Prebuild.hx b/source/Prebuild.hx index 63782fc56..6856c414c 100644 --- a/source/Prebuild.hx +++ b/source/Prebuild.hx @@ -1,9 +1,25 @@ package source; // Yeah, I know... +import sys.io.File; + +/** + * A script which executes before the game is built. + */ class Prebuild { - static function main() + static inline final BUILD_TIME_FILE:String = '.build_time'; + + static function main():Void { - trace('Prebuild'); + saveBuildTime(); + trace('Building...'); + } + + static function saveBuildTime():Void + { + var fo:sys.io.FileOutput = File.write(BUILD_TIME_FILE); + var now:Float = Sys.time(); + fo.writeDouble(now); + fo.close(); } } diff --git a/source/flixel/addons/transition/FlxTransitionableSubState.hx b/source/flixel/addons/transition/FlxTransitionableSubState.hx deleted file mode 100644 index ab416adbc..000000000 --- a/source/flixel/addons/transition/FlxTransitionableSubState.hx +++ /dev/null @@ -1,234 +0,0 @@ -package flixel.addons.transition; - -import flixel.FlxSubState; -import flixel.addons.transition.FlxTransitionableState; - -/** - * A `FlxSubState` which can perform visual transitions - * - * Usage: - * - * First, extend `FlxTransitionableSubState` as ie, `FooState`. - * - * Method 1: - * - * ```haxe - * var in:TransitionData = new TransitionData(...); // add your data where "..." is - * var out:TransitionData = new TransitionData(...); - * - * FlxG.switchState(() -> new FooState(in,out)); - * ``` - * - * Method 2: - * - * ```haxe - * FlxTransitionableSubState.defaultTransIn = new TransitionData(...); - * FlxTransitionableSubState.defaultTransOut = new TransitionData(...); - * - * FlxG.switchState(() -> new FooState()); - * ``` - */ -class FlxTransitionableSubState extends FlxSubState -{ - // global default transitions for ALL states, used if transIn/transOut are null - public static var defaultTransIn(get, set):TransitionData; - - static function get_defaultTransIn():TransitionData - { - return FlxTransitionableState.defaultTransIn; - } - - static function set_defaultTransIn(value:TransitionData):TransitionData - { - return FlxTransitionableState.defaultTransIn = value; - } - - public static var defaultTransOut(get, set):TransitionData; - - static function get_defaultTransOut():TransitionData - { - return FlxTransitionableState.defaultTransOut; - } - - static function set_defaultTransOut(value:TransitionData):TransitionData - { - return FlxTransitionableState.defaultTransOut = value; - } - - public static var skipNextTransIn(get, set):Bool; - - static function get_skipNextTransIn():Bool - { - return FlxTransitionableState.skipNextTransIn; - } - - static function set_skipNextTransIn(value:Bool):Bool - { - return FlxTransitionableState.skipNextTransIn = value; - } - - public static var skipNextTransOut(get, set):Bool; - - static function get_skipNextTransOut():Bool - { - return FlxTransitionableState.skipNextTransOut; - } - - static function set_skipNextTransOut(value:Bool):Bool - { - return FlxTransitionableState.skipNextTransOut = value; - } - - // beginning & ending transitions for THIS state: - public var transIn:TransitionData; - public var transOut:TransitionData; - - public var hasTransIn(get, never):Bool; - public var hasTransOut(get, never):Bool; - - /** - * Create a state with the ability to do visual transitions - * @param TransIn Plays when the state begins - * @param TransOut Plays when the state ends - */ - public function new(?TransIn:TransitionData, ?TransOut:TransitionData) - { - transIn = TransIn; - transOut = TransOut; - - if (transIn == null && defaultTransIn != null) - { - transIn = defaultTransIn; - } - if (transOut == null && defaultTransOut != null) - { - transOut = defaultTransOut; - } - super(); - } - - override function destroy():Void - { - super.destroy(); - transIn = null; - transOut = null; - _onExit = null; - } - - override function create():Void - { - super.create(); - transitionIn(); - } - - override function startOutro(onOutroComplete:() -> Void) - { - if (!hasTransOut) onOutroComplete(); - else if (!_exiting) - { - // play the exit transition, and when it's done call FlxG.switchState - _exiting = true; - transitionOut(onOutroComplete); - - if (skipNextTransOut) - { - skipNextTransOut = false; - finishTransOut(); - } - } - } - - /** - * Starts the in-transition. Can be called manually at any time. - */ - public function transitionIn():Void - { - if (transIn != null && transIn.type != NONE) - { - if (skipNextTransIn) - { - skipNextTransIn = false; - if (finishTransIn != null) - { - finishTransIn(); - } - return; - } - - var _trans = createTransition(transIn); - - _trans.setStatus(FULL); - openSubState(_trans); - - _trans.finishCallback = finishTransIn; - _trans.start(OUT); - } - } - - /** - * Starts the out-transition. Can be called manually at any time. - */ - public function transitionOut(?OnExit:Void->Void):Void - { - _onExit = OnExit; - if (hasTransOut) - { - var _trans = createTransition(transOut); - - _trans.setStatus(EMPTY); - openSubState(_trans); - - _trans.finishCallback = finishTransOut; - _trans.start(IN); - } - else - { - _onExit(); - } - } - - var transOutFinished:Bool = false; - - var _exiting:Bool = false; - var _onExit:Void->Void; - - function get_hasTransIn():Bool - { - return transIn != null && transIn.type != NONE; - } - - function get_hasTransOut():Bool - { - return transOut != null && transOut.type != NONE; - } - - function createTransition(data:TransitionData):Transition - { - return switch (data.type) - { - case TILES: new Transition(data); - case FADE: new Transition(data); - default: null; - } - } - - function finishTransIn() - { - closeSubState(); - } - - function finishTransOut() - { - transOutFinished = true; - - if (!_exiting) - { - closeSubState(); - } - - if (_onExit != null) - { - _onExit(); - } - } -} diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index 4b1b89dfa..aba154814 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -3,7 +3,6 @@ package funkin; import funkin.util.Constants; import flixel.util.FlxSignal; import flixel.math.FlxMath; -import funkin.play.song.Song.SongDifficulty; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongDataUtils; import funkin.save.Save; @@ -142,6 +141,9 @@ class Conductor return beatLengthMs / timeSignatureNumerator; } + /** + * The numerator for the current time signature (the `3` in `3/4`). + */ public var timeSignatureNumerator(get, never):Int; function get_timeSignatureNumerator():Int @@ -151,6 +153,9 @@ class Conductor return currentTimeChange.timeSignatureNum; } + /** + * The denominator for the current time signature (the `4` in `3/4`). + */ public var timeSignatureDenominator(get, never):Int; function get_timeSignatureDenominator():Int @@ -284,7 +289,7 @@ class Conductor * WARNING: Avoid this for things like setting the BPM of the title screen music, * you should have a metadata file for it instead. */ - public function forceBPM(?bpm:Float = null) + public function forceBPM(?bpm:Float):Void { if (bpm != null) { @@ -292,7 +297,7 @@ class Conductor } else { - // trace('[CONDUCTOR] Resetting BPM to default'); + trace('[CONDUCTOR] Resetting BPM to default'); } this.bpmOverride = bpm; @@ -310,15 +315,15 @@ class Conductor { if (songPos == null) { - // Take into account instrumental and file format song offsets. - songPos = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset + audioVisualOffset) : 0.0; + songPos = (FlxG.sound.music != null) ? FlxG.sound.music.time : 0.0; } - else - songPos += applyOffsets ? instrumentalOffset + formatOffset + audioVisualOffset : 0; - var oldMeasure = this.currentMeasure; - var oldBeat = this.currentBeat; - var oldStep = this.currentStep; + // Take into account instrumental and file format song offsets. + songPos += applyOffsets ? (instrumentalOffset + formatOffset + audioVisualOffset) : 0; + + var oldMeasure:Float = this.currentMeasure; + var oldBeat:Float = this.currentBeat; + var oldStep:Float = this.currentStep; // Set the song position we are at (for purposes of calculating note positions, etc). this.songPosition = songPos; @@ -409,39 +414,43 @@ class Conductor return this.songPosition; } - public function mapTimeChanges(songTimeChanges:Array) + /** + * Apply the `SongTimeChange` data from the song metadata to this Conductor. + * @param songTimeChanges The SongTimeChanges. + */ + public function mapTimeChanges(songTimeChanges:Array):Void { timeChanges = []; // Sort in place just in case it's out of order. SongDataUtils.sortTimeChanges(songTimeChanges); - for (currentTimeChange in songTimeChanges) + for (songTimeChange in songTimeChanges) { // TODO: Maybe handle this different? // Do we care about BPM at negative timestamps? // Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`. - if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0; + if (songTimeChange.timeStamp < 0.0) songTimeChange.timeStamp = 0.0; - if (currentTimeChange.timeStamp <= 0.0) + if (songTimeChange.timeStamp <= 0.0) { - currentTimeChange.beatTime = 0.0; + songTimeChange.beatTime = 0.0; } else { // Calculate the beat time of this timestamp. - currentTimeChange.beatTime = 0.0; + songTimeChange.beatTime = 0.0; - if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0) + if (songTimeChange.timeStamp > 0.0 && timeChanges.length > 0) { var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1]; - currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime - + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC), + songTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime + + ((songTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC), 4); } } - timeChanges.push(currentTimeChange); + timeChanges.push(songTimeChange); } if (timeChanges.length > 0) @@ -455,6 +464,8 @@ class Conductor /** * Given a time in milliseconds, return a time in steps. + * @param ms The time in milliseconds. + * @return The time in steps. */ public function getTimeInSteps(ms:Float):Float { @@ -484,7 +495,7 @@ class Conductor var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; var resultFractionalStep:Float = (ms - lastTimeChange.timeStamp) / lastStepLengthMs; - resultStep += resultFractionalStep; // Math.floor(); + resultStep += resultFractionalStep; return resultStep; } @@ -492,6 +503,8 @@ class Conductor /** * Given a time in steps and fractional steps, return a time in milliseconds. + * @param stepTime The time in steps. + * @return The time in milliseconds. */ public function getStepTimeInMs(stepTime:Float):Float { @@ -528,6 +541,8 @@ class Conductor /** * Given a time in beats and fractional beats, return a time in milliseconds. + * @param beatTime The time in beats. + * @return The time in milliseconds. */ public function getBeatTimeInMs(beatTime:Float):Float { @@ -563,7 +578,8 @@ class Conductor } /** - * @param conductorToUse defaults to Conductor.instance if null + * Adds Conductor fields to the Flixel debugger variable display. + * @param conductorToUse The conductor to use. Defaults to `Conductor.instance`. */ public static function watchQuick(?conductorToUse:Conductor):Void { diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx index 24b65832b..996e2367e 100644 --- a/source/funkin/Highscore.hx +++ b/source/funkin/Highscore.hx @@ -5,7 +5,42 @@ package funkin; */ class Highscore { + /** + * Keeps track of notes hit for the current song + * and how accurate you were with each note (bad, missed, shit, etc.) + */ public static var tallies:Tallies = new Tallies(); + + /** + * Keeps track of notes hit for the current WEEK / level + * for use with storymode, or likely any other "playlist" esque option + */ + public static var talliesLevel:Tallies = new Tallies(); + + /** + * Produces a new Tallies object which represents the sum of two existing Tallies + * @param newTally The first tally + * @param baseTally The second tally + * @return The combined tally + */ + public static function combineTallies(newTally:Tallies, baseTally:Tallies):Tallies + { + var combinedTally:Tallies = new Tallies(); + combinedTally.missed = newTally.missed + baseTally.missed; + combinedTally.shit = newTally.shit + baseTally.shit; + combinedTally.bad = newTally.bad + baseTally.bad; + combinedTally.good = newTally.good + baseTally.good; + combinedTally.sick = newTally.sick + baseTally.sick; + combinedTally.totalNotes = newTally.totalNotes + baseTally.totalNotes; + combinedTally.totalNotesHit = newTally.totalNotesHit + baseTally.totalNotesHit; + + // Current combo = use most recent. + combinedTally.combo = newTally.combo; + // Max combo = use maximum value. + combinedTally.maxCombo = Std.int(Math.max(newTally.maxCombo, baseTally.maxCombo)); + + return combinedTally; + } } @:forward @@ -29,6 +64,9 @@ abstract Tallies(RawTallies) } } +/** + * A structure object containing the data for highscore tallies. + */ typedef RawTallies = { var combo:Int; @@ -51,7 +89,7 @@ typedef RawTallies = var totalNotesHit:Int; /** - * How many notes PASSED BY AND/OR HIT!!! + * How many notes in the current chart */ var totalNotes:Int; } diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 0ad3c19b8..6ea77ec18 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -12,7 +12,6 @@ import flixel.math.FlxRect; import flixel.FlxSprite; import flixel.system.debug.log.LogStyle; import flixel.util.FlxColor; -import funkin.ui.options.PreferencesMenu; import funkin.util.macro.MacroUtil; import funkin.util.WindowUtil; import funkin.play.PlayStatePlaylist; @@ -24,13 +23,15 @@ import funkin.data.stage.StageRegistry; import funkin.data.dialogue.ConversationRegistry; import funkin.data.dialogue.DialogueBoxRegistry; import funkin.data.dialogue.SpeakerRegistry; +import funkin.data.freeplay.AlbumRegistry; import funkin.data.song.SongRegistry; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.modding.module.ModuleHandler; import funkin.ui.title.TitleState; import funkin.util.CLIUtil; import funkin.util.CLIUtil.CLIParams; -import funkin.ui.transition.LoadingState; +import funkin.util.TimerUtil; +import funkin.util.TrackerUtil; #if discord_rpc import Discord.DiscordClient; #end @@ -66,7 +67,7 @@ class InitState extends FlxState /** * Setup a bunch of important Flixel stuff. */ - function setupShit() + function setupShit():Void { // // GAME SETUP @@ -88,70 +89,7 @@ class InitState extends FlxState // Set the game to a lower frame rate while it is in the background. FlxG.game.focusLostFramerate = 30; - // - // FLIXEL DEBUG SETUP - // - #if (debug || FORCE_DEBUG_VERSION) - // Disable using ~ to open the console (we use that for the Editor menu) - FlxG.debugger.toggleKeys = [F2]; - - // Adds an additional Close Debugger button. - // This big obnoxious white button is for MOBILE, so that you can press it - // easily with your finger when debug bullshit pops up during testing lol! - FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() { - FlxG.debugger.visible = false; - }); - - // Adds a red button to the debugger. - // This pauses the game AND the music! This ensures the Conductor stops. - FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() { - if (FlxG.vcr.paused) - { - FlxG.vcr.resume(); - - for (snd in FlxG.sound.list) - { - snd.resume(); - } - - FlxG.sound.music.resume(); - } - else - { - FlxG.vcr.pause(); - - for (snd in FlxG.sound.list) - { - snd.pause(); - } - - FlxG.sound.music.pause(); - } - }); - - // Adds a blue button to the debugger. - // This skips forward in the song. - FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() { - FlxG.game.debugger.vcr.onStep(); - - for (snd in FlxG.sound.list) - { - snd.pause(); - snd.time += FlxG.elapsed * 1000; - } - - FlxG.sound.music.pause(); - FlxG.sound.music.time += FlxG.elapsed * 1000; - }); - #end - - // Make errors and warnings less annoying. - #if FORCE_DEBUG_VERSION - LogStyle.ERROR.openConsole = false; - LogStyle.ERROR.errorSound = null; - LogStyle.WARNING.openConsole = false; - LogStyle.WARNING.errorSound = null; - #end + setupFlixelDebug(); // // FLIXEL TRANSITIONS @@ -202,8 +140,12 @@ 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 Module behavior to a Flixel plugin. + // TODO: Move scripted Module behavior to a Flixel plugin. + #if debug + funkin.util.plugins.MemoryGCPlugin.initialize(); + #end funkin.util.plugins.EvacuateDebugPlugin.initialize(); + funkin.util.plugins.ForceCrashPlugin.initialize(); funkin.util.plugins.ReloadAssetsDebugPlugin.initialize(); funkin.util.plugins.ScreenshotPlugin.initialize(); funkin.util.plugins.VolumePlugin.initialize(); @@ -216,7 +158,7 @@ class InitState extends FlxState // NOTE: Registries must be imported and not referenced with fully qualified names, // to ensure build macros work properly. trace('Parsing game data...'); - var perfStart = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry. SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); @@ -224,18 +166,18 @@ class InitState extends FlxState ConversationRegistry.instance.loadEntries(); DialogueBoxRegistry.instance.loadEntries(); SpeakerRegistry.instance.loadEntries(); + AlbumRegistry.instance.loadEntries(); StageRegistry.instance.loadEntries(); - // TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers. - CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry. + // TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers and more prone to syntax errors. + // Move it to use a BaseRegistry. + CharacterDataParser.loadCharacterCache(); ModuleHandler.buildModuleCallbacks(); ModuleHandler.loadModuleCache(); ModuleHandler.callOnCreate(); - var perfEnd = haxe.Timer.stamp(); - - trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.'); + trace('Parsing game data took: ${TimerUtil.ms(perfStart)}'); } /** @@ -247,25 +189,35 @@ class InitState extends FlxState */ function startGame():Void { - #if SONG // -DSONG=bopeebo + #if SONG + // -DSONG=bopeebo startSong(defineSong(), defineDifficulty()); - #elseif LEVEL // -DLEVEL=week1 -DDIFFICULTY=hard + #elseif LEVEL + // -DLEVEL=week1 -DDIFFICULTY=hard startLevel(defineLevel(), defineDifficulty()); - #elseif FREEPLAY // -DFREEPLAY + #elseif FREEPLAY + // -DFREEPLAY FlxG.switchState(() -> new funkin.ui.freeplay.FreeplayState()); - #elseif DIALOGUE // -DDIALOGUE + #elseif DIALOGUE + // -DDIALOGUE FlxG.switchState(() -> new funkin.ui.debug.dialogue.ConversationDebugState()); - #elseif ANIMATE // -DANIMATE + #elseif ANIMATE + // -DANIMATE FlxG.switchState(() -> new funkin.ui.debug.anim.FlxAnimateTest()); - #elseif WAVEFORM // -DWAVEFORM + #elseif WAVEFORM + // -DWAVEFORM FlxG.switchState(() -> new funkin.ui.debug.WaveformTestState()); - #elseif CHARTING // -DCHARTING + #elseif CHARTING + // -DCHARTING FlxG.switchState(() -> new funkin.ui.debug.charting.ChartEditorState()); - #elseif STAGEBUILD // -DSTAGEBUILD + #elseif STAGEBUILD + // -DSTAGEBUILD FlxG.switchState(() -> new funkin.ui.debug.stage.StageBuilderState()); - #elseif ANIMDEBUG // -DANIMDEBUG + #elseif ANIMDEBUG + // -DANIMDEBUG FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState()); - #elseif LATENCY // -DLATENCY + #elseif LATENCY + // -DLATENCY FlxG.switchState(() -> new funkin.LatencyState()); #else startGameNormally(); @@ -346,6 +298,80 @@ class InitState extends FlxState }); } + function setupFlixelDebug():Void + { + // + // 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 + LogStyle.ERROR.openConsole = false; + LogStyle.ERROR.errorSound = null; + LogStyle.WARNING.openConsole = false; + LogStyle.WARNING.errorSound = null; + + // Disable using ~ to open the console (we use that for the Editor menu) + FlxG.debugger.toggleKeys = [F2]; + TrackerUtil.initTrackers(); + // Adds an additional Close Debugger button. + // This big obnoxious white button is for MOBILE, so that you can press it + // easily with your finger when debug bullshit pops up during testing lol! + FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() { + FlxG.debugger.visible = false; + + // Make errors and warnings less annoying. + // Forcing this always since I have never been happy to have the debugger to pop up + LogStyle.ERROR.openConsole = false; + LogStyle.ERROR.errorSound = null; + LogStyle.WARNING.openConsole = false; + LogStyle.WARNING.errorSound = null; + }); + + // Adds a red button to the debugger. + // This pauses the game AND the music! This ensures the Conductor stops. + FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() { + if (FlxG.vcr.paused) + { + FlxG.vcr.resume(); + + for (snd in FlxG.sound.list) + { + snd.resume(); + } + + FlxG.sound.music.resume(); + } + else + { + FlxG.vcr.pause(); + + for (snd in FlxG.sound.list) + { + snd.pause(); + } + + FlxG.sound.music.pause(); + } + }); + + // Adds a blue button to the debugger. + // This skips forward in the song. + FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() { + FlxG.game.debugger.vcr.onStep(); + + for (snd in FlxG.sound.list) + { + snd.pause(); + snd.time += FlxG.elapsed * 1000; + } + + FlxG.sound.music.pause(); + FlxG.sound.music.time += FlxG.elapsed * 1000; + }); + #end + } + function defineSong():String { return MacroUtil.getDefine('SONG'); diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx index e0212e573..d2c4833f2 100644 --- a/source/funkin/Paths.hx +++ b/source/funkin/Paths.hx @@ -11,130 +11,144 @@ class Paths { static var currentLevel:String; - static public function setCurrentLevel(name:String) + public static function setCurrentLevel(name:String):Void { currentLevel = name.toLowerCase(); } - static function getPath(file:String, type:AssetType, library:Null) + public static function stripLibrary(path:String):String + { + var parts:Array = path.split(':'); + if (parts.length < 2) return path; + return parts[1]; + } + + public static function getLibrary(path:String):String + { + var parts:Array = path.split(':'); + if (parts.length < 2) return 'preload'; + return parts[0]; + } + + static function getPath(file:String, type:AssetType, library:Null):String { if (library != null) return getLibraryPath(file, library); if (currentLevel != null) { - var levelPath = getLibraryPathForce(file, currentLevel); + var levelPath:String = getLibraryPathForce(file, currentLevel); if (OpenFlAssets.exists(levelPath, type)) return levelPath; } - var levelPath = getLibraryPathForce(file, "shared"); + var levelPath:String = getLibraryPathForce(file, 'shared'); if (OpenFlAssets.exists(levelPath, type)) return levelPath; return getPreloadPath(file); } - static public function getLibraryPath(file:String, library = "preload") + public static function getLibraryPath(file:String, library = 'preload'):String { - return if (library == "preload" || library == "default") getPreloadPath(file); else getLibraryPathForce(file, library); + return if (library == 'preload' || library == 'default') getPreloadPath(file); else getLibraryPathForce(file, library); } - inline static function getLibraryPathForce(file:String, library:String) + static inline function getLibraryPathForce(file:String, library:String):String { return '$library:assets/$library/$file'; } - inline static function getPreloadPath(file:String) + static inline function getPreloadPath(file:String):String { return 'assets/$file'; } - inline static public function file(file:String, type:AssetType = TEXT, ?library:String) + public static function file(file:String, type:AssetType = TEXT, ?library:String):String { return getPath(file, type, library); } - public static inline function animateAtlas(path:String, ?library:String) + public static function animateAtlas(path:String, ?library:String):String { return getLibraryPath('images/$path', library); } - inline static public function txt(key:String, ?library:String) + public static function txt(key:String, ?library:String):String { return getPath('data/$key.txt', TEXT, library); } - inline static public function frag(key:String, ?library:String) + public static function frag(key:String, ?library:String):String { return getPath('shaders/$key.frag', TEXT, library); } - inline static public function vert(key:String, ?library:String) + public static function vert(key:String, ?library:String):String { return getPath('shaders/$key.vert', TEXT, library); } - inline static public function xml(key:String, ?library:String) + public static function xml(key:String, ?library:String):String { return getPath('data/$key.xml', TEXT, library); } - inline static public function json(key:String, ?library:String) + public static function json(key:String, ?library:String):String { return getPath('data/$key.json', TEXT, library); } - static public function sound(key:String, ?library:String) + public static function sound(key:String, ?library:String):String { return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library); } - inline static public function soundRandom(key:String, min:Int, max:Int, ?library:String) + public static function soundRandom(key:String, min:Int, max:Int, ?library:String):String { return sound(key + FlxG.random.int(min, max), library); } - inline static public function music(key:String, ?library:String) + public static function music(key:String, ?library:String):String { return getPath('music/$key.${Constants.EXT_SOUND}', MUSIC, library); } - inline static public function videos(key:String, ?library:String) + public static function videos(key:String, ?library:String):String { return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library); } - inline static public function voices(song:String, ?suffix:String = '') + public static function voices(song:String, ?suffix:String = ''):String { - if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files + if (suffix == null) suffix = ''; // no suffix, for a sorta backwards compatibility with older-ish voice files return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}'; } - inline static public function inst(song:String, ?suffix:String = '') + public static function inst(song:String, ?suffix:String = ''):String { return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.${Constants.EXT_SOUND}'; } - inline static public function image(key:String, ?library:String) + public static function image(key:String, ?library:String):String { return getPath('images/$key.png', IMAGE, library); } - inline static public function font(key:String) + public static function font(key:String):String { return 'assets/fonts/$key'; } - inline static public function ui(key:String, ?library:String) + public static function ui(key:String, ?library:String):String { return xml('ui/$key', library); } - static public function getSparrowAtlas(key:String, ?library:String) + public static function getSparrowAtlas(key:String, ?library:String):FlxAtlasFrames { return FlxAtlasFrames.fromSparrow(image(key, library), file('images/$key.xml', library)); } - inline static public function getPackerAtlas(key:String, ?library:String) + public static function getPackerAtlas(key:String, ?library:String):FlxAtlasFrames { return FlxAtlasFrames.fromSpriteSheetPacker(image(key, library), file('images/$key.txt', library)); } diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx index 2e3e0e425..b4c871c29 100644 --- a/source/funkin/PlayerSettings.hx +++ b/source/funkin/PlayerSettings.hx @@ -13,6 +13,7 @@ import flixel.util.FlxSignal; */ class PlayerSettings { + // TODO: Finish implementation of second player. public static var numPlayers(default, null) = 0; public static var numAvatars(default, null) = 0; public static var player1(default, null):PlayerSettings; @@ -21,12 +22,21 @@ class PlayerSettings public static var onAvatarAdd(default, null) = new FlxTypedSignalVoid>(); public static var onAvatarRemove(default, null) = new FlxTypedSignalVoid>(); + /** + * The player number associated with this settings object. + */ public var id(default, null):Int; + /** + * The controls handler for this player. + */ public var controls(default, null):Controls; /** * Return the PlayerSettings for the given player number, or `null` if that player isn't active. + * + * @param id The player number this represents. + * @return The PlayerSettings for the given player number, or `null` if that player isn't active. */ public static function get(id:Int):Null { @@ -38,6 +48,9 @@ class PlayerSettings }; } + /** + * Initialize the PlayerSettings singletons for each player. + */ public static function init():Void { if (player1 == null) @@ -56,22 +69,30 @@ class PlayerSettings } } - public static function reset() + /** + * Forcibly destroy the PlayerSettings singletons for each player. + */ + public static function reset():Void { player1 = null; player2 = null; numPlayers = 0; } - static function onGamepadAdded(gamepad:FlxGamepad) + /** + * Callback invoked when a gamepad is added. + * @param gamepad The gamepad that was added. + */ + static function onGamepadAdded(gamepad:FlxGamepad):Void { + // TODO: Make this detect and handle multiple players player1.addGamepad(gamepad); } /** * @param id The player number this represents. This was refactored to START AT `1`. */ - private function new(id:Int) + function new(id:Int) { trace('loading player settings for id: $id'); @@ -83,11 +104,11 @@ class PlayerSettings function addKeyboard():Void { - var useDefault = true; - if (Save.get().hasControls(id, Keys)) + var useDefault:Bool = true; + if (Save.instance.hasControls(id, Keys)) { - var keyControlData = Save.get().getControls(id, Keys); - trace("keyControlData: " + haxe.Json.stringify(keyControlData)); + var keyControlData = Save.instance.getControls(id, Keys); + trace('Loading keyboard control scheme from user save'); useDefault = false; controls.fromSaveData(keyControlData, Keys); } @@ -98,7 +119,7 @@ class PlayerSettings if (useDefault) { - trace("Loading default keyboard control scheme"); + trace('Loading default keyboard control scheme'); controls.setKeyboardScheme(Solo); } @@ -109,13 +130,13 @@ class PlayerSettings * Called after an FlxGamepad has been detected. * @param gamepad The gamepad that was detected. */ - function addGamepad(gamepad:FlxGamepad) + function addGamepad(gamepad:FlxGamepad):Void { var useDefault = true; - if (Save.get().hasControls(id, Gamepad(gamepad.id))) + if (Save.instance.hasControls(id, Gamepad(gamepad.id))) { - var padControlData = Save.get().getControls(id, Gamepad(gamepad.id)); - trace("padControlData: " + haxe.Json.stringify(padControlData)); + var padControlData = Save.instance.getControls(id, Gamepad(gamepad.id)); + trace('Loading gamepad control scheme from user save'); useDefault = false; controls.addGamepadWithSaveData(gamepad.id, padControlData); } @@ -126,7 +147,7 @@ class PlayerSettings if (useDefault) { - trace("Loading gamepad control scheme"); + trace('Loading default gamepad control scheme'); controls.addDefaultGamepad(gamepad.id); } PreciseInputManager.instance.initializeButtons(controls, gamepad); @@ -135,13 +156,13 @@ class PlayerSettings /** * Save this player's controls to the game's persistent save. */ - public function saveControls() + public function saveControls():Void { var keyData = controls.createSaveData(Keys); if (keyData != null) { - trace("saving key data: " + haxe.Json.stringify(keyData)); - Save.get().setControls(id, Keys, keyData); + trace('Saving keyboard control scheme to user save'); + Save.instance.setControls(id, Keys, keyData); } if (controls.gamepadsAdded.length > 0) @@ -149,8 +170,8 @@ class PlayerSettings var padData = controls.createSaveData(Gamepad(controls.gamepadsAdded[0])); if (padData != null) { - trace("saving pad data: " + haxe.Json.stringify(padData)); - Save.get().setControls(id, Gamepad(controls.gamepadsAdded[0]), padData); + trace('Saving gamepad control scheme to user save'); + Save.instance.setControls(id, Gamepad(controls.gamepadsAdded[0]), padData); } } } diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx index 039a4c285..d8066c581 100644 --- a/source/funkin/Preferences.hx +++ b/source/funkin/Preferences.hx @@ -15,12 +15,12 @@ class Preferences static function get_naughtyness():Bool { - return Save.get().options.naughtyness; + return Save.instance.options.naughtyness; } static function set_naughtyness(value:Bool):Bool { - var save = Save.get(); + var save:Save = Save.instance; save.options.naughtyness = value; save.flush(); return value; @@ -34,12 +34,12 @@ class Preferences static function get_downscroll():Bool { - return Save.get().options.downscroll; + return Save.instance.options.downscroll; } static function set_downscroll(value:Bool):Bool { - var save = Save.get(); + var save:Save = Save.instance; save.options.downscroll = value; save.flush(); return value; @@ -53,12 +53,12 @@ class Preferences static function get_flashingLights():Bool { - return Save.get().options.flashingLights; + return Save.instance.options.flashingLights; } static function set_flashingLights(value:Bool):Bool { - var save = Save.get(); + var save:Save = Save.instance; save.options.flashingLights = value; save.flush(); return value; @@ -72,12 +72,12 @@ class Preferences static function get_zoomCamera():Bool { - return Save.get().options.zoomCamera; + return Save.instance.options.zoomCamera; } static function set_zoomCamera(value:Bool):Bool { - var save = Save.get(); + var save:Save = Save.instance; save.options.zoomCamera = value; save.flush(); return value; @@ -91,17 +91,17 @@ class Preferences static function get_debugDisplay():Bool { - return Save.get().options.debugDisplay; + return Save.instance.options.debugDisplay; } static function set_debugDisplay(value:Bool):Bool { - if (value != Save.get().options.debugDisplay) + if (value != Save.instance.options.debugDisplay) { toggleDebugDisplay(value); } - var save = Save.get(); + var save = Save.instance; save.options.debugDisplay = value; save.flush(); return value; @@ -115,22 +115,27 @@ class Preferences static function get_autoPause():Bool { - return Save.get().options.autoPause; + return Save.instance.options.autoPause; } static function set_autoPause(value:Bool):Bool { - if (value != Save.get().options.autoPause) FlxG.autoPause = value; + if (value != Save.instance.options.autoPause) FlxG.autoPause = value; - var save = Save.get(); + var save:Save = Save.instance; save.options.autoPause = value; save.flush(); return value; } + /** + * Loads the user's preferences from the save data and apply them. + */ public static function init():Void { + // Apply the autoPause setting (enables automatic pausing on focus lost). FlxG.autoPause = Preferences.autoPause; + // Apply the debugDisplay setting (enables the FPS and RAM display). toggleDebugDisplay(Preferences.debugDisplay); } diff --git a/source/funkin/Preloader.hx b/source/funkin/Preloader.hx index 2a73d8199..157015ba6 100644 --- a/source/funkin/Preloader.hx +++ b/source/funkin/Preloader.hx @@ -3,7 +3,6 @@ package funkin; import flash.Lib; import flash.display.Bitmap; import flash.display.BitmapData; -import flash.display.BlendMode; import flash.display.Sprite; import flixel.system.FlxBasePreloader; import openfl.display.Sprite; @@ -12,7 +11,8 @@ import openfl.text.TextField; import openfl.text.TextFormat; import flixel.system.FlxAssets; -@:bitmap("art/preloaderArt.png") class LogoImage extends BitmapData {} +@:bitmap('art/preloaderArt.png') +class LogoImage extends BitmapData {} class Preloader extends FlxBasePreloader { diff --git a/source/funkin/api/newgrounds/NGUtil.hx b/source/funkin/api/newgrounds/NGUtil.hx index c8289fc46..e9902a798 100644 --- a/source/funkin/api/newgrounds/NGUtil.hx +++ b/source/funkin/api/newgrounds/NGUtil.hx @@ -86,10 +86,10 @@ class NGUtil #end var onSessionFail:Error->Void = null; - if (sessionId == null && Save.get().ngSessionId != null) + if (sessionId == null && Save.instance.ngSessionId != null) { trace("using stored session id"); - sessionId = Save.get().ngSessionId; + sessionId = Save.instance.ngSessionId; onSessionFail = function(error) savedSessionFailed = true; } #end @@ -159,8 +159,8 @@ class NGUtil static function onNGLogin():Void { trace('logged in! user:${NG.core.user.name}'); - Save.get().ngSessionId = NG.core.sessionId; - Save.get().flush(); + Save.instance.ngSessionId = NG.core.sessionId; + Save.instance.flush(); // Load medals then call onNGMedalFetch() NG.core.requestMedals(onNGMedalFetch); @@ -174,8 +174,8 @@ class NGUtil { NG.core.logOut(); - Save.get().ngSessionId = null; - Save.get().flush(); + Save.instance.ngSessionId = null; + Save.instance.flush(); } // --- MEDALS diff --git a/source/funkin/api/newgrounds/NGio.hx b/source/funkin/api/newgrounds/NGio.hx index e505bdedf..c1f8ad3ba 100644 --- a/source/funkin/api/newgrounds/NGio.hx +++ b/source/funkin/api/newgrounds/NGio.hx @@ -86,10 +86,10 @@ class NGio #end var onSessionFail:Error->Void = null; - if (sessionId == null && Save.get().ngSessionId != null) + if (sessionId == null && Save.instance.ngSessionId != null) { trace("using stored session id"); - sessionId = Save.get().ngSessionId; + sessionId = Save.instance.ngSessionId; onSessionFail = function(error) savedSessionFailed = true; } #end @@ -159,8 +159,8 @@ class NGio static function onNGLogin():Void { trace('logged in! user:${NG.core.user.name}'); - Save.get().ngSessionId = NG.core.sessionId; - Save.get().flush(); + Save.instance.ngSessionId = NG.core.sessionId; + Save.instance.flush(); // Load medals then call onNGMedalFetch() NG.core.requestMedals(onNGMedalFetch); @@ -174,8 +174,8 @@ class NGio { NG.core.logOut(); - Save.get().ngSessionId = null; - Save.get().flush(); + Save.instance.ngSessionId = null; + Save.instance.flush(); } // --- MEDALS diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx index e7ce68d08..c64240909 100644 --- a/source/funkin/audio/FunkinSound.hx +++ b/source/funkin/audio/FunkinSound.hx @@ -1,13 +1,12 @@ package funkin.audio; -#if flash11 -import flash.media.Sound; -import flash.utils.ByteArray; -#end import flixel.sound.FlxSound; import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.util.FlxSignal.FlxTypedSignal; import flixel.system.FlxAssets.FlxSoundAsset; import funkin.util.tools.ICloneable; +import funkin.data.song.SongData.SongMusicData; +import funkin.data.song.SongRegistry; import funkin.audio.waveform.WaveformData; import funkin.audio.waveform.WaveformDataParser; import flixel.math.FlxMath; @@ -23,9 +22,31 @@ import openfl.utils.AssetType; @:nullSafety class FunkinSound extends FlxSound implements ICloneable { - static final MAX_VOLUME:Float = 2.0; + static final MAX_VOLUME:Float = 1.0; - static var cache(default, null):FlxTypedGroup = new FlxTypedGroup(); + /** + * An FlxSignal which is dispatched when the volume changes. + */ + public static var onVolumeChanged(get, never):FlxTypedSignalVoid>; + + static var _onVolumeChanged:NullVoid>> = null; + + static function get_onVolumeChanged():FlxTypedSignalVoid> + { + if (_onVolumeChanged == null) + { + _onVolumeChanged = new FlxTypedSignalVoid>(); + FlxG.sound.volumeHandler = function(volume:Float) { + _onVolumeChanged.dispatch(volume); + } + } + return _onVolumeChanged; + } + + /** + * Using `FunkinSound.load` will override a dead instance from here rather than creating a new one, if possible! + */ + static var pool(default, null):FlxTypedGroup = new FlxTypedGroup(); public var muted(default, set):Bool = false; @@ -40,7 +61,6 @@ class FunkinSound extends FlxSound implements ICloneable override function set_volume(value:Float):Float { // Uncap the volume. - fixMaxVolume(); _volume = FlxMath.bound(value, 0.0, MAX_VOLUME); updateTransform(); return _volume; @@ -88,6 +108,11 @@ class FunkinSound extends FlxSound implements ICloneable */ var _label:String = "unknown"; + /** + * Whether we received a focus lost event. + */ + var _lostFocus:Bool = false; + public function new() { super(); @@ -126,17 +151,6 @@ class FunkinSound extends FlxSound implements ICloneable return this; } - function fixMaxVolume():Void - { - #if lime_openal - // This code is pretty fragile, it reaches through 5 layers of private access. - @:privateAccess - var handle = this?._channel?.__source?.__backend?.handle; - if (handle == null) return; - lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME); - #end - } - public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound { if (!exists) return this; @@ -176,8 +190,18 @@ class FunkinSound extends FlxSound implements ICloneable public override function pause():FunkinSound { - super.pause(); - this._shouldPlay = false; + if (_shouldPlay) + { + // This sound will eventually play, but is still at a negative timestamp. + // Manually set the paused flag to ensure proper focus/unfocus behavior. + _shouldPlay = false; + _paused = true; + active = false; + } + else + { + super.pause(); + } return this; } @@ -186,10 +210,18 @@ class FunkinSound extends FlxSound implements ICloneable */ override function onFocus():Void { - if (!_alreadyPaused && this._shouldPlay) + // Flixel can sometimes toss spurious `onFocus` events, e.g. if the Flixel debugger is toggled + // on and off. We only want to resume the sound if we actually lost focus, and if we weren't + // already paused before we lost focus. + if (_lostFocus && !_alreadyPaused) { resume(); } + else + { + trace('Not resuming audio on focus!'); + } + _lostFocus = false; } /** @@ -197,6 +229,8 @@ class FunkinSound extends FlxSound implements ICloneable */ override function onFocusLost():Void { + trace('Focus lost, pausing audio!'); + _lostFocus = true; _alreadyPaused = _paused; pause(); } @@ -205,7 +239,10 @@ class FunkinSound extends FlxSound implements ICloneable { if (this._time < 0) { - this._shouldPlay = true; + // Sound with negative timestamp, restart the timer. + _shouldPlay = true; + _paused = false; + active = true; } else { @@ -246,24 +283,58 @@ class FunkinSound extends FlxSound implements ICloneable } /** - * Creates a new `FunkinSound` object. + * Creates a new `FunkinSound` object and loads it as the current music track. * - * @param embeddedSound The embedded sound resource you want to play. To stream, use the optional URL parameter instead. - * @param volume How loud to play it (0 to 1). - * @param looped Whether to loop this sound. - * @param group The group to add this sound to. - * @param autoDestroy Whether to destroy this sound when it finishes playing. + * @param key The key of the music you want to play. Music should be at `music//.ogg`. + * @param overrideExisting Whether to override music if it is already playing. + * @param mapTimeChanges Whether to check for `SongMusicData` to update the Conductor with. + * Data should be at `music//-metadata.json`. + */ + public static function playMusic(key:String, overrideExisting:Bool = false, mapTimeChanges:Bool = true):Void + { + if (!overrideExisting && FlxG.sound.music?.playing) return; + + if (mapTimeChanges) + { + var songMusicData:Null = SongRegistry.instance.parseMusicData(key); + // Will fall back and return null if the metadata doesn't exist or can't be parsed. + if (songMusicData != null) + { + Conductor.instance.mapTimeChanges(songMusicData.timeChanges); + } + else + { + FlxG.log.warn('Tried and failed to find music metadata for $key'); + } + } + + FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key')); + + // Prevent repeat update() and onFocus() calls. + FlxG.sound.list.remove(FlxG.sound.music); + } + + /** + * Creates a new `FunkinSound` object synchronously. + * + * @param embeddedSound The embedded sound resource you want to play. To stream, use the optional URL parameter instead. + * @param volume How loud to play it (0 to 1). + * @param looped Whether to loop this sound. + * @param group The group to add this sound to. + * @param autoDestroy Whether to destroy this sound when it finishes playing. * Leave this value set to `false` if you want to re-use this `FunkinSound` instance. - * @param autoPlay Whether to play the sound immediately or wait for a `play()` call. - * @param onComplete Called when the sound finished playing. - * @param onLoad Called when the sound finished loading. Called immediately for succesfully loaded embedded sounds. - * @return A `FunkinSound` object. + * @param autoPlay Whether to play the sound immediately or wait for a `play()` call. + * @param onComplete Called when the sound finished playing. + * @param onLoad Called when the sound finished loading. Called immediately for succesfully loaded embedded sounds. + * @return A `FunkinSound` object. */ public static function load(embeddedSound:FlxSoundAsset, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false, autoPlay:Bool = false, ?onComplete:Void->Void, ?onLoad:Void->Void):FunkinSound { - var sound:FunkinSound = cache.recycle(construct); + var sound:FunkinSound = pool.recycle(construct); + // Load the sound. + // Sets `exists = true` as a side effect. sound.loadEmbedded(embeddedSound, looped, autoDestroy, onComplete); if (embeddedSound is String) @@ -276,9 +347,11 @@ class FunkinSound extends FlxSound implements ICloneable sound.persist = true; if (autoPlay) sound.play(); - // Call OnlLoad() because the sound already loaded + // Call onLoad() because the sound already loaded if (onLoad != null && sound._sound != null) onLoad(); + FlxG.sound.list.remove(FlxG.sound.music); + return sound; } @@ -286,7 +359,7 @@ class FunkinSound extends FlxSound implements ICloneable { var sound:FunkinSound = new FunkinSound(); - cache.add(sound); + pool.add(sound); FlxG.sound.list.add(sound); return sound; diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx index 0e81a0901..a26537c2a 100644 --- a/source/funkin/audio/SoundGroup.hx +++ b/source/funkin/audio/SoundGroup.hx @@ -80,8 +80,9 @@ class SoundGroup extends FlxTypedGroup // We have to play, then pause the sound to set the time, // else the sound will restart immediately when played. - result.play(true, 0.0); - result.pause(); + // TODO: Past me experienced that issue but present me didn't? Investigate. + // result.play(true, 0.0); + // result.pause(); result.time = this.time; result.onComplete = function() { diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx index b4e024a4c..636c0726a 100644 --- a/source/funkin/audio/visualize/SpectogramSprite.hx +++ b/source/funkin/audio/visualize/SpectogramSprite.hx @@ -4,13 +4,11 @@ import flixel.FlxSprite; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.math.FlxMath; import flixel.math.FlxPoint; -import flixel.math.FlxVector; import flixel.sound.FlxSound; import flixel.util.FlxColor; import funkin.audio.visualize.PolygonSpectogram.VISTYPE; import funkin.audio.visualize.VisShit.CurAudioInfo; import funkin.audio.visualize.dsp.FFT; -import haxe.Timer; import lime.system.ThreadPool; import lime.utils.Int16Array; diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx index 5bfb8c7c5..204ced1e1 100644 --- a/source/funkin/audio/visualize/VisShit.hx +++ b/source/funkin/audio/visualize/VisShit.hx @@ -3,7 +3,6 @@ package funkin.audio.visualize; import flixel.math.FlxMath; import flixel.sound.FlxSound; import funkin.audio.visualize.dsp.FFT; -import haxe.Timer; import lime.system.ThreadPool; import lime.utils.Int16Array; import funkin.util.MathUtil; diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx index b82d141e7..1f649b472 100644 --- a/source/funkin/audio/waveform/WaveformData.hx +++ b/source/funkin/audio/waveform/WaveformData.hx @@ -187,6 +187,8 @@ class WaveformData */ public function merge(that:WaveformData):WaveformData { + if (that == null) return this.clone(); + var result = this.clone([]); for (channelIndex in 0...this.channels) diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx index 54a142f6a..5aa54d744 100644 --- a/source/funkin/audio/waveform/WaveformDataParser.hx +++ b/source/funkin/audio/waveform/WaveformDataParser.hx @@ -1,5 +1,7 @@ package funkin.audio.waveform; +import funkin.util.TimerUtil; + class WaveformDataParser { static final INT16_MAX:Int = 32767; @@ -71,7 +73,7 @@ class WaveformDataParser var outputData:Array = []; - var perfStart = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); for (pointIndex in 0...outputPointCount) { @@ -108,8 +110,7 @@ class WaveformDataParser var outputDataLength:Int = Std.int(outputData.length / channels / 2); var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData); - var perfEnd = haxe.Timer.stamp(); - trace('[WAVEFORM] Interpreted audio buffer in ${perfEnd - perfStart} seconds.'); + trace('[WAVEFORM] Interpreted audio buffer in ${TimerUtil.seconds(perfStart)}.'); return result; } diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 0ccbe2f18..ad028fa94 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -1,6 +1,5 @@ package funkin.data; -import openfl.Assets; import funkin.util.assets.DataAssets; import funkin.util.VersionUtil; import haxe.Constraints.Constructible; @@ -19,12 +18,23 @@ typedef EntryConstructorFunction = String->Void; @:generic abstract class BaseRegistry & Constructible), J> { + /** + * The ID of the registry. Used when logging. + */ public final registryId:String; final dataFilePath:String; + /** + * A map of entry IDs to entries. + */ final entries:Map; + /** + * A map of entry IDs to scripted class names. + */ + final scriptedEntryIds:Map; + /** * The version rule to use when loading entries. * If the entry's version does not match this rule, migration is needed. @@ -37,17 +47,18 @@ abstract class BaseRegistry & Constructible(); + this.scriptedEntryIds = []; } /** - * TODO: Create a `loadEntriesAsync()` function. + * TODO: Create a `loadEntriesAsync(onProgress, onComplete)` function. */ public function loadEntries():Void { @@ -61,12 +72,22 @@ abstract class BaseRegistry & Constructible = null; + try + { + entry = createScriptedEntry(entryCls); + } + catch (e) + { + log('Failed to create scripted entry (${entryCls})'); + continue; + } if (entry != null) { log('Successfully created scripted entry (${entryCls} = ${entry.id})'); entries.set(entry.id, entry); + scriptedEntryIds.set(entry.id, entryCls); } else { @@ -93,7 +114,7 @@ abstract class BaseRegistry & Constructible & Constructible & Constructible { var entryStr:String = loadEntryFile(id).contents; @@ -176,6 +232,8 @@ abstract class BaseRegistry & Constructible; @@ -185,6 +243,7 @@ abstract class BaseRegistry & Constructible; @@ -193,9 +252,17 @@ abstract class BaseRegistry & Constructible { + if (version == null) + { + throw '[${registryId}] Entry ${id} could not be JSON-parsed or does not have a parseable version.'; + } + // If a version rule is not specified, do not check against it. if (versionRule == null || VersionUtil.validateVersion(version, versionRule)) { @@ -206,12 +273,17 @@ abstract class BaseRegistry & Constructible & Constructible { + // We enforce that T is Constructible to ensure this is valid. return new T(id); } @@ -240,10 +313,15 @@ abstract class BaseRegistry & Constructible, currentTime:Float):Void + { + for (event in events) + { + // Deactivate future events. + if (event.time > currentTime) + { + event.activated = false; + } + + // Skip past events. + if (event.time < currentTime) + { + event.activated = true; + } + } + } + /** * Reset activation of all the provided events. */ diff --git a/source/funkin/data/freeplay/AlbumData.hx b/source/funkin/data/freeplay/AlbumData.hx new file mode 100644 index 000000000..265a01fce --- /dev/null +++ b/source/funkin/data/freeplay/AlbumData.hx @@ -0,0 +1,36 @@ +package funkin.data.freeplay; + +/** + * 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 AlbumData = +{ + /** + * Semantic version for album data. + */ + public var version:String; + + /** + * Readable name of the album. + */ + public var name:String; + + /** + * Readable name of the artist(s) of the album. + */ + public var artists:Array; + + /** + * Asset key for the album art. + * The album art will be displayed in Freeplay. + */ + public var albumArtAsset:String; + + /** + * Asset key for the album title. + * The album title will be displayed below the album art in Freeplay. + */ + public var albumTitleAsset:String; +} diff --git a/source/funkin/data/freeplay/AlbumRegistry.hx b/source/funkin/data/freeplay/AlbumRegistry.hx new file mode 100644 index 000000000..78fba451b --- /dev/null +++ b/source/funkin/data/freeplay/AlbumRegistry.hx @@ -0,0 +1,84 @@ +package funkin.data.freeplay; + +import funkin.ui.freeplay.Album; +import funkin.data.freeplay.AlbumData; +import funkin.ui.freeplay.ScriptedAlbum; + +class AlbumRegistry extends BaseRegistry +{ + /** + * The current version string for the album data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateAlbumData()` function. + */ + public static final ALBUM_DATA_VERSION:thx.semver.Version = '1.0.0'; + + public static final ALBUM_DATA_VERSION_RULE:thx.semver.VersionRule = '1.0.x'; + + public static final instance:AlbumRegistry = new AlbumRegistry(); + + public function new() + { + super('ALBUM', 'ui/freeplay/albums', ALBUM_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):Album + { + return ScriptedAlbum.init(clsName, 'unknown'); + } + + function getScriptedClassNames():Array + { + return ScriptedAlbum.listScriptClasses(); + } +} diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx index 843389cae..f5e58ae16 100644 --- a/source/funkin/data/level/LevelData.hx +++ b/source/funkin/data/level/LevelData.hx @@ -17,7 +17,7 @@ typedef LevelData = var version:String; /** - * The title of the week, as seen in the top corner. + * The title of the level, as seen in the top corner. */ var name:String; @@ -27,21 +27,35 @@ typedef LevelData = @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var titleAsset:String; + /** + * The props to display over the colored background. + * In the base game this is usually Boyfriend and the opponent. + */ @:default([]) var props:Array; - @:default(["bopeebo"]) + + /** + * The list of song IDs included in this level. + */ + @:default(['bopeebo']) var songs:Array; - @:default("#F9CF51") + + /** + * The background for the level behind the props. + */ + @:default('#F9CF51') @:optional var background:String; } +/** + * Data for a single prop for a story mode level. + */ typedef LevelPropData = { /** * The image to use for the prop. May optionally be a sprite sheet. */ - // @:jcustomparse(funkin.data.DataParse.stringNotEmpty) var assetPath:String; /** diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index bba5f899f..26380947a 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -106,11 +106,14 @@ class SongMetadata implements ICloneable /** * Serialize this SongMetadata into a JSON string. + * @param pretty Whether the JSON should be big ol string (false), + * or formatted with tabs (true) * @return The JSON string. */ public function serialize(pretty:Bool = true):String { - var writer = new json2object.JsonWriter(); + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); // I believe @:jignored should be iggnored by the writer? // var output = this.clone(); // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer. @@ -597,7 +600,8 @@ class SongChartData implements ICloneable */ public function serialize(pretty:Bool = true):String { - var writer = new json2object.JsonWriter(); + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); return writer.write(this, pretty ? ' ' : null); } @@ -648,7 +652,7 @@ class SongEventDataRaw implements ICloneable * Custom events can be added by scripts with the `ScriptedSongEvent` class. */ @:alias("e") - public var event:String; + public var eventKind:String; /** * The data for the event. @@ -668,10 +672,10 @@ class SongEventDataRaw implements ICloneable @:jignored public var activated:Bool = false; - public function new(time:Float, event:String, value:Dynamic = null) + public function new(time:Float, eventKind:String, value:Dynamic = null) { this.time = time; - this.event = event; + this.eventKind = eventKind; this.value = value; } @@ -687,22 +691,22 @@ class SongEventDataRaw implements ICloneable public function clone():SongEventDataRaw { - return new SongEventDataRaw(this.time, this.event, this.value); + return new SongEventDataRaw(this.time, this.eventKind, this.value); } } /** * Wrap SongEventData in an abstract so we can overload operators. */ -@:forward(time, event, value, activated, getStepTime, clone) +@:forward(time, eventKind, value, activated, getStepTime, clone) abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw { - public function new(time:Float, event:String, value:Dynamic = null) + public function new(time:Float, eventKind:String, value:Dynamic = null) { - this = new SongEventDataRaw(time, event, value); + this = new SongEventDataRaw(time, eventKind, value); } - public inline function valueAsStruct(?defaultKey:String = "key"):Dynamic + public function valueAsStruct(?defaultKey:String = "key"):Dynamic { if (this.value == null) return {}; if (Std.isOfType(this.value, Array)) @@ -726,12 +730,12 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR public inline function getHandler():Null { - return SongEventRegistry.getEvent(this.event); + return SongEventRegistry.getEvent(this.eventKind); } public inline function getSchema():Null { - return SongEventRegistry.getEventSchema(this.event); + return SongEventRegistry.getEventSchema(this.eventKind); } public inline function getDynamic(key:String):Null @@ -784,7 +788,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR var eventHandler = getHandler(); var eventSchema = getSchema(); - if (eventSchema == null) return 'Unknown Event: ${this.event}'; + if (eventSchema == null) return 'Unknown Event: ${this.eventKind}'; var result = '${eventHandler.getTitle()}'; @@ -809,19 +813,19 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR public function clone():SongEventData { - return new SongEventData(this.time, this.event, this.value); + return new SongEventData(this.time, this.eventKind, this.value); } @:op(A == B) public function op_equals(other:SongEventData):Bool { - return this.time == other.time && this.event == other.event && this.value == other.value; + return this.time == other.time && this.eventKind == other.eventKind && this.value == other.value; } @:op(A != B) public function op_notEquals(other:SongEventData):Bool { - return this.time != other.time || this.event != other.event || this.value != other.value; + return this.time != other.time || this.eventKind != other.eventKind || this.value != other.value; } @:op(A > B) @@ -853,7 +857,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR */ public function toString():String { - return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})'; + return 'SongEventData(${this.time}ms, ${this.eventKind}: ${this.value})'; } } @@ -1022,6 +1026,12 @@ class SongNoteDataRaw implements ICloneable { return new SongNoteDataRaw(this.time, this.data, this.length, this.kind); } + + public function toString():String + { + return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}' + + (this.kind != '' ? ' [kind: ${this.kind}])' : ')'); + } } /** diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx index 7f3b01eb4..c93c5379a 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -47,7 +47,7 @@ class SongDataUtils public static function offsetSongEventData(events:Array, offset:Float):Array { return events.map(function(event:SongEventData):SongEventData { - return new SongEventData(event.time + offset, event.event, event.value); + return new SongEventData(event.time + offset, event.eventKind, event.value); }); } diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index dad287e82..e7f74569c 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -68,6 +68,7 @@ class SongRegistry extends BaseRegistry { log('Successfully created scripted entry (${entryCls} = ${entry.id})'); entries.set(entry.id, entry); + scriptedEntryIds.set(entry.id, entryCls); } else { @@ -441,6 +442,13 @@ class SongRegistry extends BaseRegistry return {fileName: entryFilePath, contents: rawJson}; } + function hasMusicDataFile(id:String, ?variation:String):Bool + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json'); + return openfl.Assets.exists(entryFilePath); + } + function loadEntryChartFile(id:String, ?variation:String):Null { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx index cb914007f..22b883c75 100644 --- a/source/funkin/data/stage/StageData.hx +++ b/source/funkin/data/stage/StageData.hx @@ -32,18 +32,21 @@ class StageData bf: { zIndex: 0, + scale: 1, position: [0, 0], cameraOffsets: [-100, -100] }, dad: { zIndex: 0, + scale: 1, position: [0, 0], cameraOffsets: [100, -100] }, gf: { zIndex: 0, + scale: 1, position: [0, 0], cameraOffsets: [0, 0] } @@ -114,6 +117,7 @@ typedef StageDataProp = @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats) @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats) @:optional + @:default(Left(1.0)) var scale:haxe.ds.Either>; /** @@ -190,6 +194,13 @@ typedef StageDataCharacter = @:default([0, 0]) var position:Array; + /** + * The scale to render the character at. + */ + @:optional + @:default(1) + var scale:Float; + /** * The camera offsets to apply when focusing on the character on this stage. * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx index 487aaac34..ffbd63fab 100644 --- a/source/funkin/graphics/FunkinSprite.hx +++ b/source/funkin/graphics/FunkinSprite.hx @@ -3,12 +3,30 @@ package funkin.graphics; import flixel.FlxSprite; import flixel.util.FlxColor; import flixel.graphics.FlxGraphic; +import flixel.tweens.FlxTween; +import openfl.display3D.textures.TextureBase; +import funkin.graphics.framebuffer.FixedBitmapData; +import openfl.display.BitmapData; /** * An FlxSprite with additional functionality. + * - A more efficient method for creating solid color sprites. + * - TODO: Better cache handling for textures. */ class FunkinSprite extends FlxSprite { + /** + * An internal list of all the textures cached with `cacheTexture`. + * This excludes any temporary textures like those from `FlxText` or `makeSolidColor`. + */ + static var currentCachedTextures:Map = []; + + /** + * An internal list of textures that were cached in the previous state. + * We don't know whether we want to keep them cached or not. + */ + static var previousCachedTextures:Map = []; + /** * @param x Starting X position * @param y Starting Y position @@ -18,26 +36,225 @@ class FunkinSprite extends FlxSprite super(x, y); } + /** + * Create a new FunkinSprite with a static texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function create(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadTexture(key); + return sprite; + } + + /** + * Create a new FunkinSprite with a Sparrow atlas animated texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function createSparrow(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadSparrow(key); + return sprite; + } + + /** + * Create a new FunkinSprite with a Packer atlas animated texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function createPacker(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadPacker(key); + return sprite; + } + + /** + * Load a static image as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadTexture(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + loadGraphic(graphicKey); + + return this; + } + + /** + * Apply an OpenFL `BitmapData` to this sprite. + * @param input The OpenFL `BitmapData` to apply + * @return This sprite, for chaining + */ + public function loadBitmapData(input:BitmapData):FunkinSprite + { + loadGraphic(input); + + return this; + } + + /** + * Apply an OpenFL `TextureBase` to this sprite. + * @param input The OpenFL `TextureBase` to apply + * @return This sprite, for chaining + */ + public function loadTextureBase(input:TextureBase):FunkinSprite + { + var inputBitmap:FixedBitmapData = FixedBitmapData.fromTexture(input); + + return loadBitmapData(inputBitmap); + } + + /** + * Load an animated texture (Sparrow atlas spritesheet) as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadSparrow(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + this.frames = Paths.getSparrowAtlas(key); + + return this; + } + + /** + * Load an animated texture (Packer atlas spritesheet) as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadPacker(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + this.frames = Paths.getPackerAtlas(key); + + return this; + } + + /** + * Determine whether the texture with the given key is cached. + * @param key The key of the texture to check. + * @return Whether the texture is cached. + */ + public static function isTextureCached(key:String):Bool + { + return FlxG.bitmap.get(key) != null; + } + + /** + * Ensure the texture with the given key is cached. + * @param key The key of the texture to cache. + */ + public static function cacheTexture(key:String):Void + { + // We don't want to cache the same texture twice. + if (currentCachedTextures.exists(key)) return; + + if (previousCachedTextures.exists(key)) + { + // Move the graphic from the previous cache to the current cache. + var graphic = previousCachedTextures.get(key); + previousCachedTextures.remove(key); + currentCachedTextures.set(key, graphic); + return; + } + + // Else, texture is currently uncached. + var graphic:FlxGraphic = FlxGraphic.fromAssetKey(key, false, null, true); + if (graphic == null) + { + FlxG.log.warn('Failed to cache graphic: $key'); + } + else + { + trace('Successfully cached graphic: $key'); + graphic.persist = true; + currentCachedTextures.set(key, graphic); + } + } + + public static function cacheSparrow(key:String):Void + { + cacheTexture(Paths.image(key)); + } + + public static function cachePacker(key:String):Void + { + cacheTexture(Paths.image(key)); + } + + /** + * Call this, then `cacheTexture` to keep the textures we still need, then `purgeCache` to remove the textures that we won't be using anymore. + */ + public static function preparePurgeCache():Void + { + previousCachedTextures = currentCachedTextures; + currentCachedTextures = []; + } + + public static function purgeCache():Void + { + // Everything that is in previousCachedTextures but not in currentCachedTextures should be destroyed. + for (graphicKey in previousCachedTextures.keys()) + { + var graphic = previousCachedTextures.get(graphicKey); + FlxG.bitmap.remove(graphic); + graphic.destroy(); + previousCachedTextures.remove(graphicKey); + } + } + + static function isGraphicCached(graphic:FlxGraphic):Bool + { + if (graphic == null) return false; + var result = FlxG.bitmap.get(graphic.key); + if (result == null) return false; + if (result != graphic) + { + FlxG.log.warn('Cached graphic does not match original: ${graphic.key}'); + return false; + } + return true; + } + /** * Acts similarly to `makeGraphic`, but with improved memory usage, - * at the expense of not being able to paint onto the sprite. + * at the expense of not being able to paint onto the resulting sprite. * * @param width The target width of the sprite. * @param height The target height of the sprite. * @param color The color to fill the sprite with. + * @return This sprite, for chaining. */ public function makeSolidColor(width:Int, height:Int, color:FlxColor = FlxColor.WHITE):FunkinSprite { + // Create a tiny solid color graphic and scale it up to the desired size. var graphic:FlxGraphic = FlxG.bitmap.create(2, 2, color, false, 'solid#${color.toHexString(true, false)}'); frames = graphic.imageFrame; - scale.set(width / 2, height / 2); + scale.set(width / 2.0, height / 2.0); updateHitbox(); return this; } /** - * Ensure scale is applied when cloning a sprite. + * Ensure scale is applied when cloning a sprite.R * The default `clone()` method acts kinda weird TBH. * @return A clone of this sprite. */ @@ -50,4 +267,13 @@ class FunkinSprite extends FlxSprite return result; } + + public override function destroy():Void + { + frames = null; + // Cancel all tweens so they don't continue to run on a destroyed sprite. + // This prevents crashes. + FlxTween.cancelTweensOf(this); + super.destroy(); + } } diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx index ae7a5708c..c5a3a3771 100644 --- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx +++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx @@ -3,7 +3,9 @@ package funkin.graphics.adobeanimate; import flixel.util.FlxSignal.FlxTypedSignal; import flxanimate.FlxAnimate; import flxanimate.FlxAnimate.Settings; -import flixel.math.FlxPoint; +import flxanimate.frames.FlxAnimateFrames; +import openfl.display.BitmapData; +import openfl.utils.Assets; /** * A sprite which provides convenience functions for rendering a texture atlas with animations. @@ -18,7 +20,7 @@ class FlxAtlasSprite extends FlxAnimate // ?OnComplete:Void -> Void, ShowPivot: #if debug false #else false #end, Antialiasing: true, - ScrollFactor: new FlxPoint(1, 1), + ScrollFactor: null, // Offset: new FlxPoint(0, 0), // This is just FlxSprite.offset }; @@ -31,7 +33,7 @@ class FlxAtlasSprite extends FlxAnimate var canPlayOtherAnims:Bool = true; - public function new(x:Float, y:Float, path:String, ?settings:Settings) + public function new(x:Float, y:Float, ?path:String, ?settings:Settings) { if (settings == null) settings = SETTINGS; @@ -55,8 +57,9 @@ class FlxAtlasSprite extends FlxAnimate */ public function listAnimations():Array { - // return this.anim.getFrameLabels(); - return [""]; + if (this.anim == null) return []; + return this.anim.getFrameLabels(); + // return [""]; } /** @@ -81,9 +84,13 @@ class FlxAtlasSprite extends FlxAnimate * @param id A string ID of the animation to play. * @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 + * NOTE: `loop` and `ignoreOther` are not compatible with each other! */ - public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false):Void + public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, ?loop:Bool = false):Void { + if (loop == null) loop = false; + // Skip if not allowed to play animations. if ((!canPlayOtherAnims && !ignoreOther)) return; @@ -110,15 +117,21 @@ class FlxAtlasSprite extends FlxAnimate return; } - // Stop the current animation if it is playing. - // This includes removing existing frame callbacks. - if (this.currentAnimation != null) this.stopAnimation(); + anim.callback = function(_, frame:Int) { + var offset = loop ? 0 : -1; - // Add a callback to ensure `onAnimationFinish` is dispatched. - addFrameCallback(getNextFrameLabel(id), function() { - trace('Animation finished: ' + id); - onAnimationFinish.dispatch(id); - }); + if (frame == (anim.getFrameLabel(id).duration + offset) + anim.getFrameLabel(id).index) + { + if (loop) + { + playAnimation(id, true, false, true); + } + else + { + onAnimationFinish.dispatch(id); + } + } + }; // Prevent other animations from playing if `ignoreOther` is true. if (ignoreOther) canPlayOtherAnims = false; @@ -128,6 +141,11 @@ class FlxAtlasSprite extends FlxAnimate this.currentAnimation = id; } + override public function update(elapsed:Float) + { + super.update(elapsed); + } + /** * Stops the current animation. */ @@ -146,22 +164,22 @@ class FlxAtlasSprite extends FlxAnimate frameLabel.add(callback); } - inline function goToFrameLabel(label:String):Void + function goToFrameLabel(label:String):Void { this.anim.goToFrameLabel(label); } - inline function getNextFrameLabel(label:String):String + function getNextFrameLabel(label:String):String { return listAnimations()[(getLabelIndex(label) + 1) % listAnimations().length]; } - inline function getLabelIndex(label:String):Int + function getLabelIndex(label:String):Int { return listAnimations().indexOf(label); } - inline function goToFrameIndex(index:Int):Void + function goToFrameIndex(index:Int):Void { this.anim.curFrame = index; } @@ -169,7 +187,7 @@ class FlxAtlasSprite extends FlxAnimate public function cleanupAnimation(_:String):Void { canPlayOtherAnims = true; - this.currentAnimation = null; - this.anim.stop(); + // this.currentAnimation = null; + this.anim.pause(); } } diff --git a/source/funkin/graphics/framebuffer/FixedBitmapData.hx b/source/funkin/graphics/framebuffer/FixedBitmapData.hx index 00b39ce1c..4ffcbb867 100644 --- a/source/funkin/graphics/framebuffer/FixedBitmapData.hx +++ b/source/funkin/graphics/framebuffer/FixedBitmapData.hx @@ -32,11 +32,11 @@ class FixedBitmapData extends BitmapData public static function fromTexture(texture:TextureBase):FixedBitmapData { if (texture == null) return null; - final bitmapData = new FixedBitmapData(texture.__width, texture.__height, true, 0); - bitmapData.readable = false; + final bitmapData:FixedBitmapData = new FixedBitmapData(texture.__width, texture.__height, true, 0); + // bitmapData.readable = false; bitmapData.__texture = texture; bitmapData.__textureContext = texture.__textureContext; - bitmapData.image = null; + // bitmapData.image = null; return bitmapData; } } diff --git a/source/funkin/graphics/video/FlxVideo.hx b/source/funkin/graphics/video/FlxVideo.hx index e95d7caa6..a0fab9c09 100644 --- a/source/funkin/graphics/video/FlxVideo.hx +++ b/source/funkin/graphics/video/FlxVideo.hx @@ -1,45 +1,113 @@ package funkin.graphics.video; -import flixel.FlxBasic; -import flixel.FlxSprite; +import flixel.util.FlxColor; +import flixel.util.FlxSignal.FlxTypedSignal; +import funkin.audio.FunkinSound; +import openfl.display3D.textures.TextureBase; import openfl.events.NetStatusEvent; +import openfl.media.SoundTransform; import openfl.media.Video; import openfl.net.NetConnection; import openfl.net.NetStream; /** * Plays a video via a NetStream. Only works on HTML5. - * This does NOT replace hxCodec, nor does hxCodec replace this. hxCodec only works on desktop and does not work on HTML5! + * This does NOT replace hxCodec, nor does hxCodec replace this. + * hxCodec only works on desktop and does not work on HTML5! */ -class FlxVideo extends FlxBasic +class FlxVideo extends FunkinSprite { var video:Video; var netStream:NetStream; - - public var finishCallback:Void->Void; + var videoPath:String; /** - * Doesn't actually interact with Flixel shit, only just a pleasant to use class + * A callback to execute when the video finishes. */ + public var finishCallback:Void->Void; + public function new(videoPath:String) { super(); + this.videoPath = videoPath; + + makeGraphic(2, 2, FlxColor.TRANSPARENT); + video = new Video(); video.x = 0; video.y = 0; + video.alpha = 0; - FlxG.addChildBelowMouse(video); + FlxG.game.addChild(video); - var netConnection = new NetConnection(); + var netConnection:NetConnection = new NetConnection(); netConnection.connect(null); netStream = new NetStream(netConnection); - netStream.client = {onMetaData: client_onMetaData}; - netConnection.addEventListener(NetStatusEvent.NET_STATUS, netConnection_onNetStatus); + netStream.client = {onMetaData: onClientMetaData}; + netConnection.addEventListener(NetStatusEvent.NET_STATUS, onNetConnectionNetStatus); netStream.play(videoPath); } + /** + * Tell the FlxVideo to pause playback. + */ + public function pauseVideo():Void + { + if (netStream != null) + { + netStream.pause(); + } + } + + /** + * Tell the FlxVideo to resume if it is paused. + */ + public function resumeVideo():Void + { + // Resume playing the video. + if (netStream != null) + { + netStream.resume(); + } + } + + var videoAvailable:Bool = false; + var frameTimer:Float; + + static final FRAME_RATE:Float = 60; + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (frameTimer >= (1 / FRAME_RATE)) + { + frameTimer = 0; + // TODO: We just draw the video buffer to the sprite 60 times a second. + // Can we copy the video buffer instead somehow? + pixels.draw(video); + } + + if (videoAvailable) frameTimer += elapsed; + } + + /** + * Tell the FlxVideo to seek to the beginning. + */ + public function restartVideo():Void + { + // Seek to the beginning of the video. + if (netStream != null) + { + netStream.seek(0); + } + } + + /** + * Tell the FlxVideo to end. + */ public function finishVideo():Void { netStream.dispose(); @@ -48,15 +116,48 @@ class FlxVideo extends FlxBasic if (finishCallback != null) finishCallback(); } - public function client_onMetaData(metaData:Dynamic) + public override function destroy():Void + { + if (netStream != null) + { + netStream.dispose(); + + if (FlxG.game.contains(video)) FlxG.game.removeChild(video); + } + + super.destroy(); + } + + /** + * Callback executed when the video stream loads. + * @param metaData The metadata of the video + */ + public function onClientMetaData(metaData:Dynamic):Void { video.attachNetStream(netStream); - video.width = FlxG.width; - video.height = FlxG.height; + onVideoReady(); } - function netConnection_onNetStatus(event:NetStatusEvent):Void + function onVideoReady():Void + { + video.width = FlxG.width; + video.height = FlxG.height; + + videoAvailable = true; + + FunkinSound.onVolumeChanged.add(onVolumeChanged); + onVolumeChanged(FlxG.sound.muted ? 0 : FlxG.sound.volume); + + makeGraphic(Std.int(video.width), Std.int(video.height), FlxColor.TRANSPARENT); + } + + function onVolumeChanged(volume:Float):Void + { + netStream.soundTransform = new SoundTransform(volume); + } + + function onNetConnectionNetStatus(event:NetStatusEvent):Void { if (event.info.code == 'NetStream.Play.Complete') finishVideo(); } diff --git a/source/funkin/import.hx b/source/funkin/import.hx index 02055d4ed..250de99cb 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -13,7 +13,6 @@ using Lambda; using StringTools; using funkin.util.tools.ArraySortTools; using funkin.util.tools.ArrayTools; -using funkin.util.tools.DynamicTools; using funkin.util.tools.FloatTools; using funkin.util.tools.Int64Tools; using funkin.util.tools.IntTools; diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx index c4760cf5f..0857678d0 100644 --- a/source/funkin/input/Controls.hx +++ b/source/funkin/input/Controls.hx @@ -58,12 +58,11 @@ class Controls extends FlxActionSet var _back = new FlxActionDigital(Action.BACK); var _pause = new FlxActionDigital(Action.PAUSE); var _reset = new FlxActionDigital(Action.RESET); + var _screenshot = new FlxActionDigital(Action.SCREENSHOT); var _cutscene_advance = new FlxActionDigital(Action.CUTSCENE_ADVANCE); - var _cutscene_skip = new FlxActionDigital(Action.CUTSCENE_SKIP); var _debug_menu = new FlxActionDigital(Action.DEBUG_MENU); var _debug_chart = new FlxActionDigital(Action.DEBUG_CHART); var _debug_stage = new FlxActionDigital(Action.DEBUG_STAGE); - var _screenshot = new FlxActionDigital(Action.SCREENSHOT); var _volume_up = new FlxActionDigital(Action.VOLUME_UP); var _volume_down = new FlxActionDigital(Action.VOLUME_DOWN); var _volume_mute = new FlxActionDigital(Action.VOLUME_MUTE); @@ -208,16 +207,21 @@ class Controls extends FlxActionSet inline function get_PAUSE() return _pause.check(); + public var RESET(get, never):Bool; + + inline function get_RESET() + return _reset.check(); + + public var SCREENSHOT(get, never):Bool; + + inline function get_SCREENSHOT() + return _screenshot.check(); + public var CUTSCENE_ADVANCE(get, never):Bool; inline function get_CUTSCENE_ADVANCE() return _cutscene_advance.check(); - public var CUTSCENE_SKIP(get, never):Bool; - - inline function get_CUTSCENE_SKIP() - return _cutscene_skip.check(); - public var DEBUG_MENU(get, never):Bool; inline function get_DEBUG_MENU() @@ -233,11 +237,6 @@ class Controls extends FlxActionSet inline function get_DEBUG_STAGE() return _debug_stage.check(); - public var SCREENSHOT(get, never):Bool; - - inline function get_SCREENSHOT() - return _screenshot.check(); - public var VOLUME_UP(get, never):Bool; inline function get_VOLUME_UP() @@ -253,11 +252,6 @@ class Controls extends FlxActionSet inline function get_VOLUME_MUTE() return _volume_mute.check(); - public var RESET(get, never):Bool; - - inline function get_RESET() - return _reset.check(); - public function new(name, scheme:KeyboardScheme = null) { super(name); @@ -289,16 +283,15 @@ class Controls extends FlxActionSet add(_accept); add(_back); add(_pause); + add(_reset); + add(_screenshot); add(_cutscene_advance); - add(_cutscene_skip); add(_debug_menu); add(_debug_chart); add(_debug_stage); - add(_screenshot); add(_volume_up); add(_volume_down); add(_volume_mute); - add(_reset); for (action in digitalActions) byName[action.name] = action; @@ -383,12 +376,11 @@ class Controls extends FlxActionSet case BACK: _back; case PAUSE: _pause; case RESET: _reset; + case SCREENSHOT: _screenshot; case CUTSCENE_ADVANCE: _cutscene_advance; - case CUTSCENE_SKIP: _cutscene_skip; case DEBUG_MENU: _debug_menu; case DEBUG_CHART: _debug_chart; case DEBUG_STAGE: _debug_stage; - case SCREENSHOT: _screenshot; case VOLUME_UP: _volume_up; case VOLUME_DOWN: _volume_down; case VOLUME_MUTE: _volume_mute; @@ -449,26 +441,24 @@ class Controls extends FlxActionSet func(_back, JUST_PRESSED); case PAUSE: func(_pause, JUST_PRESSED); + case RESET: + func(_reset, JUST_PRESSED); + case SCREENSHOT: + func(_screenshot, JUST_PRESSED); case CUTSCENE_ADVANCE: func(_cutscene_advance, JUST_PRESSED); - case CUTSCENE_SKIP: - func(_cutscene_skip, PRESSED); case DEBUG_MENU: func(_debug_menu, JUST_PRESSED); case DEBUG_CHART: func(_debug_chart, JUST_PRESSED); case DEBUG_STAGE: func(_debug_stage, JUST_PRESSED); - case SCREENSHOT: - func(_screenshot, JUST_PRESSED); case VOLUME_UP: func(_volume_up, JUST_PRESSED); case VOLUME_DOWN: func(_volume_down, JUST_PRESSED); case VOLUME_MUTE: func(_volume_mute, JUST_PRESSED); - case RESET: - func(_reset, JUST_PRESSED); } } @@ -654,13 +644,12 @@ class Controls extends FlxActionSet bindKeys(Control.ACCEPT, getDefaultKeybinds(scheme, Control.ACCEPT)); bindKeys(Control.BACK, getDefaultKeybinds(scheme, Control.BACK)); bindKeys(Control.PAUSE, getDefaultKeybinds(scheme, Control.PAUSE)); + bindKeys(Control.RESET, getDefaultKeybinds(scheme, Control.RESET)); + bindKeys(Control.SCREENSHOT, getDefaultKeybinds(scheme, Control.SCREENSHOT)); bindKeys(Control.CUTSCENE_ADVANCE, getDefaultKeybinds(scheme, Control.CUTSCENE_ADVANCE)); - bindKeys(Control.CUTSCENE_SKIP, getDefaultKeybinds(scheme, Control.CUTSCENE_SKIP)); bindKeys(Control.DEBUG_MENU, getDefaultKeybinds(scheme, Control.DEBUG_MENU)); bindKeys(Control.DEBUG_CHART, getDefaultKeybinds(scheme, Control.DEBUG_CHART)); bindKeys(Control.DEBUG_STAGE, getDefaultKeybinds(scheme, Control.DEBUG_STAGE)); - bindKeys(Control.RESET, getDefaultKeybinds(scheme, Control.RESET)); - bindKeys(Control.SCREENSHOT, getDefaultKeybinds(scheme, Control.SCREENSHOT)); bindKeys(Control.VOLUME_UP, getDefaultKeybinds(scheme, Control.VOLUME_UP)); bindKeys(Control.VOLUME_DOWN, getDefaultKeybinds(scheme, Control.VOLUME_DOWN)); bindKeys(Control.VOLUME_MUTE, getDefaultKeybinds(scheme, Control.VOLUME_MUTE)); @@ -683,16 +672,15 @@ class Controls extends FlxActionSet case Control.ACCEPT: return [Z, SPACE, ENTER]; case Control.BACK: return [X, BACKSPACE, ESCAPE]; case Control.PAUSE: return [P, ENTER, ESCAPE]; + case Control.RESET: return [R]; + case Control.SCREENSHOT: return [F3]; // TODO: Change this back to PrintScreen case Control.CUTSCENE_ADVANCE: return [Z, ENTER]; - case Control.CUTSCENE_SKIP: return [P, ESCAPE]; case Control.DEBUG_MENU: return [GRAVEACCENT]; case Control.DEBUG_CHART: return []; case Control.DEBUG_STAGE: return []; - case Control.SCREENSHOT: return [F3]; // TODO: Change this back to PrintScreen case Control.VOLUME_UP: return [PLUS, NUMPADPLUS]; case Control.VOLUME_DOWN: return [MINUS, NUMPADMINUS]; case Control.VOLUME_MUTE: return [ZERO, NUMPADZERO]; - case Control.RESET: return [R]; } case Duo(true): switch (control) { @@ -707,16 +695,15 @@ class Controls extends FlxActionSet case Control.ACCEPT: return [G, Z]; case Control.BACK: return [H, X]; case Control.PAUSE: return [ONE]; + case Control.RESET: return [R]; + case Control.SCREENSHOT: return [PRINTSCREEN]; case Control.CUTSCENE_ADVANCE: return [G, Z]; - case Control.CUTSCENE_SKIP: return [ONE]; case Control.DEBUG_MENU: return [GRAVEACCENT]; case Control.DEBUG_CHART: return []; case Control.DEBUG_STAGE: return []; - case Control.SCREENSHOT: return [PRINTSCREEN]; case Control.VOLUME_UP: return [PLUS]; case Control.VOLUME_DOWN: return [MINUS]; case Control.VOLUME_MUTE: return [ZERO]; - case Control.RESET: return [R]; } case Duo(false): switch (control) { @@ -731,16 +718,15 @@ class Controls extends FlxActionSet case Control.ACCEPT: return [ENTER]; case Control.BACK: return [ESCAPE]; case Control.PAUSE: return [ONE]; + case Control.RESET: return [R]; + case Control.SCREENSHOT: return [PRINTSCREEN]; case Control.CUTSCENE_ADVANCE: return [ENTER]; - case Control.CUTSCENE_SKIP: return [ONE]; case Control.DEBUG_MENU: return [GRAVEACCENT]; case Control.DEBUG_CHART: return []; case Control.DEBUG_STAGE: return []; - case Control.SCREENSHOT: return [PRINTSCREEN]; case Control.VOLUME_UP: return [NUMPADPLUS]; case Control.VOLUME_DOWN: return [NUMPADMINUS]; case Control.VOLUME_MUTE: return [NUMPADZERO]; - case Control.RESET: return [R]; } default: // Fallthrough. @@ -843,15 +829,14 @@ class Controls extends FlxActionSet Control.NOTE_LEFT => getDefaultGamepadBinds(Control.NOTE_LEFT), Control.NOTE_RIGHT => getDefaultGamepadBinds(Control.NOTE_RIGHT), Control.PAUSE => getDefaultGamepadBinds(Control.PAUSE), + Control.RESET => getDefaultGamepadBinds(Control.RESET), // Control.SCREENSHOT => [], // Control.VOLUME_UP => [RIGHT_SHOULDER], // Control.VOLUME_DOWN => [LEFT_SHOULDER], // Control.VOLUME_MUTE => [RIGHT_TRIGGER], Control.CUTSCENE_ADVANCE => getDefaultGamepadBinds(Control.CUTSCENE_ADVANCE), - Control.CUTSCENE_SKIP => getDefaultGamepadBinds(Control.CUTSCENE_SKIP), // Control.DEBUG_MENU // Control.DEBUG_CHART - Control.RESET => getDefaultGamepadBinds(Control.RESET) ]); } @@ -868,15 +853,14 @@ class Controls extends FlxActionSet case Control.NOTE_LEFT: return [DPAD_LEFT, X, LEFT_STICK_DIGITAL_LEFT, RIGHT_STICK_DIGITAL_LEFT]; case Control.NOTE_RIGHT: return [DPAD_RIGHT, B, LEFT_STICK_DIGITAL_RIGHT, RIGHT_STICK_DIGITAL_RIGHT]; case Control.PAUSE: return [START]; + case Control.RESET: return [RIGHT_SHOULDER]; case Control.SCREENSHOT: return []; case Control.VOLUME_UP: return []; case Control.VOLUME_DOWN: return []; case Control.VOLUME_MUTE: return []; case Control.CUTSCENE_ADVANCE: return [A]; - case Control.CUTSCENE_SKIP: return [START]; case Control.DEBUG_MENU: return []; case Control.DEBUG_CHART: return []; - case Control.RESET: return [RIGHT_SHOULDER]; default: // Fallthrough. } @@ -1228,14 +1212,13 @@ enum Control UI_RIGHT; UI_DOWN; RESET; + SCREENSHOT; ACCEPT; BACK; PAUSE; // CUTSCENE CUTSCENE_ADVANCE; - CUTSCENE_SKIP; // SCREENSHOT - SCREENSHOT; // VOLUME VOLUME_UP; VOLUME_DOWN; @@ -1279,15 +1262,14 @@ abstract Action(String) to String from String var BACK = "back"; var PAUSE = "pause"; var RESET = "reset"; + // SCREENSHOT + var SCREENSHOT = "screenshot"; // CUTSCENE var CUTSCENE_ADVANCE = "cutscene_advance"; - var CUTSCENE_SKIP = "cutscene_skip"; // VOLUME var VOLUME_UP = "volume_up"; var VOLUME_DOWN = "volume_down"; var VOLUME_MUTE = "volume_mute"; - // SCREENSHOT - var SCREENSHOT = "screenshot"; // DEBUG var DEBUG_MENU = "debug_menu"; var DEBUG_CHART = "debug_chart"; diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx index b009aea41..5f2ff2b9e 100644 --- a/source/funkin/modding/IScriptedClass.hx +++ b/source/funkin/modding/IScriptedClass.hx @@ -56,7 +56,20 @@ interface IStateStageProp extends IScriptedClass */ interface INoteScriptedClass extends IScriptedClass { - public function onNoteHit(event:NoteScriptEvent):Void; + /** + * Called when a note enters the field of view and approaches the strumline. + */ + public function onNoteIncoming(event:NoteScriptEvent):Void; + + /** + * Called when EITHER player hits a note. + * Query the note attached to the event to determine if it was hit by the player or CPU. + */ + public function onNoteHit(event:HitNoteScriptEvent):Void; + + /** + * Called when EITHER player (usually the player) misses a note. + */ public function onNoteMiss(event:NoteScriptEvent):Void; } @@ -73,7 +86,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 IScriptedClass +interface IPlayStateScriptedClass extends INoteScriptedClass { /** * Called when the game is paused. @@ -113,17 +126,6 @@ interface IPlayStateScriptedClass extends IScriptedClass */ public function onSongRetry(event:ScriptEvent):Void; - /** - * Called when EITHER player hits a note. - * Query the note attached to the event to determine if it was hit by the player or CPU. - */ - public function onNoteHit(event:NoteScriptEvent):Void; - - /** - * Called when EITHER player (usually the player) misses a note. - */ - public function onNoteMiss(event:NoteScriptEvent):Void; - /** * Called when the player presses a key when no note is on the strumline. */ diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index f1e82aee9..a88476d4d 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -1,24 +1,26 @@ package funkin.modding; -import funkin.util.macro.ClassMacro; -import funkin.modding.module.ModuleHandler; -import funkin.data.song.SongData; -import funkin.data.stage.StageData; -import polymod.Polymod; -import polymod.backends.PolymodAssets.PolymodAssetType; -import polymod.format.ParseRules.TextFileFormat; -import funkin.data.event.SongEventRegistry; -import funkin.data.stage.StageRegistry; -import funkin.util.FileUtil; -import funkin.data.level.LevelRegistry; -import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.dialogue.ConversationRegistry; import funkin.data.dialogue.DialogueBoxRegistry; import funkin.data.dialogue.SpeakerRegistry; +import funkin.data.event.SongEventRegistry; +import funkin.data.level.LevelRegistry; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.data.song.SongRegistry; +import funkin.data.stage.StageRegistry; +import funkin.data.freeplay.AlbumRegistry; +import funkin.modding.module.ModuleHandler; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.save.Save; -import funkin.data.song.SongRegistry; +import funkin.util.FileUtil; +import funkin.util.macro.ClassMacro; +import polymod.backends.PolymodAssets.PolymodAssetType; +import polymod.format.ParseRules.TextFileFormat; +import polymod.Polymod; +/** + * A class for interacting with Polymod, the atomic modding framework for Haxe. + */ class PolymodHandler { /** @@ -27,16 +29,33 @@ class PolymodHandler * Bug fixes increment the patch version, new features increment the minor version. * Changes that break old mods increment the major version. */ - static final API_VERSION:String = "0.1.0"; + static final API_VERSION:String = '0.1.0'; /** * Where relative to the executable that mods are located. */ - static final MOD_FOLDER:String = #if (REDIRECT_ASSETS_FOLDER && macos) "../../../../../../../example_mods" #elseif REDIRECT_ASSETS_FOLDER "../../../../example_mods" #else "mods" #end; + static final MOD_FOLDER:String = + #if (REDIRECT_ASSETS_FOLDER && macos) + '../../../../../../../example_mods' + #elseif REDIRECT_ASSETS_FOLDER + '../../../../example_mods' + #else + 'mods' + #end; - static final CORE_FOLDER:Null = #if (REDIRECT_ASSETS_FOLDER && macos) "../../../../../../../assets" #elseif REDIRECT_ASSETS_FOLDER "../../../../assets" #else null #end; + static final CORE_FOLDER:Null = + #if (REDIRECT_ASSETS_FOLDER && macos) + '../../../../../../../assets' + #elseif REDIRECT_ASSETS_FOLDER + '../../../../assets' + #else + null + #end; - public static function createModRoot() + /** + * If the mods folder doesn't exist, create it. + */ + public static function createModRoot():Void { FileUtil.createDirIfNotExists(MOD_FOLDER); } @@ -44,40 +63,44 @@ class PolymodHandler /** * Loads the game with ALL mods enabled with Polymod. */ - public static function loadAllMods() + public static function loadAllMods():Void { // Create the mod root if it doesn't exist. createModRoot(); - trace("Initializing Polymod (using all mods)..."); + trace('Initializing Polymod (using all mods)...'); loadModsById(getAllModIds()); } /** * Loads the game with configured mods enabled with Polymod. */ - public static function loadEnabledMods() + public static function loadEnabledMods():Void { // Create the mod root if it doesn't exist. createModRoot(); - trace("Initializing Polymod (using configured mods)..."); - loadModsById(Save.get().enabledModIds); + trace('Initializing Polymod (using configured mods)...'); + loadModsById(Save.instance.enabledModIds); } /** * Loads the game without any mods enabled with Polymod. */ - public static function loadNoMods() + public static function loadNoMods():Void { // Create the mod root if it doesn't exist. createModRoot(); // We still need to configure the debug print calls etc. - trace("Initializing Polymod (using no mods)..."); + trace('Initializing Polymod (using no mods)...'); loadModsById([]); } - public static function loadModsById(ids:Array) + /** + * Load all the mods with the given ids. + * @param ids The ORDERED list of mod ids to load. + */ + public static function loadModsById(ids:Array):Void { if (ids.length == 0) { @@ -90,7 +113,7 @@ class PolymodHandler buildImports(); - var loadedModList = polymod.Polymod.init( + var loadedModList:Array = polymod.Polymod.init( { // Root directory for all mods. modRoot: MOD_FOLDER, @@ -142,30 +165,40 @@ class PolymodHandler } #if debug - var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE); + var fileList:Array = Polymod.listModFiles(PolymodAssetType.IMAGE); trace('Installed mods have replaced ${fileList.length} images.'); for (item in fileList) + { trace(' * $item'); + } fileList = Polymod.listModFiles(PolymodAssetType.TEXT); trace('Installed mods have added/replaced ${fileList.length} text files.'); for (item in fileList) + { trace(' * $item'); + } fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC); trace('Installed mods have replaced ${fileList.length} music files.'); for (item in fileList) + { trace(' * $item'); + } fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND); trace('Installed mods have replaced ${fileList.length} sound files.'); for (item in fileList) + { trace(' * $item'); + } fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC); trace('Installed mods have replaced ${fileList.length} generic audio files.'); for (item in fileList) + { trace(' * $item'); + } #end } @@ -183,21 +216,21 @@ class PolymodHandler for (cls in ClassMacro.listClassesInPackage('polymod')) { if (cls == null) continue; - var className = Type.getClassName(cls); + var className:String = Type.getClassName(cls); Polymod.blacklistImport(className); } } static function buildParseRules():polymod.format.ParseRules { - var output = polymod.format.ParseRules.getDefault(); + var output:polymod.format.ParseRules = polymod.format.ParseRules.getDefault(); // Ensure TXT files have merge support. - output.addType("txt", TextFileFormat.LINES); + output.addType('txt', TextFileFormat.LINES); // Ensure script files have merge support. - output.addType("hscript", TextFileFormat.PLAINTEXT); - output.addType("hxs", TextFileFormat.PLAINTEXT); - output.addType("hxc", TextFileFormat.PLAINTEXT); - output.addType("hx", TextFileFormat.PLAINTEXT); + output.addType('hscript', TextFileFormat.PLAINTEXT); + output.addType('hxs', TextFileFormat.PLAINTEXT); + output.addType('hxc', TextFileFormat.PLAINTEXT); + output.addType('hx', TextFileFormat.PLAINTEXT); // You can specify the format of a specific file, with file extension. // output.addFile("data/introText.txt", TextFileFormat.LINES) @@ -208,17 +241,21 @@ class PolymodHandler { return { assetLibraryPaths: [ - "default" => "preload", "shared" => "shared", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2", - "week3" => "week3", "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1", + 'default' => 'preload', 'shared' => 'shared', 'songs' => 'songs', 'tutorial' => 'tutorial', 'week1' => 'week1', 'week2' => 'week2', + 'week3' => 'week3', 'week4' => 'week4', 'week5' => 'week5', 'week6' => 'week6', 'week7' => 'week7', 'weekend1' => 'weekend1', ], coreAssetRedirect: CORE_FOLDER, } } + /** + * Retrieve a list of metadata for ALL installed mods, including disabled mods. + * @return An array of mod metadata + */ public static function getAllMods():Array { trace('Scanning the mods folder...'); - var modMetadata = Polymod.scan( + var modMetadata:Array = Polymod.scan( { modRoot: MOD_FOLDER, apiVersionRule: API_VERSION, @@ -228,17 +265,25 @@ class PolymodHandler return modMetadata; } + /** + * Retrieve a list of ALL mod IDs, including disabled mods. + * @return An array of mod IDs + */ public static function getAllModIds():Array { - var modIds = [for (i in getAllMods()) i.id]; + var modIds:Array = [for (i in getAllMods()) i.id]; return modIds; } + /** + * Retrieve a list of metadata for all enabled mods. + * @return An array of mod metadata + */ public static function getEnabledMods():Array { - var modIds = Save.get().enabledModIds; - var modMetadata = getAllMods(); - var enabledMods = []; + var modIds:Array = Save.instance.enabledModIds; + var modMetadata:Array = getAllMods(); + var enabledMods:Array = []; for (item in modMetadata) { if (modIds.indexOf(item.id) != -1) @@ -249,7 +294,11 @@ class PolymodHandler return enabledMods; } - public static function forceReloadAssets() + /** + * Clear and reload from disk all data assets. + * Useful for "hot reloading" for fast iteration! + */ + public static function forceReloadAssets():Void { // Forcibly clear scripts so that scripts can be edited. ModuleHandler.clearModuleCache(); @@ -276,6 +325,7 @@ class PolymodHandler ConversationRegistry.instance.loadEntries(); DialogueBoxRegistry.instance.loadEntries(); SpeakerRegistry.instance.loadEntries(); + AlbumRegistry.instance.loadEntries(); StageRegistry.instance.loadEntries(); CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry. ModuleHandler.loadModuleCache(); diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx index 18f934aee..0d424a281 100644 --- a/source/funkin/modding/events/ScriptEvent.hx +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -106,12 +106,19 @@ class NoteScriptEvent extends ScriptEvent */ public var playSound(default, default):Bool; - public function new(type:ScriptEventType, note:NoteSprite, comboCount:Int = 0, cancelable:Bool = false):Void + /** + * The health gained or lost from this note. + * This affects both hits and misses. Remember that max health is 2.00. + */ + public var healthChange:Float; + + public function new(type:ScriptEventType, note:NoteSprite, healthChange:Float, comboCount:Int = 0, cancelable:Bool = false):Void { super(type, cancelable); this.note = note; this.comboCount = comboCount; this.playSound = true; + this.healthChange = healthChange; } public override function toString():String @@ -120,6 +127,31 @@ class NoteScriptEvent extends ScriptEvent } } +class HitNoteScriptEvent extends NoteScriptEvent +{ + /** + * The judgement the player received for hitting the note. + */ + public var judgement:String; + + /** + * The score the player received for hitting the note. + */ + public var score:Int; + + public function new(note:NoteSprite, healthChange:Float, score:Int, judgement:String, comboCount:Int = 0):Void + { + super(NOTE_HIT, note, healthChange, comboCount, true); + this.score = score; + this.judgement = judgement; + } + + public override function toString():String + { + return 'HitNoteScriptEvent(note=' + note + ', comboCount=' + comboCount + ', judgement=' + judgement + ', score=' + score + ')'; + } +} + /** * An event that is fired when you press a key with no note present. */ @@ -182,17 +214,17 @@ class SongEventScriptEvent extends ScriptEvent * The note associated with this event. * You cannot replace it, but you can edit it. */ - public var event(default, null):funkin.data.song.SongData.SongEventData; + public var eventData(default, null):funkin.data.song.SongData.SongEventData; - public function new(event:funkin.data.song.SongData.SongEventData):Void + public function new(eventData:funkin.data.song.SongData.SongEventData):Void { super(SONG_EVENT, true); - this.event = event; + this.eventData = eventData; } public override function toString():String { - return 'SongEventScriptEvent(event=' + event + ')'; + return 'SongEventScriptEvent(event=' + eventData + ')'; } } diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx index f5d797ea4..fd58d0fad 100644 --- a/source/funkin/modding/events/ScriptEventDispatcher.hx +++ b/source/funkin/modding/events/ScriptEventDispatcher.hx @@ -71,17 +71,29 @@ class ScriptEventDispatcher } } - if (Std.isOfType(target, IPlayStateScriptedClass)) + if (Std.isOfType(target, INoteScriptedClass)) { - var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass); + var t:INoteScriptedClass = cast(target, INoteScriptedClass); switch (event.type) { + case NOTE_INCOMING: + t.onNoteIncoming(cast event); + return; case NOTE_HIT: t.onNoteHit(cast event); return; case NOTE_MISS: t.onNoteMiss(cast event); return; + default: // Continue; + } + } + + if (Std.isOfType(target, IPlayStateScriptedClass)) + { + var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass); + switch (event.type) + { case NOTE_GHOST_MISS: t.onNoteGhostMiss(cast event); return; diff --git a/source/funkin/modding/events/ScriptEventType.hx b/source/funkin/modding/events/ScriptEventType.hx index e06b5ad24..eeeb8ef29 100644 --- a/source/funkin/modding/events/ScriptEventType.hx +++ b/source/funkin/modding/events/ScriptEventType.hx @@ -63,6 +63,13 @@ enum abstract ScriptEventType(String) from String to String */ var SONG_STEP_HIT = 'STEP_HIT'; + /** + * Called when a note comes on screen and starts approaching the strumline. + * + * This event is not cancelable. + */ + var NOTE_INCOMING = 'NOTE_INCOMING'; + /** * Called when a character hits a note. * Important information such as judgement/timing, note data, player/opponent, etc. are all provided. diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx index f50c9936a..be9b7146b 100644 --- a/source/funkin/modding/module/Module.hx +++ b/source/funkin/modding/module/Module.hx @@ -83,7 +83,9 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte public function onGameOver(event:ScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} + public function onNoteIncoming(event:NoteScriptEvent) {} + + public function onNoteHit(event:HitNoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent) {} diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index 5b7ce9fc2..747565100 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -3,6 +3,7 @@ package funkin.play; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.FlxSprite; +import funkin.graphics.FunkinSprite; import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.module.ModuleHandler; import funkin.modding.events.ScriptEvent; @@ -214,7 +215,7 @@ class Countdown if (spritePath == null) return; - var countdownSprite:FlxSprite = new FlxSprite(0, 0).loadGraphic(Paths.image(spritePath)); + var countdownSprite:FunkinSprite = FunkinSprite.create(spritePath); countdownSprite.scrollFactor.set(0, 0); if (isPixelStyle) countdownSprite.setGraphicSize(Std.int(countdownSprite.width * Constants.PIXEL_ART_SCALE)); diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx index 74b39417e..ec3282164 100644 --- a/source/funkin/play/GameOverSubState.hx +++ b/source/funkin/play/GameOverSubState.hx @@ -4,16 +4,19 @@ import flixel.FlxG; import flixel.FlxObject; import flixel.FlxSprite; import flixel.sound.FlxSound; -import funkin.ui.story.StoryMenuState; +import funkin.audio.FunkinSound; import flixel.util.FlxColor; import flixel.util.FlxTimer; import funkin.graphics.FunkinSprite; -import funkin.ui.MusicBeatSubState; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.PlayState; -import funkin.ui.freeplay.FreeplayState; import funkin.play.character.BaseCharacter; +import funkin.play.PlayState; +import funkin.util.MathUtil; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.story.StoryMenuState; +import openfl.utils.Assets; /** * A substate which renders over the PlayState when the player dies. @@ -63,7 +66,7 @@ class GameOverSubState extends MusicBeatSubState /** * The music playing in the background of the state. */ - var gameOverMusic:FlxSound = new FlxSound(); + var gameOverMusic:Null = null; /** * Whether the player has confirmed and prepared to restart the level. @@ -71,10 +74,18 @@ class GameOverSubState extends MusicBeatSubState */ var isEnding:Bool = false; + /** + * Whether the death music is on its first loop. + */ + var isStarting:Bool = true; + var isChartingMode:Bool = false; var transparent:Bool; + final CAMERA_ZOOM_DURATION:Float = 0.5; + var targetCameraZoom:Float = 1.0; + public function new(params:GameOverParams) { super(); @@ -86,14 +97,14 @@ class GameOverSubState extends MusicBeatSubState /** * Reset the game over configuration to the default. */ - public static function reset() + public static function reset():Void { - animationSuffix = ""; - musicSuffix = ""; - blueBallSuffix = ""; + animationSuffix = ''; + musicSuffix = ''; + blueBallSuffix = ''; } - override public function create() + override public function create():Void { if (instance != null) { @@ -108,6 +119,8 @@ class GameOverSubState extends MusicBeatSubState // Set up the visuals // + var playState = PlayState.instance; + // Add a black background to the screen. var bg = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); // We make this transparent so that we can see the stage underneath during debugging, @@ -119,55 +132,73 @@ class GameOverSubState extends MusicBeatSubState // Pluck Boyfriend from the PlayState and place him (in the same position) in the GameOverSubState. // We can then play the character's `firstDeath` animation. - boyfriend = PlayState.instance.currentStage.getBoyfriend(true); - boyfriend.isDead = true; - add(boyfriend); - boyfriend.resetCharacter(); + if (PlayState.instance.isMinimalMode) {} + else + { + boyfriend = PlayState.instance.currentStage.getBoyfriend(true); + boyfriend.isDead = true; + add(boyfriend); + boyfriend.resetCharacter(); - // 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; - var offsets:Array = boyfriend.getDeathCameraOffsets(); - cameraFollowPoint.x += offsets[0]; - cameraFollowPoint.y += offsets[1]; - add(cameraFollowPoint); + // 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; + var offsets:Array = boyfriend.getDeathCameraOffsets(); + cameraFollowPoint.x += offsets[0]; + cameraFollowPoint.y += offsets[1]; + add(cameraFollowPoint); - FlxG.camera.target = null; - FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01); + FlxG.camera.target = null; + FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01); + targetCameraZoom = PlayState?.instance?.currentStage?.camZoom * boyfriend.getDeathCameraZoom(); + } // // Set up the audio // - // Prepare the game over music. - FlxG.sound.list.add(gameOverMusic); - gameOverMusic.stop(); - // The conductor now represents the BPM of the game over music. Conductor.instance.update(0); } + public function resetCameraZoom():Void + { + // Apply camera zoom level from stage data. + FlxG.camera.zoom = PlayState?.instance?.currentStage?.camZoom ?? 1.0; + } + var hasStartedAnimation:Bool = false; - override function update(elapsed:Float) + override function update(elapsed:Float):Void { if (!hasStartedAnimation) { hasStartedAnimation = true; - if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100)) + if (PlayState.instance.isMinimalMode) { - boyfriend.playAnimation('fakeoutDeath', true, false); - } - else - { - boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over! // Play the "blue balled" sound. May play a variant if one has been assigned. playBlueBalledSFX(); } + else + { + if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100)) + { + boyfriend.playAnimation('fakeoutDeath', true, false); + } + else + { + boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over! + // Play the "blue balled" sound. May play a variant if one has been assigned. + playBlueBalledSFX(); + } + } } + // Smoothly lerp the camera + FlxG.camera.zoom = MathUtil.smoothLerp(FlxG.camera.zoom, targetCameraZoom, elapsed, CAMERA_ZOOM_DURATION); + // // Handle user inputs. // @@ -216,7 +247,7 @@ class GameOverSubState extends MusicBeatSubState } } - if (gameOverMusic.playing) + if (gameOverMusic != null && gameOverMusic.playing) { // Match the conductor to the music. // This enables the stepHit and beatHit events. @@ -224,27 +255,34 @@ class GameOverSubState extends MusicBeatSubState } else { - // Music hasn't started yet. - switch (PlayStatePlaylist.campaignId) + if (PlayState.instance.isMinimalMode) { - // TODO: Make the behavior for playing Jeff's voicelines generic or un-hardcoded. - // This will simplify the class and make it easier for mods to add death quotes. - case "week7": - if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote) - { - playingJeffQuote = true; - playJeffQuote(); - // Start music at lower volume - startDeathMusic(0.2, false); - boyfriend.playAnimation('deathLoop' + animationSuffix); - } - default: - // Start music at normal volume once the initial death animation finishes. - if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) - { - startDeathMusic(1.0, false); - boyfriend.playAnimation('deathLoop' + animationSuffix); - } + // startDeathMusic(1.0, false); + } + else + { + // Music hasn't started yet. + switch (PlayStatePlaylist.campaignId) + { + // TODO: Make the behavior for playing Jeff's voicelines generic or un-hardcoded. + // This will simplify the class and make it easier for mods to add death quotes. + case 'week7': + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote) + { + playingJeffQuote = true; + playJeffQuote(); + // Start music at lower volume + startDeathMusic(0.2, false); + boyfriend.playAnimation('deathLoop' + animationSuffix); + } + default: + // Start music at normal volume once the initial death animation finishes. + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) + { + startDeathMusic(1.0, false); + boyfriend.playAnimation('deathLoop' + animationSuffix); + } + } } } @@ -262,7 +300,11 @@ class GameOverSubState extends MusicBeatSubState isEnding = true; startDeathMusic(1.0, true); // isEnding changes this function's behavior. - boyfriend.playAnimation('deathConfirm' + animationSuffix, true); + if (PlayState.instance.isMinimalMode) {} + else + { + boyfriend.playAnimation('deathConfirm' + animationSuffix, true); + } // After the animation finishes... new FlxTimer().start(0.7, function(tmr:FlxTimer) { @@ -272,10 +314,17 @@ class GameOverSubState extends MusicBeatSubState FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true); PlayState.instance.needsReset = true; - // Readd Boyfriend to the stage. - boyfriend.isDead = false; - remove(boyfriend); - PlayState.instance.currentStage.addCharacter(boyfriend, BF); + if (PlayState.instance.isMinimalMode) {} + else + { + // Readd Boyfriend to the stage. + boyfriend.isDead = false; + remove(boyfriend); + PlayState.instance.currentStage.addCharacter(boyfriend, BF); + } + + // Snap reset the camera which may have changed because of the player character data. + resetCameraZoom(); // Close the substate. close(); @@ -291,24 +340,71 @@ class GameOverSubState extends MusicBeatSubState ScriptEventDispatcher.callEvent(boyfriend, event); } + /** + * Rather than hardcoding stuff, we look for the presence of a music file + * with the given suffix, and strip it down until we find one that's valid. + */ + function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null + { + var basePath = 'gameplay/gameover/gameOver'; + if (starting) basePath += 'Start'; + else if (ending) basePath += 'End'; + + var musicPath = Paths.music(basePath + suffix); + while (!Assets.exists(musicPath) && suffix.length > 0) + { + suffix = suffix.split('-').slice(0, -1).join('-'); + musicPath = Paths.music(basePath + suffix); + } + if (!Assets.exists(musicPath)) return null; + trace('Resolved music path: ' + musicPath); + return musicPath; + } + /** * Starts the death music at the appropriate volume. * @param startingVolume */ - function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void + public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void { - var musicPath = Paths.music('gameplay/gameover/gameOver' + musicSuffix); - if (isEnding) + var musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding); + var onComplete = null; + if (isStarting) { - musicPath = Paths.music('gameplay/gameover/gameOverEnd' + musicSuffix); + if (musicPath == null) + { + isStarting = false; + musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding); + } + else + { + onComplete = function() { + isStarting = false; + // We need to force to ensure that the non-starting music plays. + startDeathMusic(1.0, true); + }; + } } - if (!gameOverMusic.playing || force) + + if (musicPath == null) { - gameOverMusic.loadEmbedded(musicPath); + trace('Could not find game over music!'); + return; + } + else if (gameOverMusic == null || !gameOverMusic.playing || force) + { + if (gameOverMusic != null) gameOverMusic.stop(); + gameOverMusic = FunkinSound.load(musicPath); gameOverMusic.volume = startingVolume; - gameOverMusic.looped = !isEnding; + gameOverMusic.looped = !(isEnding || isStarting); + gameOverMusic.onComplete = onComplete; gameOverMusic.play(); } + else + { + @:privateAccess + trace('Music already playing! ${gameOverMusic?._label}'); + } } static var blueballed:Bool = false; @@ -320,7 +416,14 @@ class GameOverSubState extends MusicBeatSubState public static function playBlueBalledSFX() { blueballed = true; - FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); + if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix))) + { + FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); + } + else + { + FlxG.log.error('Missing blue ball sound effect: ' + Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); + } } var playingJeffQuote:Bool = false; @@ -344,6 +447,14 @@ class GameOverSubState extends MusicBeatSubState }); } + public override function destroy() + { + super.destroy(); + if (gameOverMusic != null) gameOverMusic.stop(); + gameOverMusic = null; + instance = null; + } + public override function toString():String { return "GameOverSubState"; diff --git a/source/funkin/play/GitarooPause.hx b/source/funkin/play/GitarooPause.hx index edeb4229c..eae56a9c3 100644 --- a/source/funkin/play/GitarooPause.hx +++ b/source/funkin/play/GitarooPause.hx @@ -3,6 +3,7 @@ package funkin.play; import flixel.FlxSprite; import flixel.graphics.frames.FlxAtlasFrames; import funkin.play.PlayState; +import funkin.graphics.FunkinSprite; import funkin.ui.MusicBeatState; import flixel.addons.transition.FlxTransitionableState; import funkin.ui.mainmenu.MainMenuState; @@ -27,25 +28,22 @@ class GitarooPause extends MusicBeatState { if (FlxG.sound.music != null) FlxG.sound.music.stop(); - var bg:FlxSprite = new FlxSprite().loadGraphic(Paths.image('pauseAlt/pauseBG')); + var bg:FunkinSprite = FunkinSprite.create('pauseAlt/pauseBG'); add(bg); - var bf:FlxSprite = new FlxSprite(0, 30); - bf.frames = Paths.getSparrowAtlas('pauseAlt/bfLol'); + var bf:FunkinSprite = FunkinSprite.createSparrow(0, 30, 'pauseAlt/bfLol'); bf.animation.addByPrefix('lol', "funnyThing", 13); bf.animation.play('lol'); add(bf); bf.screenCenter(X); - replayButton = new FlxSprite(FlxG.width * 0.28, FlxG.height * 0.7); - replayButton.frames = Paths.getSparrowAtlas('pauseAlt/pauseUI'); + replayButton = FunkinSprite.createSparrow(FlxG.width * 0.28, FlxG.height * 0.7, 'pauseAlt/pauseUI'); replayButton.animation.addByPrefix('selected', 'bluereplay', 0, false); replayButton.animation.appendByPrefix('selected', 'yellowreplay'); replayButton.animation.play('selected'); add(replayButton); - cancelButton = new FlxSprite(FlxG.width * 0.58, replayButton.y); - cancelButton.frames = Paths.getSparrowAtlas('pauseAlt/pauseUI'); + cancelButton = FunkinSprite.createSparrow(FlxG.width * 0.58, replayButton.y, 'pauseAlt/pauseUI'); cancelButton.animation.addByPrefix('selected', 'bluecancel', 0, false); cancelButton.animation.appendByPrefix('selected', 'cancelyellow'); cancelButton.animation.play('selected'); diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx index ff56dcb8f..5acfcfa63 100644 --- a/source/funkin/play/PauseSubState.hx +++ b/source/funkin/play/PauseSubState.hx @@ -1,316 +1,738 @@ package funkin.play; -import funkin.play.PlayStatePlaylist; -import flixel.FlxSprite; import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxG; +import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; -import funkin.ui.MusicBeatSubState; -import funkin.ui.debug.latency.LatencyState; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxMath; import flixel.sound.FlxSound; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; -import funkin.play.PlayState; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; import funkin.data.song.SongRegistry; -import funkin.ui.Alphabet; +import funkin.graphics.FunkinSprite; +import funkin.play.cutscene.VideoCutscene; +import funkin.play.PlayState; +import funkin.ui.AtlasText; +import funkin.ui.debug.latency.LatencyState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.transition.StickerSubState; +/** + * Parameters for initializing the PauseSubState. + */ +typedef PauseSubStateParams = +{ + /** + * Which mode to start in. Dictates what entries are displayed. + */ + ?mode:PauseMode, +}; + +/** + * The menu displayed when the Play State is paused. + */ class PauseSubState extends MusicBeatSubState { - var grpMenuShit:FlxTypedGroup; + // =============== + // Constants + // =============== - // todo: maybe make these enums or somethin? - final pauseOptionsBase:Array = [ - 'Resume', - 'Restart Song', - 'Change Difficulty', - 'Toggle Practice Mode', - 'Adjust Input Offsets', - 'Exit to Menu' + /** + * Pause menu entries for when the game is paused during a song. + */ + static final PAUSE_MENU_ENTRIES_STANDARD:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Change Difficulty', callback: switchMode.bind(_, Difficulty)}, + {text: 'Enable Practice Mode', callback: enablePracticeMode, filter: () -> !(PlayState.instance?.isPracticeMode ?? false)}, + {text: 'Exit to Menu', callback: quitToMenu}, ]; - final pauseOptionsCharting:Array = ['Resume', 'Restart Song', 'Adjust Input Offsets', 'Exit to Chart Editor']; - final pauseOptionsDifficultyBase:Array = ['BACK']; + /** + * Pause menu entries for when the game is paused in the Chart Editor preview. + */ + static final PAUSE_MENU_ENTRIES_CHARTING:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Return to Chart Editor', callback: quitToChartEditor}, + ]; - var pauseOptionsDifficulty:Array = []; // AUTO-POPULATED + /** + * Pause menu entries for when the user selects "Change Difficulty". + */ + static final PAUSE_MENU_ENTRIES_DIFFICULTY:Array = [ + {text: 'Back', callback: switchMode.bind(_, Standard)} + // Other entries are added dynamically. + ]; - var menuItems:Array = []; - var curSelected:Int = 0; + /** + * Pause menu entries for when the game is paused during a video cutscene. + */ + static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Cutscene', callback: restartVideoCutscene}, + {text: 'Skip Cutscene', callback: skipVideoCutscene}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; - var pauseMusic:FlxSound; + /** + * Pause menu entries for when the game is paused during a conversation. + */ + static final PAUSE_MENU_ENTRIES_CONVERSATION:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Dialogue', callback: restartConversation}, + {text: 'Skip Dialogue', callback: skipConversation}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; - var practiceText:FlxText; + /** + * Duration for the music to fade in when the pause menu is opened. + */ + static final MUSIC_FADE_IN_TIME:Float = 5; - public var exitingToMenu:Bool = false; + /** + * The final volume for the music when the pause menu is opened. + */ + static final MUSIC_FINAL_VOLUME:Float = 0.75; - var bg:FlxSprite; - var metaDataGrp:FlxTypedGroup; + /** + * Defines which pause music to use. + */ + public static var musicSuffix:String = ''; - var isChartingMode:Bool; + /** + * Reset the pause configuration to the default. + */ + public static function reset():Void + { + musicSuffix = ''; + } - public function new(isChartingMode:Bool = false) + // =============== + // Status Variables + // =============== + + /** + * Disallow input until transitions are complete! + * This prevents the pause menu from immediately closing when opened, among other things. + */ + public var allowInput:Bool = false; + + /** + * The entries currently displayed in the pause menu. + */ + var currentMenuEntries:Array; + + /** + * The index of `currentMenuEntries` that is currently selected. + */ + var currentEntry:Int = 0; + + /** + * The mode that the pause menu is currently in. + */ + var currentMode:PauseMode; + + // =============== + // Graphics Variables + // =============== + + /** + * The semi-transparent black background that appears when the game is paused. + */ + var background:FunkinSprite; + + /** + * The metadata displayed in the top right. + */ + var metadata:FlxTypedSpriteGroup; + + /** + * A text object that displays the current practice mode status. + */ + var metadataPractice:FlxText; + + /** + * A text object that displays the current death count. + */ + var metadataDeaths:FlxText; + + /** + * The actual text objects for the menu entries. + */ + var menuEntryText:FlxTypedSpriteGroup; + + // =============== + // Audio Variables + // =============== + var pauseMusic:FunkinSound; + + // =============== + // Constructor + // =============== + + public function new(?params:PauseSubStateParams) { super(); - - this.isChartingMode = isChartingMode; - - menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; - var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation); - trace('DIFFICULTIES: ${difficultiesInVariation}'); - - pauseOptionsDifficulty = difficultiesInVariation.map(function(item:String):String { - return item.toUpperCase(); - }).concat(pauseOptionsDifficultyBase); - - if (PlayStatePlaylist.campaignId == 'week6') - { - pauseMusic = new FlxSound().loadEmbedded(Paths.music('breakfast-pixel'), true, true); - } - else - { - pauseMusic = new FlxSound().loadEmbedded(Paths.music('breakfast'), true, true); - } - pauseMusic.volume = 0; - pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2))); - - FlxG.sound.list.add(pauseMusic); - - bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); - bg.alpha = 0; - bg.scrollFactor.set(); - add(bg); - - metaDataGrp = new FlxTypedGroup(); - add(metaDataGrp); - - var levelInfo:FlxText = new FlxText(20, 15, 0, '', 32); - if (PlayState.instance.currentChart != null) - { - levelInfo.text += '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}'; - } - levelInfo.scrollFactor.set(); - levelInfo.setFormat(Paths.font('vcr.ttf'), 32); - levelInfo.updateHitbox(); - metaDataGrp.add(levelInfo); - - var levelDifficulty:FlxText = new FlxText(20, 15 + 32, 0, '', 32); - levelDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase(); - levelDifficulty.scrollFactor.set(); - levelDifficulty.setFormat(Paths.font('vcr.ttf'), 32); - levelDifficulty.updateHitbox(); - metaDataGrp.add(levelDifficulty); - - var deathCounter:FlxText = new FlxText(20, 15 + 64, 0, '', 32); - deathCounter.text = 'Blue balled: ${PlayState.instance.deathCounter}'; - FlxG.watch.addQuick('totalNotesHit', Highscore.tallies.totalNotesHit); - FlxG.watch.addQuick('totalNotes', Highscore.tallies.totalNotes); - deathCounter.scrollFactor.set(); - deathCounter.setFormat(Paths.font('vcr.ttf'), 32); - deathCounter.updateHitbox(); - metaDataGrp.add(deathCounter); - - practiceText = new FlxText(20, 15 + 64 + 32, 0, 'PRACTICE MODE', 32); - practiceText.scrollFactor.set(); - practiceText.setFormat(Paths.font('vcr.ttf'), 32); - practiceText.updateHitbox(); - practiceText.x = FlxG.width - (practiceText.width + 20); - practiceText.visible = PlayState.instance.isPracticeMode; - metaDataGrp.add(practiceText); - - levelDifficulty.alpha = 0; - levelInfo.alpha = 0; - deathCounter.alpha = 0; - - levelInfo.x = FlxG.width - (levelInfo.width + 20); - levelDifficulty.x = FlxG.width - (levelDifficulty.width + 20); - deathCounter.x = FlxG.width - (deathCounter.width + 20); - - FlxTween.tween(bg, {alpha: 0.6}, 0.4, {ease: FlxEase.quartInOut}); - FlxTween.tween(levelInfo, {alpha: 1, y: 20}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.3}); - FlxTween.tween(levelDifficulty, {alpha: 1, y: levelDifficulty.y + 5}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.5}); - FlxTween.tween(deathCounter, {alpha: 1, y: deathCounter.y + 5}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.7}); - - grpMenuShit = new FlxTypedGroup(); - add(grpMenuShit); - - regenMenu(); - - // cameras = [FlxG.cameras.list[FlxG.cameras.list.length - 1]]; + this.currentMode = params?.mode ?? Standard; } - function regenMenu():Void + // =============== + // Lifecycle Functions + // =============== + + /** + * Called when the state is first loaded. + */ + public override function create():Void { - while (grpMenuShit.members.length > 0) - { - grpMenuShit.remove(grpMenuShit.members[0], true); - } + super.create(); - for (i in 0...menuItems.length) - { - var songText:Alphabet = new Alphabet(0, (70 * i) + 30, menuItems[i], true, false); - songText.isMenuItem = true; - songText.targetY = i; - grpMenuShit.add(songText); - } + startPauseMusic(); - curSelected = 0; - changeSelection(); + buildBackground(); + + buildMetadata(); + + regenerateMenu(); + + transitionIn(); } - override function update(elapsed:Float):Void + /** + * Called every frame. + * @param elapsed The time elapsed since the last frame, in seconds. + */ + public override function update(elapsed:Float):Void { - if (pauseMusic.volume < 0.5) pauseMusic.volume += 0.01 * elapsed; - super.update(elapsed); handleInputs(); } + /** + * Called when the state is closed. + */ + public override function destroy():Void + { + super.destroy(); + pauseMusic.stop(); + } + + // =============== + // Initialization Functions + // =============== + + /** + * Play the pause music. + */ + function startPauseMusic():Void + { + var pauseMusicPath:String = Paths.music('breakfast$musicSuffix'); + pauseMusic = FunkinSound.load(pauseMusicPath, true, true); + + if (pauseMusic == null) + { + FlxG.log.warn('Could not play pause music: ${pauseMusicPath} does not exist!'); + } + + // Start playing at a random point in the song. + pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2))); + pauseMusic.fadeIn(MUSIC_FADE_IN_TIME, 0, MUSIC_FINAL_VOLUME); + } + + /** + * Render the semi-transparent black background. + */ + function buildBackground():Void + { + // Using state.bgColor causes bugs! + background = new FunkinSprite(0, 0); + background.makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK); + background.alpha = 0.0; + background.scrollFactor.set(0, 0); + background.updateHitbox(); + add(background); + } + + /** + * Render the metadata in the top right. + */ + function buildMetadata():Void + { + metadata = new FlxTypedSpriteGroup(); + metadata.scrollFactor.set(0, 0); + add(metadata); + + var metadataSong:FlxText = new FlxText(20, 15, FlxG.width - 40, 'Song Name - Artist'); + metadataSong.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentChart != null) + { + metadataSong.text = '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}'; + } + metadataSong.scrollFactor.set(0, 0); + metadata.add(metadataSong); + + var metadataDifficulty:FlxText = new FlxText(20, 15 + 32, FlxG.width - 40, 'Difficulty: '); + metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentDifficulty != null) + { + metadataDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase(); + } + metadataDifficulty.scrollFactor.set(0, 0); + metadata.add(metadataDifficulty); + + metadataDeaths = new FlxText(20, 15 + 64, FlxG.width - 40, '${PlayState.instance?.deathCounter} Blue Balls'); + metadataDeaths.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataDeaths.scrollFactor.set(0, 0); + metadata.add(metadataDeaths); + + metadataPractice = new FlxText(20, 15 + 96, FlxG.width - 40, 'PRACTICE MODE'); + metadataPractice.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + metadataPractice.scrollFactor.set(0, 0); + metadata.add(metadataPractice); + + updateMetadataText(); + } + + /** + * Perform additional animations to transition the pause menu in when it is first displayed. + */ + function transitionIn():Void + { + FlxTween.tween(background, {alpha: 0.6}, 0.8, {ease: FlxEase.quartOut}); + + // Animate each element a little bit downwards. + var delay:Float = 0.1; + for (child in metadata.members) + { + FlxTween.tween(child, {alpha: 1, y: child.y + 5}, 1.8, {ease: FlxEase.quartOut, startDelay: delay}); + delay += 0.1; + } + + new FlxTimer().start(0.2, (_) -> { + allowInput = true; + }); + } + + // =============== + // Input Handling + // =============== + + /** + * Process user inputs every frame. + */ function handleInputs():Void { - var upP = controls.UI_UP_P; - var downP = controls.UI_DOWN_P; - var accepted = controls.ACCEPT; + if (!allowInput) return; + + if (controls.UI_UP_P) + { + changeSelection(-1); + } + if (controls.UI_DOWN_P) + { + changeSelection(1); + } + + if (controls.ACCEPT) + { + currentMenuEntries[currentEntry].callback(this); + } + else if (controls.PAUSE) + { + resume(this); + } #if (debug || FORCE_DEBUG_VERSION) // to pause the game and get screenshots easy, press H on pause menu! if (FlxG.keys.justPressed.H) { - bg.visible = !bg.visible; - grpMenuShit.visible = !grpMenuShit.visible; - metaDataGrp.visible = !metaDataGrp.visible; + var visible = !metadata.visible; + + metadata.visible = visible; + menuEntryText.visible = visible; + this.bgColor = visible ? 0x99000000 : 0x00000000; // 60% or fully transparent black } #end - - if (!exitingToMenu) - { - if (upP) - { - changeSelection(-1); - } - if (downP) - { - changeSelection(1); - } - - var androidPause:Bool = false; - - #if android - androidPause = FlxG.android.justPressed.BACK; - #end - - if (androidPause) close(); - - if (accepted) - { - var daSelected:String = menuItems[curSelected]; - - switch (daSelected) - { - case 'Resume': - close(); - - case 'Change Difficulty': - menuItems = pauseOptionsDifficulty; - regenMenu(); - - case 'Toggle Practice Mode': - PlayState.instance.isPracticeMode = true; - practiceText.visible = PlayState.instance.isPracticeMode; - - case 'Restart Song': - PlayState.instance.needsReset = true; - close(); - - case 'Exit to Menu': - exitingToMenu = true; - PlayState.instance.deathCounter = 0; - - for (item in grpMenuShit.members) - { - item.targetY = -3; - item.alpha = 0.6; - } - - FlxTransitionableState.skipNextTransIn = true; - FlxTransitionableState.skipNextTransOut = true; - - if (PlayStatePlaylist.isStoryMode) - { - PlayStatePlaylist.reset(); - openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker))); - } - else - { - openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker))); - } - case 'Adjust Input Offsets': - openSubState(new LatencyState()); - case 'Exit to Chart Editor': - this.close(); - if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! - PlayState.instance.close(); // This only works because PlayState is a substate! - - case 'BACK': - menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; - regenMenu(); - - default: - if (pauseOptionsDifficulty.contains(daSelected)) - { - PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); - - // Reset campaign score when changing difficulty - // So if you switch difficulty on the last song of a week you get a really low overall score. - PlayStatePlaylist.campaignScore = 0; - PlayStatePlaylist.campaignDifficulty = daSelected.toLowerCase(); - PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; - - PlayState.instance.needsReset = true; - - close(); - } - else - { - trace('[WARN] Unhandled pause menu option: ${daSelected}'); - } - } - } - - if (FlxG.keys.justPressed.J) - { - // for reference later! - // PlayerSettings.player1.controls.replaceBinding(Control.LEFT, Keys, FlxKey.J, null); - } - } - } - - override function destroy():Void - { - pauseMusic.destroy(); - - super.destroy(); } + /** + * Move the current selection up or down. + * @param change The amount to change the selection by, with sign indicating direction. + */ function changeSelection(change:Int = 0):Void { FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - curSelected += change; + currentEntry += change; - if (curSelected < 0) curSelected = menuItems.length - 1; - if (curSelected >= menuItems.length) curSelected = 0; + if (currentEntry < 0) currentEntry = currentMenuEntries.length - 1; + if (currentEntry >= currentMenuEntries.length) currentEntry = 0; - for (index => item in grpMenuShit.members) + for (entryIndex in 0...currentMenuEntries.length) { - item.targetY = index - curSelected; + var isCurrent:Bool = entryIndex == currentEntry; - item.alpha = 0.6; + var entry:PauseMenuEntry = currentMenuEntries[entryIndex]; + var text:AtlasText = entry.sprite; - if (item.targetY == 0) - { - item.alpha = 1; - } + // Set the transparency. + text.alpha = isCurrent ? 1.0 : 0.6; + + // Set the position. + var targetX = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 20 + 90; + var targetY = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 120 + (FlxG.height * 0.48); + FlxTween.globalManager.cancelTweensOf(text); + FlxTween.tween(text, {x: targetX, y: targetY}, 0.33, {ease: FlxEase.quartOut}); } } + + // =============== + // Menu Functions + // =============== + + /** + * Clear the current menu entries and regenerate them based on the current mode. + * @param targetMode Optionally specify a mode to switch to before regenerating the menu. + */ + function regenerateMenu(?targetMode:PauseMode):Void + { + // If targetMode is null, keep the current mode. + if (targetMode == null) targetMode = this.currentMode; + + var previousMode:PauseMode = this.currentMode; + this.currentMode = targetMode; + + resetSelection(); + chooseMenuEntries(); + clearAndAddMenuEntries(); + updateMetadataText(); + changeSelection(); + } + + /** + * Reset the current selection to the first entry. + */ + function resetSelection():Void + { + this.currentEntry = 0; + } + + /** + * Select which menu entries to display based on the current mode. + */ + function chooseMenuEntries():Void + { + // Choose the correct menu entries. + // NOTE: We clone the arrays to prevent modifications to the arrays from affecting the original. + switch (this.currentMode) + { + case PauseMode.Standard: + currentMenuEntries = PAUSE_MENU_ENTRIES_STANDARD.clone(); + case PauseMode.Charting: + currentMenuEntries = PAUSE_MENU_ENTRIES_CHARTING.clone(); + case PauseMode.Difficulty: + // Prepend the difficulties. + var entries:Array = []; + if (PlayState.instance.currentChart != null) + { + var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation); + trace('DIFFICULTIES: ${difficultiesInVariation}'); + for (difficulty in difficultiesInVariation) + { + entries.push({text: difficulty.toTitleCase(), callback: (state) -> changeDifficulty(state, difficulty)}); + } + } + + // Add the back button. + currentMenuEntries = entries.concat(PAUSE_MENU_ENTRIES_DIFFICULTY.clone()); + case PauseMode.Conversation: + currentMenuEntries = PAUSE_MENU_ENTRIES_CONVERSATION.clone(); + case PauseMode.Cutscene: + currentMenuEntries = PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE.clone(); + } + } + + /** + * Clear the `menuEntryText` group and render the current menu entries to it. + * We first create the `menuEntryText` group if it doesn't already exist. + */ + function clearAndAddMenuEntries():Void + { + if (menuEntryText == null) + { + menuEntryText = new FlxTypedSpriteGroup(); + menuEntryText.scrollFactor.set(0, 0); + add(menuEntryText); + } + menuEntryText.clear(); + + // Render out the entries depending on the mode. + var entryIndex:Int = 0; + var toRemove = []; + for (entry in currentMenuEntries) + { + if (entry == null || (entry.filter != null && !entry.filter())) + { + // Remove entries that should be hidden. + toRemove.push(entry); + } + else + { + // Handle visible entries. + var yPos:Float = 70 * entryIndex + 30; + var text:AtlasText = new AtlasText(0, yPos, entry.text, AtlasFont.BOLD); + text.scrollFactor.set(0, 0); + text.alpha = 0; + menuEntryText.add(text); + + entry.sprite = text; + + entryIndex++; + } + } + for (entry in toRemove) + { + currentMenuEntries.remove(entry); + } + } + + // =============== + // Metadata Functions + // =============== + + /** + * Update the values for the metadata text in the top right. + */ + function updateMetadataText():Void + { + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + + switch (this.currentMode) + { + case Standard | Difficulty: + metadataDeaths.text = '${PlayState.instance?.deathCounter} Blue Balls'; + case Charting: + metadataDeaths.text = 'Chart Editor Preview'; + case Conversation: + metadataDeaths.text = 'Dialogue Paused'; + case Cutscene: + metadataDeaths.text = 'Video Paused'; + } + } + + // =============== + // Menu Callbacks + // =============== + + /** + * Close the pause menu and resume the game. + * @param state The current PauseSubState. + */ + static function resume(state:PauseSubState):Void + { + // Resume a paused video if it exists. + VideoCutscene.resumeVideo(); + + state.close(); + } + + /** + * Switch the pause menu to the indicated mode. + * Create a callback from this using `.bind(_, targetMode)`. + * @param state The current PauseSubState. + * @param targetMode The mode to switch to. + */ + static function switchMode(state:PauseSubState, targetMode:PauseMode):Void + { + state.regenerateMenu(targetMode); + } + + /** + * Switch the game's difficulty to the indicated difficulty, then resume the game. + * @param state The current PauseSubState. + * @param difficulty The difficulty to switch to. + */ + static function changeDifficulty(state:PauseSubState, difficulty:String):Void + { + PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); + + // Reset campaign score when changing difficulty + // So if you switch difficulty on the last song of a week you get a really low overall score. + PlayStatePlaylist.campaignScore = 0; + PlayStatePlaylist.campaignDifficulty = difficulty; + PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; + + PlayState.instance.needsReset = true; + + state.close(); + } + + /** + * Restart the current level, then resume the game. + * @param state The current PauseSubState. + */ + static function restartPlayState(state:PauseSubState):Void + { + PlayState.instance.needsReset = true; + state.close(); + } + + /** + * Force the game into practice mode, then update the pause menu. + * @param state The current PauseSubState. + */ + static function enablePracticeMode(state:PauseSubState):Void + { + if (PlayState.instance == null) return; + + PlayState.instance.isPracticeMode = true; + state.regenerateMenu(); + } + + /** + * Restart the paused video cutscene, then resume the game. + * @param state The current PauseSubState. + */ + static function restartVideoCutscene(state:PauseSubState):Void + { + VideoCutscene.restartVideo(); + state.close(); + } + + /** + * Skip the paused video cutscene, then resume the game. + * @param state The current PauseSubState. + */ + static function skipVideoCutscene(state:PauseSubState):Void + { + VideoCutscene.finishVideo(); + state.close(); + } + + /** + * Restart the paused conversation, then resume the game. + * @param state The current PauseSubState. + */ + static function restartConversation(state:PauseSubState):Void + { + if (PlayState.instance?.currentConversation == null) return; + + PlayState.instance.currentConversation.resetConversation(); + state.close(); + } + + /** + * Skip the paused conversation, then resume the game. + * @param state The current PauseSubState. + */ + static function skipConversation(state:PauseSubState):Void + { + if (PlayState.instance?.currentConversation == null) return; + + PlayState.instance.currentConversation.skipConversation(); + state.close(); + } + + /** + * Quit the game and return to the main menu. + * @param state The current PauseSubState. + */ + static function quitToMenu(state:PauseSubState):Void + { + state.allowInput = false; + + PlayState.instance.deathCounter = 0; + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + if (PlayStatePlaylist.isStoryMode) + { + PlayStatePlaylist.reset(); + state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker))); + } + else + { + state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker))); + } + } + + /** + * Quit the game and return to the chart editor. + * @param state The current PauseSubState. + */ + static function quitToChartEditor(state:PauseSubState):Void + { + state.close(); + if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! + PlayState.instance.close(); // This only works because PlayState is a substate! + } } + +/** + * Which set of options the pause menu should display. + */ +enum PauseMode +{ + /** + * The menu displayed when the player pauses the game during a song. + */ + Standard; + + /** + * The menu displayed when the player pauses the game during a song while in charting mode. + */ + Charting; + + /** + * The menu displayed when the player moves to change the game's difficulty. + */ + Difficulty; + + /** + * The menu displayed when the player pauses the game during a conversation. + */ + Conversation; + + /** + * The menu displayed when the player pauses the game during a video cutscene. + */ + Cutscene; +} + +/** + * Represents a single entry in the pause menu. + */ +typedef PauseMenuEntry = +{ + /** + * The text to display for this entry. + * TODO: Implement localization. + */ + var text:String; + + /** + * The callback to execute when the user selects this entry. + */ + var callback:PauseSubState->Void; + + /** + * If this returns true, the entry will be displayed. If it returns false, the entry will be hidden. + */ + var ?filter:Void->Bool; + + // Instance-specific properties + + /** + * The text object currently displaying this entry. + */ + var ?sprite:AtlasText; +}; diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 1dbba5b54..12b290afd 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,21 +1,20 @@ package funkin.play; -import flixel.addons.display.FlxPieDial; +import funkin.audio.FunkinSound; import flixel.addons.display.FlxPieDial; import flixel.addons.transition.FlxTransitionableState; -import flixel.addons.transition.FlxTransitionableState; -import flixel.addons.transition.FlxTransitionableSubState; -import flixel.addons.transition.FlxTransitionableSubState; import flixel.addons.transition.Transition; import flixel.addons.transition.Transition; import flixel.FlxCamera; import flixel.FlxObject; -import flixel.FlxSprite; import flixel.FlxState; +import funkin.graphics.FunkinSprite; import flixel.FlxSubState; +import funkin.graphics.FunkinSprite; import flixel.math.FlxMath; import flixel.math.FlxPoint; import flixel.math.FlxRect; +import funkin.graphics.FunkinSprite; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; @@ -95,7 +94,7 @@ typedef PlayStateParams = ?targetDifficulty:String, /** * The variation to play on. - * @default `Constants.DEFAULT_VARIATION` . + * @default `Constants.DEFAULT_VARIATION` */ ?targetVariation:String, /** @@ -109,6 +108,11 @@ typedef PlayStateParams = * @default `false` */ ?practiceMode:Bool, + /** + * Whether the song should start in Bot Play Mode. + * @default `false` + */ + ?botPlayMode:Bool, /** * Whether the song should be in minimal mode. * @default `false` @@ -116,8 +120,14 @@ typedef PlayStateParams = ?minimalMode:Bool, /** * If specified, the game will jump to the specified timestamp after the countdown ends. + * @default `0.0` */ ?startTimestamp:Float, + /** + * If specified, the game will play the song with the given speed. + * @default `1.0` for 100% speed. + */ + ?playbackRate:Float, /** * If specified, the game will not load the instrumental or vocal tracks, * and must be loaded externally. @@ -208,15 +218,32 @@ class PlayState extends MusicBeatSubState */ public var startTimestamp:Float = 0.0; + /** + * Play back the song at this speed. + * @default `1.0` for normal speed. + */ + public var playbackRate:Float = 1.0; + /** * An empty FlxObject contained in the scene. * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly. * * It needs to be an object in the scene for the camera to be configured to follow it. - * We optionally make this an FlxSprite so we can draw a debug graphic with it. + * We optionally make this a sprite so we can draw a debug graphic with it. */ public var cameraFollowPoint:FlxObject; + /** + * An FlxTween that tweens the camera to the follow point. + * Only used when tweening the camera manually, rather than tweening via follow. + */ + public var cameraFollowTween:FlxTween; + + /** + * An FlxTween that zooms the camera to the desired amount. + */ + public var cameraZoomTween:FlxTween; + /** * The camera follow point from the last stage. * Used to persist the position of the `cameraFollowPosition` between levels. @@ -224,14 +251,23 @@ class PlayState extends MusicBeatSubState public var previousCameraFollowPoint:FlxPoint = null; /** - * The current camera zoom level. - * - * The camera zoom is increased every beat, and lerped back to this value every frame, creating a smooth 'zoom-in' effect. - * Defaults to 1.05 but may be larger or smaller depending on the current stage, - * and may be changed by the `ZoomCamera` song event. + * The current camera zoom level without any modifiers applied. + */ + public var currentCameraZoom:Float = FlxCamera.defaultZoom * 1.05; + + /** + * currentCameraZoom is increased every beat, and lerped back to this value every frame, creating a smooth 'zoom-in' effect. + * Defaults to 1.05, but may be larger or smaller depending on the current stage. + * Tweened via the `ZoomCamera` song event in direct mode. */ public var defaultCameraZoom:Float = FlxCamera.defaultZoom * 1.05; + /** + * Camera zoom applied on top of currentCameraZoom. + * Tweened via the `ZoomCamera` song event in additive mode. + */ + public var additiveCameraZoom:Float = 0; + /** * The current HUD camera zoom level. * @@ -268,6 +304,18 @@ class PlayState extends MusicBeatSubState */ public var isPracticeMode:Bool = false; + /** + * Whether the game is currently in Bot Play Mode. + * If true, player will not lose gain or lose score from notes. + */ + public var isBotPlayMode:Bool = false; + + /** + * Whether the player has dropped below zero health, + * and we are just waiting for an animation to play out before transitioning. + */ + public var isPlayerDying:Bool = false; + /** * In Minimal Mode, the stage and characters are not loaded and a standard background is used. */ @@ -314,6 +362,11 @@ class PlayState extends MusicBeatSubState **/ var inputReleaseQueue:Array = []; + /** + * If we just unpaused the game, we shouldn't be able to pause again for one frame. + */ + var justUnpaused:Bool = false; + /** * PRIVATE INSTANCE VARIABLES * Private instance variables should be used for information that must be reset or dereferenced @@ -360,10 +413,15 @@ class PlayState extends MusicBeatSubState var startingSong:Bool = false; /** - * False if `FlxG.sound.music` + * Track if we currently have the music paused for a Pause substate, so we can unpause it when we return. */ var musicPausedBySubState:Bool = false; + /** + * Track any camera tweens we've paused for a Pause substate, so we can unpause them when we return. + */ + var cameraTweensPausedBySubState:List = new List(); + /** * False until `create()` has completed. */ @@ -400,7 +458,7 @@ class PlayState extends MusicBeatSubState * The background image used for the health bar. * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`. */ - public var healthBarBG:FlxSprite; + public var healthBarBG:FunkinSprite; /** * The health icon representing the player. @@ -442,11 +500,6 @@ class PlayState extends MusicBeatSubState */ var comboPopUps:PopUpStuff; - /** - * The circular sprite that appears while the user is holding down the Skip Cutscene button. - */ - var skipTimer:FlxPieDial; - /** * PROPERTIES */ @@ -474,7 +527,7 @@ class PlayState extends MusicBeatSubState if (!Std.isOfType(this.subState, PauseSubState)) return false; var pauseSubState:PauseSubState = cast this.subState; - return pauseSubState.exitingToMenu; + return !pauseSubState.allowInput; } /** @@ -546,8 +599,10 @@ class PlayState extends MusicBeatSubState if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty; if (params.targetVariation != null) currentVariation = params.targetVariation; isPracticeMode = params.practiceMode ?? false; + isBotPlayMode = params.botPlayMode ?? false; isMinimalMode = params.minimalMode ?? false; startTimestamp = params.startTimestamp ?? 0.0; + playbackRate = params.playbackRate ?? 1.0; overrideMusic = params.overrideMusic ?? false; previousCameraFollowPoint = params.cameraFollowPoint; @@ -568,12 +623,15 @@ class PlayState extends MusicBeatSubState if (!assertChartExists()) return; + // TODO: Add something to toggle this on! if (false) { // Displays the camera follow point as a sprite for debug purposes. - cameraFollowPoint = new FlxSprite(0, 0).makeGraphic(8, 8, 0xFF00FF00); + var cameraFollowPoint = new FunkinSprite(0, 0); + cameraFollowPoint.makeSolidColor(8, 8, 0xFF00FF00); cameraFollowPoint.visible = false; cameraFollowPoint.zIndex = 1000000; + this.cameraFollowPoint = cameraFollowPoint; } else { @@ -626,16 +684,9 @@ class PlayState extends MusicBeatSubState // Initialize the judgements and combo meter. comboPopUps = new PopUpStuff(); - comboPopUps.cameras = [camHUD]; + comboPopUps.zIndex = 900; add(comboPopUps); - - // The little dial that shows up when you hold the Skip Cutscene key. - skipTimer = new FlxPieDial(16, 16, 32, FlxColor.WHITE, 36, CIRCLE, true, 24); - skipTimer.amount = 0; - skipTimer.zIndex = 1000; - add(skipTimer); - // Renders only in video cutscene mode. - skipTimer.cameras = [camCutscene]; + comboPopUps.cameras = [camHUD]; #if discord_rpc // Initialize Discord Rich Presence. @@ -752,6 +803,8 @@ class PlayState extends MusicBeatSubState public override function update(elapsed:Float):Void { + // TOTAL: 9.42% CPU Time when profiled in VS 2019. + if (criticalFailure) return; super.update(elapsed); @@ -772,12 +825,13 @@ class PlayState extends MusicBeatSubState persistentDraw = true; startingSong = true; + isPlayerDying = false; inputSpitter = []; // Reset music properly. - - FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instance.instrumentalOffset); + FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset; + FlxG.sound.music.pitch = playbackRate; FlxG.sound.music.pause(); if (!overrideMusic) @@ -853,7 +907,7 @@ class PlayState extends MusicBeatSubState #end // Attempt to pause the game. - if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame) + if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame && !justUnpaused) { var event = new PauseScriptEvent(FlxG.random.bool(1 / 1000)); @@ -888,12 +942,12 @@ class PlayState extends MusicBeatSubState boyfriendPos = currentStage.getBoyfriend().getScreenPosition(); } - var pauseSubState:FlxSubState = new PauseSubState(isChartingMode); + var pauseSubState:FlxSubState = new PauseSubState({mode: isChartingMode ? Charting : Standard}); - FlxTransitionableSubState.skipNextTransIn = true; - FlxTransitionableSubState.skipNextTransOut = true; - openSubState(pauseSubState); + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; pauseSubState.camera = camHUD; + openSubState(pauseSubState); // boyfriendPos.put(); // TODO: Why is this here? } @@ -910,14 +964,17 @@ class PlayState extends MusicBeatSubState // Lerp the camera zoom towards the target level. if (subState == null) { - FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95); + currentCameraZoom = FlxMath.lerp(defaultCameraZoom, currentCameraZoom, 0.95); + FlxG.camera.zoom = currentCameraZoom + additiveCameraZoom; + camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95); } - if (currentStage != null) + if (currentStage != null && currentStage.getBoyfriend() != null) { FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation()); } + FlxG.watch.addQuick('health', health); // TODO: Add a song event for Handle GF dance speed. @@ -939,7 +996,7 @@ class PlayState extends MusicBeatSubState } #end - if (health <= Constants.HEALTH_MIN && !isPracticeMode) + if (health <= Constants.HEALTH_MIN && !isPracticeMode && !isPlayerDying) { vocals.pause(); FlxG.sound.music.pause(); @@ -965,24 +1022,61 @@ class PlayState extends MusicBeatSubState } #end - var gameOverSubState = new GameOverSubState( - { - isChartingMode: isChartingMode, - transparent: persistentDraw + isPlayerDying = true; + + var deathPreTransitionDelay = currentStage?.getBoyfriend()?.getDeathPreTransitionDelay() ?? 0.0; + if (deathPreTransitionDelay > 0) + { + new FlxTimer().start(deathPreTransitionDelay, function(_) { + moveToGameOver(); }); - FlxTransitionableSubState.skipNextTransIn = true; - FlxTransitionableSubState.skipNextTransOut = true; - openSubState(gameOverSubState); + } + else + { + // Transition immediately. + moveToGameOver(); + } #if 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 } + else if (isPlayerDying) + { + // Wait up. + } } + processSongEvents(); + + // Handle keybinds. + processInputQueue(); + if (!isInCutscene && !disableKeys) debugKeyShit(); + if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed); + + // Moving notes into position is now done by Strumline.update(). + if (!isInCutscene) processNotes(elapsed); + + justUnpaused = false; + } + + function moveToGameOver():Void + { + var gameOverSubState = new GameOverSubState( + { + isChartingMode: isChartingMode, + transparent: persistentDraw + }); + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + openSubState(gameOverSubState); + } + + function processSongEvents():Void + { // Query and activate song events. - // TODO: Check that these work even when songPosition is less than 0. + // TODO: Check that these work appropriately even when songPosition is less than 0, to play events during countdown. if (songEvents != null && songEvents.length > 0) { var songEventsToActivate:Array = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition); @@ -992,8 +1086,9 @@ class PlayState extends MusicBeatSubState trace('Found ${songEventsToActivate.length} event(s) to activate.'); for (event in songEventsToActivate) { - // If an event is trying to play, but it's over 5 seconds old, skip it. - if (event.time - Conductor.instance.songPosition < -5000) + // If an event is trying to play, but it's over 1 second old, skip it. + var eventAge:Float = Conductor.instance.songPosition - event.time; + if (eventAge > 1000) { event.activated = true; continue; @@ -1009,14 +1104,6 @@ class PlayState extends MusicBeatSubState } } } - - // Handle keybinds. - processInputQueue(); - if (!isInCutscene && !disableKeys) debugKeyShit(); - if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed); - - // Moving notes into position is now done by Strumline.update(). - processNotes(elapsed); } public override function dispatchEvent(event:ScriptEvent):Void @@ -1057,11 +1144,30 @@ class PlayState extends MusicBeatSubState // Pause the music. if (FlxG.sound.music != null) { - musicPausedBySubState = FlxG.sound.music.playing; - FlxG.sound.music.pause(); + if (FlxG.sound.music.playing) + { + FlxG.sound.music.pause(); + musicPausedBySubState = true; + } + + // Pause vocals. + // Not tracking that we've done this via a bool because vocal re-syncing involves pausing the vocals anyway. if (vocals != null) vocals.pause(); } + // Pause camera tweening, and keep track of which tweens we pause. + if (cameraFollowTween != null && cameraFollowTween.active) + { + cameraFollowTween.active = false; + cameraTweensPausedBySubState.add(cameraFollowTween); + } + + if (cameraZoomTween != null && cameraZoomTween.active) + { + cameraZoomTween.active = false; + cameraTweensPausedBySubState.add(cameraZoomTween); + } + // Pause the countdown. Countdown.pauseCountdown(); } @@ -1083,12 +1189,26 @@ class PlayState extends MusicBeatSubState if (event.eventCanceled) return; - // Resume + // Resume music if we paused it. if (musicPausedBySubState) { - FlxG.sound.music.play(FlxG.sound.music.time); + FlxG.sound.music.play(); + musicPausedBySubState = false; } + // Resume camera tweens if we paused any. + for (camTween in cameraTweensPausedBySubState) + { + camTween.active = true; + } + cameraTweensPausedBySubState.clear(); + + if (currentConversation != null) + { + currentConversation.resumeMusic(); + } + + // Re-sync vocals. if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); // Resume the countdown. @@ -1105,6 +1225,8 @@ class PlayState extends MusicBeatSubState DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC); } #end + + justUnpaused = true; } else if (Std.isOfType(subState, Transition)) { @@ -1234,7 +1356,7 @@ class PlayState extends MusicBeatSubState if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.instance.currentBeat % cameraZoomRate == 0) { // Zoom camera in (1.5%) - FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom; + currentCameraZoom += cameraZoomIntensity * defaultCameraZoom; // Hud zooms double (3%) camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom; } @@ -1265,6 +1387,7 @@ class PlayState extends MusicBeatSubState { var animShit:ComboMilestone = new ComboMilestone(-100, 300, Highscore.tallies.combo); animShit.scrollFactor.set(0.6, 0.6); + animShit.zIndex = 1100; animShit.cameras = [camHUD]; add(animShit); @@ -1286,12 +1409,6 @@ class PlayState extends MusicBeatSubState public override function destroy():Void { - if (currentConversation != null) - { - remove(currentConversation); - currentConversation.kill(); - } - performCleanup(); super.destroy(); @@ -1348,21 +1465,24 @@ class PlayState extends MusicBeatSubState function initHealthBar():Void { var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9; - healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar')); + healthBarBG = FunkinSprite.create(0, healthBarYPos, 'healthBar'); healthBarBG.screenCenter(X); healthBarBG.scrollFactor.set(0, 0); + healthBarBG.zIndex = 800; add(healthBarBG); healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this, 'healthLerp', 0, 2); healthBar.scrollFactor.set(); healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN); + healthBar.zIndex = 801; add(healthBar); // The score text below the health bar. scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20); scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); scoreText.scrollFactor.set(); + scoreText.zIndex = 802; add(scoreText); // Move the health bar to the HUD camera. @@ -1382,7 +1502,7 @@ class PlayState extends MusicBeatSubState function initMinimalMode():Void { // Create the green background. - var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + var menuBG = FunkinSprite.create('menuDesat'); menuBG.color = 0xFF4CAF50; menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); menuBG.updateHitbox(); @@ -1408,8 +1528,7 @@ class PlayState extends MusicBeatSubState var event:ScriptEvent = new ScriptEvent(CREATE, false); ScriptEventDispatcher.callEvent(currentStage, event); - // Apply camera zoom level from stage data. - defaultCameraZoom = currentStage.camZoom; + resetCameraZoom(); // Add the stage to the scene. this.add(currentStage); @@ -1425,6 +1544,18 @@ class PlayState extends MusicBeatSubState } } + public function resetCameraZoom():Void + { + if (PlayState.instance.isMinimalMode) return; + // Apply camera zoom level from stage data. + defaultCameraZoom = currentStage.camZoom; + currentCameraZoom = defaultCameraZoom; + FlxG.camera.zoom = currentCameraZoom; + + // Reset additive zoom. + additiveCameraZoom = 0; + } + /** * Generates the character sprites and adds them to the stage. */ @@ -1463,16 +1594,17 @@ class PlayState extends MusicBeatSubState if (dad != null) { dad.characterType = CharacterType.DAD; - } - // - // OPPONENT HEALTH ICON - // - iconP2 = new HealthIcon('dad', 1); - iconP2.y = healthBar.y - (iconP2.height / 2); - dad.initHealthIcon(true); // Apply the character ID here - add(iconP2); - iconP2.cameras = [camHUD]; + // + // OPPONENT HEALTH ICON + // + iconP2 = new HealthIcon('dad', 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + dad.initHealthIcon(true); // Apply the character ID here + iconP2.zIndex = 850; + add(iconP2); + iconP2.cameras = [camHUD]; + } // // BOYFRIEND @@ -1482,16 +1614,17 @@ class PlayState extends MusicBeatSubState if (boyfriend != null) { boyfriend.characterType = CharacterType.BF; - } - // - // PLAYER HEALTH ICON - // - iconP1 = new HealthIcon('bf', 0); - iconP1.y = healthBar.y - (iconP1.height / 2); - boyfriend.initHealthIcon(false); // Apply the character ID here - add(iconP1); - iconP1.cameras = [camHUD]; + // + // PLAYER HEALTH ICON + // + iconP1 = new HealthIcon('bf', 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + boyfriend.initHealthIcon(false); // Apply the character ID here + iconP1.zIndex = 850; + add(iconP1); + iconP1.cameras = [camHUD]; + } // // ADD CHARACTERS TO SCENE @@ -1548,8 +1681,10 @@ class PlayState extends MusicBeatSubState var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId); if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault(); - playerStrumline = new Strumline(noteStyle, true); + playerStrumline = new Strumline(noteStyle, !isBotPlayMode); + playerStrumline.onNoteIncoming.add(onStrumlineNoteIncoming); opponentStrumline = new Strumline(noteStyle, false); + opponentStrumline.onNoteIncoming.add(onStrumlineNoteIncoming); add(playerStrumline); add(opponentStrumline); @@ -1557,13 +1692,13 @@ class PlayState extends MusicBeatSubState playerStrumline.x = FlxG.width / 2 + Constants.STRUMLINE_X_OFFSET; // Classic style // playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; // Centered style playerStrumline.y = Preferences.downscroll ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET; - playerStrumline.zIndex = 200; + playerStrumline.zIndex = 1001; playerStrumline.cameras = [camHUD]; // Position the opponent strumline on the left half of the screen opponentStrumline.x = Constants.STRUMLINE_X_OFFSET; opponentStrumline.y = Preferences.downscroll ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET; - opponentStrumline.zIndex = 100; + opponentStrumline.zIndex = 1000; opponentStrumline.cameras = [camHUD]; if (!PlayStatePlaylist.isStoryMode) @@ -1666,7 +1801,6 @@ class PlayState extends MusicBeatSubState if (strumTime < startTime) continue; // Skip notes that are before the start time. var noteData:Int = songNote.getDirection(); - var playerNote:Bool = true; if (noteData > 3) playerNote = false; @@ -1675,6 +1809,8 @@ class PlayState extends MusicBeatSubState { case 0: playerNoteData.push(songNote); + // increment totalNotes for total possible notes able to be hit by the player + Highscore.tallies.totalNotes++; case 1: opponentNoteData.push(songNote); } @@ -1684,6 +1820,13 @@ class PlayState extends MusicBeatSubState opponentStrumline.applyNoteData(opponentNoteData); } + function onStrumlineNoteIncoming(noteSprite:NoteSprite):Void + { + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_INCOMING, noteSprite, 0, false); + + dispatchEvent(event); + } + /** * Prepares to start the countdown. * Ends any running cutscenes, creates the strumlines, and starts the countdown. @@ -1710,6 +1853,7 @@ class PlayState extends MusicBeatSubState currentConversation = ConversationRegistry.instance.fetchEntry(conversationId); if (currentConversation == null) return; + if (!currentConversation.alive) currentConversation.revive(); currentConversation.completeCallback = onConversationComplete; currentConversation.cameras = [camCutscene]; @@ -1727,8 +1871,13 @@ class PlayState extends MusicBeatSubState function onConversationComplete():Void { isInCutscene = false; - remove(currentConversation); - currentConversation = null; + + if (currentConversation != null) + { + currentConversation.kill(); + remove(currentConversation); + currentConversation = null; + } if (startingSong && !isInCountdown) { @@ -1750,16 +1899,21 @@ class PlayState extends MusicBeatSubState currentChart.playInst(1.0, false); } - FlxG.sound.music.onComplete = endSong; + FlxG.sound.music.onComplete = endSong.bind(false); // 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.time = startTimestamp - Conductor.instance.instrumentalOffset; + FlxG.sound.music.play(true, startTimestamp - Conductor.instance.instrumentalOffset); + FlxG.sound.music.pitch = playbackRate; + + // I am going insane. + FlxG.sound.music.volume = 1.0; + + FlxG.sound.music.fadeTween?.cancel(); trace('Playing vocals...'); add(vocals); - - FlxG.sound.music.play(FlxG.sound.music.time); vocals.play(); + vocals.pitch = playbackRate; resyncVocals(); #if discord_rpc @@ -1798,7 +1952,14 @@ class PlayState extends MusicBeatSubState function updateScoreText():Void { // TODO: Add functionality for modules to update the score text. - scoreText.text = 'Score:' + songScore; + if (isBotPlayMode) + { + scoreText.text = 'Bot Play Enabled'; + } + else + { + scoreText.text = 'Score:' + songScore; + } } /** @@ -1806,7 +1967,14 @@ class PlayState extends MusicBeatSubState */ function updateHealthBar():Void { - healthLerp = FlxMath.lerp(healthLerp, health, 0.15); + if (isBotPlayMode) + { + healthLerp = Constants.HEALTH_MAX; + } + else + { + healthLerp = FlxMath.lerp(healthLerp, health, 0.15); + } } /** @@ -1850,13 +2018,16 @@ class PlayState extends MusicBeatSubState if (Conductor.instance.songPosition > hitWindowEnd) { - if (note.hasMissed) continue; + if (note.hasMissed || note.hasBeenHit) continue; note.tooEarly = false; note.mayHit = false; note.hasMissed = true; - if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.missedNote = true; + } } else if (Conductor.instance.songPosition > hitWindowCenter) { @@ -1864,7 +2035,7 @@ class PlayState extends MusicBeatSubState // Call an event to allow canceling the note hit. // NOTE: This is what handles the character animations! - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, 0, true); + var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', 0); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! @@ -1943,10 +2114,38 @@ class PlayState extends MusicBeatSubState if (Conductor.instance.songPosition > hitWindowEnd) { + if (note.hasMissed || note.hasBeenHit) continue; note.tooEarly = false; note.mayHit = false; note.hasMissed = true; - if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.missedNote = true; + } + } + else if (isBotPlayMode && Conductor.instance.songPosition > hitWindowCenter) + { + if (note.hasBeenHit) continue; + + // We call onHitNote to play the proper animations, + // but not goodNoteHit! This means zero score and zero notes hit for the results screen! + + // Call an event to allow canceling the note hit. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', 0); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Command the bot to hit the note on time. + // NOTE: This is what handles the strumline and cleaning up the note itself! + playerStrumline.hitNote(note); + + if (note.holdNoteSprite != null) + { + playerStrumline.playNoteHoldCover(note.holdNoteSprite); + } } else if (Conductor.instance.songPosition > hitWindowStart) { @@ -1969,7 +2168,7 @@ class PlayState extends MusicBeatSubState { // Call an event to allow canceling the note miss. // NOTE: This is what handles the character animations! - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, 0, true); + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, -Constants.HEALTH_MISS_PENALTY, 0, true); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! @@ -1978,7 +2177,7 @@ class PlayState extends MusicBeatSubState // Judge the miss. // NOTE: This is what handles the scoring. trace('Missed note! ${note.noteData}'); - onNoteMiss(note); + onNoteMiss(note, event.playSound, event.healthChange); note.handledMiss = true; } @@ -1991,7 +2190,7 @@ class PlayState extends MusicBeatSubState if (holdNote == null || !holdNote.alive) continue; // While the hold note is being hit, and there is length on the hold note... - if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0) + if (!isBotPlayMode && holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0) { // Grant the player health. health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed; @@ -2030,6 +2229,7 @@ class PlayState extends MusicBeatSubState } } + // Respawns notes that were b playerStrumline.handleSkippedNotes(); opponentStrumline.handleSkippedNotes(); } @@ -2123,13 +2323,41 @@ class PlayState extends MusicBeatSubState function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void { - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, Highscore.tallies.combo + 1, true); + // Calculate the input latency (do this as late as possible). + // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}'); + var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp; + var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS; + // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!'); + + // Get the offset and compensate for input latency. + // Round inward (trim remainder) for consistency. + var noteDiff:Int = Std.int(Conductor.instance.songPosition - note.noteData.time - inputLatencyMs); + + var score = Scoring.scoreNote(noteDiff, PBOT1); + var daRating = Scoring.judgeNote(noteDiff, PBOT1); + + var healthChange = 0.0; + switch (daRating) + { + case 'sick': + healthChange = Constants.HEALTH_SICK_BONUS; + case 'good': + healthChange = Constants.HEALTH_GOOD_BONUS; + case 'bad': + healthChange = Constants.HEALTH_BAD_BONUS; + case 'shit': + healthChange = Constants.HEALTH_SHIT_BONUS; + } + + // Send the note hit event. + var event:HitNoteScriptEvent = new HitNoteScriptEvent(note, healthChange, score, daRating, Highscore.tallies.combo + 1); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! if (event.eventCanceled) return; - popUpScore(note, input); + // Display the combo meter and add the calculation to the score. + popUpScore(note, event.score, event.judgement, event.healthChange); if (note.isHoldNote && note.holdNoteSprite != null) { @@ -2143,15 +2371,11 @@ class PlayState extends MusicBeatSubState * Called when a note leaves the screen and is considered missed by the player. * @param note */ - function onNoteMiss(note:NoteSprite):Void + function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthChange:Float):Void { - // a MISS is when you let a note scroll past you!! - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Highscore.tallies.combo, true); - dispatchEvent(event); - // Calling event.cancelEvent() skips all the other logic! Neat! - if (event.eventCanceled) return; + // If we are here, we already CALLED the onNoteMiss script hook! - health -= Constants.HEALTH_MISS_PENALTY; + health += healthChange; songScore -= 10; if (!isPracticeMode) @@ -2201,7 +2425,7 @@ class PlayState extends MusicBeatSubState Highscore.tallies.combo = comboPopUps.displayCombo(0); } - if (event.playSound) + if (playSound) { vocals.playerVolume = 0; FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); @@ -2274,11 +2498,6 @@ class PlayState extends MusicBeatSubState if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible; #end - // Eject button - if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); - - if (FlxG.keys.justPressed.F5) debug_refreshModules(); - // Open the stage editor overlaying the current state. if (controls.DEBUG_STAGE) { @@ -2301,7 +2520,7 @@ class PlayState extends MusicBeatSubState #if (debug || FORCE_DEBUG_VERSION) // 1: End the song immediately. - if (FlxG.keys.justPressed.ONE) endSong(); + if (FlxG.keys.justPressed.ONE) endSong(true); // 2: Gain 10% health. if (FlxG.keys.justPressed.TWO) health += 0.1 * Constants.HEALTH_MAX; @@ -2328,23 +2547,10 @@ class PlayState extends MusicBeatSubState /** * Handles health, score, and rating popups when a note is hit. */ - function popUpScore(daNote:NoteSprite, input:PreciseInputEvent):Void + function popUpScore(daNote:NoteSprite, score:Int, daRating:String, healthChange:Float):Void { vocals.playerVolume = 1; - // Calculate the input latency (do this as late as possible). - // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}'); - var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp; - var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS; - // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!'); - - // Get the offset and compensate for input latency. - // Round inward (trim remainder) for consistency. - var noteDiff:Int = Std.int(Conductor.instance.songPosition - daNote.noteData.time - inputLatencyMs); - - var score = Scoring.scoreNote(noteDiff, PBOT1); - var daRating = Scoring.judgeNote(noteDiff, PBOT1); - if (daRating == 'miss') { // If daRating is 'miss', that means we made a mistake and should not continue. @@ -2359,22 +2565,20 @@ class PlayState extends MusicBeatSubState { case 'sick': Highscore.tallies.sick += 1; - health += Constants.HEALTH_SICK_BONUS; isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK; case 'good': Highscore.tallies.good += 1; - health += Constants.HEALTH_GOOD_BONUS; isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK; case 'bad': Highscore.tallies.bad += 1; - health += Constants.HEALTH_BAD_BONUS; isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK; case 'shit': Highscore.tallies.shit += 1; - health += Constants.HEALTH_SHIT_BONUS; isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK; } + health += healthChange; + if (isComboBreak) { // Break the combo, but don't increment tallies.misses. @@ -2444,67 +2648,71 @@ class PlayState extends MusicBeatSubState */ function handleCutsceneKeys(elapsed:Float):Void { + if (isGamePaused) return; + if (currentConversation != null) { - if (controls.CUTSCENE_ADVANCE) currentConversation?.advanceConversation(); - - if (controls.CUTSCENE_SKIP) + // Pause/unpause may conflict with advancing the conversation! + if (controls.CUTSCENE_ADVANCE && !justUnpaused) { - currentConversation?.trySkipConversation(elapsed); + currentConversation.advanceConversation(); } - else + else if (controls.PAUSE && !justUnpaused) { - currentConversation?.trySkipConversation(-1); + currentConversation.pauseMusic(); + + var pauseSubState:FlxSubState = new PauseSubState({mode: Conversation}); + + persistentUpdate = false; + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + pauseSubState.camera = camCutscene; + openSubState(pauseSubState); } } else if (VideoCutscene.isPlaying()) { // This is a video cutscene. + if (controls.PAUSE && !justUnpaused) + { + VideoCutscene.pauseVideo(); - if (controls.CUTSCENE_SKIP) - { - trySkipVideoCutscene(elapsed); - } - else - { - trySkipVideoCutscene(-1); + var pauseSubState:FlxSubState = new PauseSubState({mode: Cutscene}); + + persistentUpdate = false; + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + pauseSubState.camera = camCutscene; + openSubState(pauseSubState); } } } /** - * Handle logic for the skip timer. - * If the skip button is being held, pass the amount of time elapsed since last game update. - * If the skip button has been released, pass a negative number. + * Handle logic for actually skipping a video cutscene after it has been held. */ - function trySkipVideoCutscene(elapsed:Float):Void + function skipVideoCutscene():Void { - if (skipTimer == null || skipTimer.animation == null) return; - - if (elapsed < 0) - { - skipHeldTimer = 0.0; - } - else - { - skipHeldTimer += elapsed; - } - - skipTimer.visible = skipHeldTimer >= 0.05; - skipTimer.amount = Math.min(skipHeldTimer / 1.5, 1.0); - - if (skipHeldTimer >= 1.5) - { - VideoCutscene.finishVideo(); - } + 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. */ - function endSong():Void + public function endSong(rightGoddamnNow:Bool = false):Void { - dispatchEvent(new ScriptEvent(SONG_END)); + FlxG.sound.music.volume = 0; + vocals.volume = 0; + mayPauseGame = false; + + // Check if any events want to prevent the song from ending. + var event = new ScriptEvent(SONG_END, true); + dispatchEvent(event); + if (event.eventCanceled) return; #if sys // spitter for ravy, teehee!! @@ -2514,9 +2722,7 @@ class PlayState extends MusicBeatSubState #end deathCounter = 0; - mayPauseGame = false; - FlxG.sound.music.volume = 0; - vocals.volume = 0; + if (currentSong != null && currentSong.validScore) { // crackhead double thingie, sets whether was new highscore, AND saves the song! @@ -2538,9 +2744,12 @@ class PlayState extends MusicBeatSubState accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, }; - if (Save.get().isSongHighScore(currentSong.id, currentDifficulty, data)) + // adds current song data into the tallies for the level (story levels) + Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel); + + if (Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data)) { - Save.get().setSongScore(currentSong.id, currentDifficulty, data); + Save.instance.setSongScore(currentSong.id, currentDifficulty, data); #if newgrounds NGio.postScore(score, currentSong.id); #end @@ -2557,7 +2766,7 @@ class PlayState extends MusicBeatSubState if (targetSongId == null) { - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); + FunkinSound.playMusic('freakyMenu'); // transIn = FlxTransitionableState.defaultTransIn; // transOut = FlxTransitionableState.defaultTransOut; @@ -2588,9 +2797,9 @@ class PlayState extends MusicBeatSubState accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, }; - if (Save.get().isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data)) + if (Save.instance.isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data)) { - Save.get().setLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data); + Save.instance.setLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data); #if newgrounds NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}'); #end @@ -2603,7 +2812,14 @@ class PlayState extends MusicBeatSubState } else { - moveToResultsScreen(); + if (rightGoddamnNow) + { + moveToResultsScreen(); + } + else + { + zoomIntoResultsScreen(); + } } } else @@ -2621,10 +2837,10 @@ class PlayState extends MusicBeatSubState // TODO: Softcode this cutscene. if (currentSong.id == 'eggnog') { - var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom, - -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); - blackShit.scrollFactor.set(); - add(blackShit); + var blackBG:FunkinSprite = new FunkinSprite(-FlxG.width * FlxG.camera.zoom, -FlxG.height * FlxG.camera.zoom); + blackBG.makeSolidColor(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); + blackBG.scrollFactor.set(); + add(blackBG); camHUD.visible = false; isInCutscene = true; @@ -2661,7 +2877,14 @@ class PlayState extends MusicBeatSubState } else { - moveToResultsScreen(); + if (rightGoddamnNow) + { + moveToResultsScreen(); + } + else + { + zoomIntoResultsScreen(); + } } } } @@ -2678,19 +2901,23 @@ class PlayState extends MusicBeatSubState */ function performCleanup():Void { + // If the camera is being tweened, stop it. + cancelAllCameraTweens(); + + if (currentConversation != null) + { + remove(currentConversation); + currentConversation.kill(); + } + if (currentChart != null) { // TODO: Uncache the song. } - if (!overrideMusic) - { - // Stop the music. - FlxG.sound.music.pause(); - if (vocals != null) vocals.stop(); - } - else + if (overrideMusic) { + // Stop the music. Do NOT destroy it, something still references it! FlxG.sound.music.pause(); if (vocals != null) { @@ -2698,6 +2925,16 @@ class PlayState extends MusicBeatSubState remove(vocals); } } + else + { + // Stop and destroy the music. + FlxG.sound.music.pause(); + if (vocals != null) + { + vocals.destroy(); + remove(vocals); + } + } // Remove reference to stage and remove sprites from it to save memory. if (currentStage != null) @@ -2709,21 +2946,25 @@ class PlayState extends MusicBeatSubState } GameOverSubState.reset(); + PauseSubState.reset(); // Clear the static reference to this state. instance = null; } /** - * Play the camera zoom animation and move to the results screen. + * Play the camera zoom animation and then move to the results screen once it's done. */ - function moveToResultsScreen():Void + function zoomIntoResultsScreen():Void { trace('WENT TO RESULTS SCREEN!'); // Stop camera zooming on beat. cameraZoomRate = 0; + // Cancel camera tweening if it's active. + cancelAllCameraTweens(); + // If the opponent is GF, zoom in on the opponent. // Else, if there is no GF, zoom in on BF. // Else, zoom in on GF. @@ -2771,22 +3012,33 @@ class PlayState extends MusicBeatSubState { ease: FlxEase.expoIn, onComplete: function(_) { - persistentUpdate = false; - vocals.stop(); - camHUD.alpha = 1; - var res:ResultState = new ResultState( - { - storyMode: PlayStatePlaylist.isStoryMode, - title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'), - tallies: Highscore.tallies, - }); - res.camera = camHUD; - openSubState(res); + moveToResultsScreen(); } }); }); } + /** + * Move to the results screen right goddamn now. + */ + function moveToResultsScreen():Void + { + persistentUpdate = false; + vocals.stop(); + camHUD.alpha = 1; + + var talliesToUse:Tallies = PlayStatePlaylist.isStoryMode ? Highscore.talliesLevel : Highscore.tallies; + + var res:ResultState = new ResultState( + { + storyMode: PlayStatePlaylist.isStoryMode, + title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'), + tallies: talliesToUse, + }); + res.camera = camHUD; + openSubState(res); + } + /** * Pauses music and vocals easily. */ @@ -2799,15 +3051,119 @@ class PlayState extends MusicBeatSubState /** * Resets the camera's zoom level and focus point. */ - public function resetCamera():Void + public function resetCamera(?resetZoom:Bool = true, ?cancelTweens:Bool = true):Void { + // Cancel camera tweens if any are active. + if (cancelTweens) + { + cancelAllCameraTweens(); + } + FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04); FlxG.camera.targetOffset.set(); - FlxG.camera.zoom = defaultCameraZoom; + + if (resetZoom) + { + resetCameraZoom(); + } + // Snap the camera to the follow point immediately. FlxG.camera.focusOn(cameraFollowPoint.getPosition()); } + /** + * 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. + cancelCameraFollowTween(); + + if (duration == 0) + { + // Instant movement. Just reset the camera to force it to the follow point. + resetCamera(false, false); + } + else + { + // Disable camera following for the duration of the tween. + FlxG.camera.target = null; + + // Follow tween! Caching it so we can cancel/pause it later if needed. + var followPos:FlxPoint = cameraFollowPoint.getPosition() - FlxPoint.weak(FlxG.camera.width * 0.5, FlxG.camera.height * 0.5); + cameraFollowTween = FlxTween.tween(FlxG.camera.scroll, {x: followPos.x, y: followPos.y}, duration, + { + ease: ease, + onComplete: function(_) { + resetCamera(false, false); // Re-enable camera following when the tween is complete. + } + }); + } + } + + public function cancelCameraFollowTween() + { + if (cameraFollowTween != null) + { + cameraFollowTween.cancel(); + } + } + + /** + * Tweens the camera zoom to the desired amount. + */ + public function tweenCameraZoom(?zoom:Float, ?duration:Float, ?directMode:Bool, ?ease:NullFloat>):Void + { + // Cancel the current tween if it's active. + cancelCameraZoomTween(); + + var targetZoom = zoom * FlxCamera.defaultZoom; + + if (directMode) // Direct mode: Tween defaultCameraZoom for basic "smooth" zooms. + { + if (duration == 0) + { + // Instant zoom. No tween needed. + defaultCameraZoom = targetZoom; + } + else + { + // Zoom tween! Caching it so we can cancel/pause it later if needed. + cameraZoomTween = FlxTween.tween(this, {defaultCameraZoom: targetZoom}, duration, {ease: ease}); + } + } + else // Additive mode: Tween additiveCameraZoom for ease-based zooms. + { + if (duration == 0) + { + // Instant zoom. No tween needed. + additiveCameraZoom = targetZoom; + } + else + { + // Zoom tween! Caching it so we can cancel/pause it later if needed. + cameraZoomTween = FlxTween.tween(this, {additiveCameraZoom: targetZoom}, duration, {ease: ease}); + } + } + } + + public function cancelCameraZoomTween() + { + if (cameraZoomTween != null) + { + cameraZoomTween.cancel(); + } + } + + /** + * Cancel all active camera tweens simultaneously. + */ + public function cancelAllCameraTweens() + { + cancelCameraFollowTween(); + cancelCameraZoomTween(); + } + #if (debug || FORCE_DEBUG_VERSION) /** * Jumps forward or backward a number of sections in the song. @@ -2816,14 +3172,18 @@ class PlayState extends MusicBeatSubState */ function changeSection(sections:Int):Void { - FlxG.sound.music.pause(); + // FlxG.sound.music.pause(); - var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections); + var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections); var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); + // Don't go back in time to before the song started. + targetTimeMs = Math.max(0, targetTimeMs); + FlxG.sound.music.time = targetTimeMs; handleSkippedNotes(); + SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition); // regenNoteData(FlxG.sound.music.time); Conductor.instance.update(FlxG.sound.music.time); diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx index 9ffeefcfd..a78d61583 100644 --- a/source/funkin/play/ResultState.hx +++ b/source/funkin/play/ResultState.hx @@ -2,26 +2,25 @@ package funkin.play; import funkin.ui.story.StoryMenuState; import funkin.graphics.adobeanimate.FlxAtlasSprite; -import flixel.FlxBasic; import flixel.FlxSprite; -import flixel.graphics.frames.FlxAtlasFrames; +import funkin.graphics.FunkinSprite; import flixel.graphics.frames.FlxBitmapFont; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.math.FlxPoint; import funkin.ui.MusicBeatSubState; import flixel.math.FlxRect; import flixel.text.FlxBitmapText; -import flixel.text.FlxText; import flixel.tweens.FlxEase; import funkin.ui.freeplay.FreeplayState; import flixel.tweens.FlxTween; -import flixel.util.FlxColor; import flixel.util.FlxGradient; import flixel.util.FlxTimer; import funkin.graphics.shaders.LeftMaskShader; import funkin.play.components.TallyCounter; -import flxanimate.FlxAnimate.Settings; +/** + * The state for the results screen after a song or week is finished. + */ class ResultState extends MusicBeatSubState { final params:ResultsStateParams; @@ -30,8 +29,8 @@ class ResultState extends MusicBeatSubState var songName:FlxBitmapText; var difficulty:FlxSprite; - var maskShaderSongName = new LeftMaskShader(); - var maskShaderDifficulty = new LeftMaskShader(); + var maskShaderSongName:LeftMaskShader = new LeftMaskShader(); + var maskShaderDifficulty:LeftMaskShader = new LeftMaskShader(); public function new(params:ResultsStateParams) { @@ -49,22 +48,22 @@ class ResultState extends MusicBeatSubState else resultsVariation = NORMAL; - var loops = resultsVariation != SHIT; + var loops:Bool = resultsVariation != SHIT; FlxG.sound.playMusic(Paths.music("results" + resultsVariation), 1, loops); // TEMP-ish, just used to sorta "cache" the 3000x3000 image! - var cacheBullShit = new FlxSprite().loadGraphic(Paths.image("resultScreen/soundSystem")); + var cacheBullShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/soundSystem")); add(cacheBullShit); - var dumb = new FlxSprite().loadGraphic(Paths.image("resultScreen/scorePopin")); + var dumb:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/scorePopin")); add(dumb); var bg:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFECC5C, 0xFFFDC05C], 90); bg.scrollFactor.set(); add(bg); - var bgFlash:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFffeb69, 0xFFffe66a], 90); + var bgFlash:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFEB69, 0xFFFFE66A], 90); bgFlash.scrollFactor.set(); bgFlash.visible = false; add(bgFlash); @@ -96,8 +95,7 @@ class ResultState extends MusicBeatSubState bfSHIT.anim.play(); // unpauses this anim, since it's on PlayOnce! }; - var gf:FlxSprite = new FlxSprite(500, 300); - gf.frames = Paths.getSparrowAtlas('resultScreen/resultGirlfriendGOOD'); + var gf:FlxSprite = FunkinSprite.createSparrow(500, 300, 'resultScreen/resultGirlfriendGOOD'); gf.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false); gf.visible = false; gf.animation.finishCallback = _ -> { @@ -105,8 +103,7 @@ class ResultState extends MusicBeatSubState }; add(gf); - var boyfriend:FlxSprite = new FlxSprite(640, -200); - boyfriend.frames = Paths.getSparrowAtlas('resultScreen/resultBoyfriendGOOD'); + var boyfriend:FlxSprite = FunkinSprite.createSparrow(640, -200, 'resultScreen/resultBoyfriendGOOD'); boyfriend.animation.addByPrefix("fall", "Boyfriend Good", 24, false); boyfriend.visible = false; boyfriend.animation.finishCallback = function(_) { @@ -115,8 +112,7 @@ class ResultState extends MusicBeatSubState add(boyfriend); - var soundSystem:FlxSprite = new FlxSprite(-15, -180); - soundSystem.frames = Paths.getSparrowAtlas("resultScreen/soundSystem"); + var soundSystem:FlxSprite = FunkinSprite.createSparrow(-15, -180, 'resultScreen/soundSystem'); soundSystem.animation.addByPrefix("idle", "sound system", 24, false); soundSystem.visible = false; new FlxTimer().start(0.4, _ -> { @@ -162,20 +158,17 @@ class ResultState extends MusicBeatSubState FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut, startDelay: 0.5}); add(blackTopBar); - var resultsAnim:FlxSprite = new FlxSprite(-200, -10); - resultsAnim.frames = Paths.getSparrowAtlas("resultScreen/results"); + var resultsAnim:FunkinSprite = FunkinSprite.createSparrow(-200, -10, "resultScreen/results"); resultsAnim.animation.addByPrefix("result", "results", 24, false); resultsAnim.animation.play("result"); add(resultsAnim); - var ratingsPopin:FlxSprite = new FlxSprite(-150, 120); - ratingsPopin.frames = Paths.getSparrowAtlas("resultScreen/ratingsPopin"); + var ratingsPopin:FunkinSprite = FunkinSprite.createSparrow(-150, 120, "resultScreen/ratingsPopin"); ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false); ratingsPopin.visible = false; add(ratingsPopin); - var scorePopin:FlxSprite = new FlxSprite(-180, 520); - scorePopin.frames = Paths.getSparrowAtlas("resultScreen/scorePopin"); + var scorePopin:FunkinSprite = FunkinSprite.createSparrow(-180, 520, "resultScreen/scorePopin"); scorePopin.animation.addByPrefix("score", "tally score", 24, false); scorePopin.visible = false; add(scorePopin); @@ -193,6 +186,10 @@ class ResultState extends MusicBeatSubState var ratingGrp:FlxTypedGroup = new FlxTypedGroup(); add(ratingGrp); + /** + * NOTE: We display how many notes were HIT, not how many notes there were in total. + * + */ var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit); ratingGrp.add(totalHit); @@ -207,7 +204,7 @@ class ResultState extends MusicBeatSubState var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5); ratingGrp.add(tallyGood); - var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xffE6CF8A); + var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xFFE6CF8A); ratingGrp.add(tallyBad); var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A); @@ -279,13 +276,13 @@ class ResultState extends MusicBeatSubState super.create(); } - function timerThenSongName() + function timerThenSongName():Void { movingSongStuff = false; difficulty.x = 555; - var diffYTween = 122; + var diffYTween:Float = 122; difficulty.y = -difficulty.height; FlxTween.tween(difficulty, {y: diffYTween}, 0.5, {ease: FlxEase.quartOut, startDelay: 0.8}); @@ -315,7 +312,7 @@ class ResultState extends MusicBeatSubState // maskShaderSongName.frameUV = songName.frame.uv; } - override function update(elapsed:Float) + override function update(elapsed:Float):Void { // maskShaderSongName.swagSprX = songName.x; maskShaderDifficulty.swagSprX = difficulty.x; @@ -350,6 +347,10 @@ class ResultState extends MusicBeatSubState if (controls.PAUSE) { + FlxTween.tween(FlxG.sound.music, {volume: 0}, 0.8); + FlxTween.tween(FlxG.sound.music, {pitch: 3}, 0.1, {onComplete: _ -> { + FlxTween.tween(FlxG.sound.music, {pitch: 0.5}, 0.4); + }}); if (params.storyMode) { openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker))); diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx index f9dc18119..9e7aa98bf 100644 --- a/source/funkin/play/character/AnimateAtlasCharacter.hx +++ b/source/funkin/play/character/AnimateAtlasCharacter.hx @@ -46,7 +46,8 @@ class AnimateAtlasCharacter extends BaseCharacter var _skipTransformChildren:Bool = false; var animations:Map = new Map(); - var currentAnimation:String; + var currentAnimName:Null = null; + var animFinished:Bool = false; public function new(id:String) { @@ -77,6 +78,7 @@ class AnimateAtlasCharacter extends BaseCharacter var atlasSprite:FlxAtlasSprite = loadAtlasSprite(); setSprite(atlasSprite); + loadAnimations(); super.onCreate(event); @@ -86,10 +88,36 @@ class AnimateAtlasCharacter extends BaseCharacter { if ((!canPlayOtherAnims && !ignoreOther)) return; - currentAnimation = name; - var prefix:String = getAnimationData(name).prefix; - if (prefix == null) prefix = name; - this.mainSprite.playAnimation(prefix, restart, ignoreOther); + var correctName = correctAnimationName(name); + if (correctName == null) + { + trace('Could not find Atlas animation: ' + name); + return; + } + + var animData = getAnimationData(correctName); + currentAnimName = correctName; + var prefix:String = animData.prefix; + if (prefix == null) prefix = correctName; + var loop:Bool = animData.looped; + + this.mainSprite.playAnimation(prefix, restart, ignoreOther, loop); + + animFinished = false; + } + + public override function hasAnimation(name:String):Bool + { + return getAnimationData(name) != null; + } + + /** + * Returns true if the animation has finished playing. + * Never true if animation is configured to loop. + */ + public override function isAnimationFinished():Bool + { + return animFinished; } function loadAtlasSprite():FlxAtlasSprite @@ -114,7 +142,13 @@ class AnimateAtlasCharacter extends BaseCharacter } else { + // Make the game hold on the last frame. this.mainSprite.cleanupAnimation(prefix); + // currentAnimName = null; + animFinished = true; + + // Fallback to idle! + // playAnimation('idle', true, false); } } @@ -140,19 +174,30 @@ class AnimateAtlasCharacter extends BaseCharacter function loadAnimations():Void { - trace('[ATLASCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + trace('[ATLASCHAR] Attempting to load ${_data.animations.length} animations for ${characterId}'); var animData:Array = cast _data.animations; for (anim in animData) { + // Validate the animation before adding. + var prefix = anim.prefix; + if (!this.mainSprite.hasAnimation(prefix)) + { + FlxG.log.warn('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}'); + continue; + } animations.set(anim.name, anim); + trace('[ATLASCHAR] - Successfully loaded animation ${anim.name} to ${characterId}'); } + + trace('[ATLASCHAR] Loaded ${animations.size()} animations for ${characterId}'); } public override function getCurrentAnimation():String { - return this.mainSprite.getCurrentAnimation(); + // return this.mainSprite.getCurrentAnimation(); + return currentAnimName; } function getAnimationData(name:String = null):AnimateAtlasAnimation @@ -191,7 +236,11 @@ class AnimateAtlasCharacter extends BaseCharacter _skipTransformChildren = true; super.kill(); _skipTransformChildren = false; - this.mainSprite.kill(); + if (this.mainSprite != null) + { + this.mainSprite.kill(); + this.mainSprite = null; + } } /** diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index 390864148..2796f8123 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -60,7 +60,7 @@ class BaseCharacter extends Bopper @:allow(funkin.ui.debug.anim.DebugBoundingState) final _data:CharacterData; - final singTimeSec:Float; + final singTimeSteps:Float; /** * The offset between the corner of the sprite and the origin of the sprite (at the character's feet). @@ -180,7 +180,7 @@ class BaseCharacter extends Bopper { this.characterName = _data.name; this.name = _data.name; - this.singTimeSec = _data.singTime; + this.singTimeSteps = _data.singTime; this.globalOffsets = _data.offsets; this.flipX = _data.flipX; } @@ -193,6 +193,21 @@ class BaseCharacter extends Bopper return _data.death?.cameraOffsets ?? [0.0, 0.0]; } + public function getBaseScale():Float + { + return _data.scale; + } + + public function getDeathCameraZoom():Float + { + return _data.death?.cameraZoom ?? 1.0; + } + + public function getDeathPreTransitionDelay():Float + { + return _data.death?.preTransitionDelay ?? 0.0; + } + /** * Gets the value of flipX from the character data. * `!getFlipX()` is the direction Boyfriend should face. @@ -250,8 +265,8 @@ class BaseCharacter extends Bopper } /** - * Set the sprite scale to the appropriate value. - * @param scale + * Set the character's sprite scale to the appropriate value. + * @param scale The desired scale. */ public function setScale(scale:Null):Void { @@ -367,9 +382,9 @@ class BaseCharacter extends Bopper // This lets you add frames to the end of the sing animation to ease back into the idle! holdTimer += event.elapsed; - var singTimeSec:Float = singTimeSec * (Conductor.instance.beatLengthMs * 0.001); // x beats, to ms. + var singTimeSec:Float = singTimeSteps * (Conductor.instance.stepLengthMs / Constants.MS_PER_SEC); // x beats, to ms. - if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss + if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss??? // Without this check here, the player character would only play the `sing` animation // for one beat, as opposed to holding it as long as the player is holding the button. @@ -378,7 +393,7 @@ class BaseCharacter extends Bopper FlxG.watch.addQuick('singTimeSec-${characterId}', singTimeSec); if (holdTimer > singTimeSec && shouldStopSinging) { - // trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation'); + trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation'); holdTimer = 0; dance(true); } @@ -475,7 +490,7 @@ class BaseCharacter extends Bopper * Every time a note is hit, check if the note is from the same strumline. * If it is, then play the sing animation. */ - public override function onNoteHit(event:NoteScriptEvent) + public override function onNoteHit(event:HitNoteScriptEvent) { super.onNoteHit(event); diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index 69e3ca48e..7d3d6cfb9 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -305,9 +305,15 @@ class CharacterDataParser icon = "darnell"; case "senpai-angry": icon = "senpai"; + case "tankman" | "tankman-atlas": + icon = "tankmen"; } - return Paths.image("freeplay/icons/" + icon + "pixel"); + var path = Paths.image("freeplay/icons/" + icon + "pixel"); + if (Assets.exists(path)) return path; + + // TODO: Hardcode some additional behavior or a fallback. + return null; } /** @@ -372,12 +378,12 @@ class CharacterDataParser } /** - * The default time the character should sing for, in beats. + * The default time the character should sing for, in steps. * Values that are too low will cause the character to stop singing between notes. - * Originally, this value was set to 1, but it was changed to 2 because that became - * too low after some other code changes. + * 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 = 2.0; + static final DEFAULT_SINGTIME:Float = 8.0; static final DEFAULT_DANCEEVERY:Int = 1; static final DEFAULT_FLIPX:Bool = false; @@ -738,4 +744,17 @@ typedef DeathData = * @default [0, 0] */ var ?cameraOffsets:Array; + + /** + * The amount to zoom the camera by while focusing on this character as they die. + * Value is a multiplier of the default camera zoom for the stage. + * @default 1.0 + */ + var ?cameraZoom:Float; + + /** + * Impose a delay between when the character reaches `0` health and when the death animation plays. + * @default 0.0 + */ + var ?preTransitionDelay:Float; } diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx index 420a4fdc4..419c5b3ea 100644 --- a/source/funkin/play/components/HealthIcon.hx +++ b/source/funkin/play/components/HealthIcon.hx @@ -6,6 +6,7 @@ import flixel.math.FlxMath; import flixel.math.FlxPoint; import funkin.play.character.CharacterData.CharacterDataParser; import openfl.utils.Assets; +import funkin.graphics.FunkinSprite; import funkin.util.MathUtil; /** @@ -26,7 +27,7 @@ import funkin.util.MathUtil; * @author MasterEric */ @:nullSafety -class HealthIcon extends FlxSprite +class HealthIcon extends FunkinSprite { /** * The character this icon is representing. @@ -408,7 +409,7 @@ class HealthIcon extends FlxSprite if (!isLegacyStyle) { - frames = Paths.getSparrowAtlas('icons/icon-$charId'); + loadSparrow('icons/icon-$charId'); loadAnimationNew(); } diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx index 9553856a9..39fc192a0 100644 --- a/source/funkin/play/components/PopUpStuff.hx +++ b/source/funkin/play/components/PopUpStuff.hx @@ -3,7 +3,10 @@ package funkin.play.components; import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.tweens.FlxTween; +import flixel.util.FlxDirection; +import funkin.graphics.FunkinSprite; import funkin.play.PlayState; +import funkin.util.TimerUtil; class PopUpStuff extends FlxTypedGroup { @@ -14,26 +17,20 @@ class PopUpStuff extends FlxTypedGroup public function displayRating(daRating:String) { + var perfStart:Float = TimerUtil.start(); + if (daRating == null) daRating = "good"; - var rating:FlxSprite = new FlxSprite(0, 0); - rating.scrollFactor.set(0.2, 0.2); - - rating.zIndex = 1000; var ratingPath:String = daRating; if (PlayState.instance.currentStageId.startsWith('school')) ratingPath = "weeb/pixelUI/" + ratingPath + "-pixel"; - rating.loadGraphic(Paths.image(ratingPath)); - rating.x = FlxG.width * 0.50; - rating.x -= FlxG.camera.scroll.x * 0.2; - // make sure rating is visible lol! - // if (rating.x < FlxG.camera.scroll.x) - // rating.x = FlxG.camera.scroll.x; - // else if (rating.x > FlxG.camera.scroll.x + FlxG.camera.width - rating.width) - // rating.x = FlxG.camera.scroll.x + FlxG.camera.width - rating.width; + var rating:FunkinSprite = FunkinSprite.create(0, 0, ratingPath); + rating.scrollFactor.set(0.2, 0.2); - // FlxG.camera.scroll.y + + rating.zIndex = 1000; + rating.x = FlxG.width * 0.50; + // rating.x -= FlxG.camera.scroll.x * 0.2; rating.y = FlxG.camera.height * 0.4 - 60; rating.acceleration.y = 550; rating.velocity.y -= FlxG.random.int(140, 175); @@ -61,10 +58,14 @@ class PopUpStuff extends FlxTypedGroup }, startDelay: Conductor.instance.beatLengthMs * 0.001 }); + + trace('displayRating took: ${TimerUtil.seconds(perfStart)}'); } public function displayCombo(?combo:Int = 0):Int { + var perfStart:Float = TimerUtil.start(); + if (combo == null) combo = 0; var pixelShitPart1:String = ""; @@ -75,16 +76,10 @@ class PopUpStuff extends FlxTypedGroup pixelShitPart1 = 'weeb/pixelUI/'; pixelShitPart2 = '-pixel'; } - var comboSpr:FlxSprite = new FlxSprite().loadGraphic(Paths.image(pixelShitPart1 + 'combo' + pixelShitPart2)); + var comboSpr:FunkinSprite = FunkinSprite.create(pixelShitPart1 + 'combo' + pixelShitPart2); comboSpr.y = FlxG.camera.height * 0.4 + 80; comboSpr.x = FlxG.width * 0.50; - comboSpr.x -= FlxG.camera.scroll.x * 0.2; - // make sure combo is visible lol! - // 194 fits 4 combo digits - // if (comboSpr.x < FlxG.camera.scroll.x + 194) - // comboSpr.x = FlxG.camera.scroll.x + 194; - // else if (comboSpr.x > FlxG.camera.scroll.x + FlxG.camera.width - comboSpr.width) - // comboSpr.x = FlxG.camera.scroll.x + FlxG.camera.width - comboSpr.width; + // comboSpr.x -= FlxG.camera.scroll.x * 0.2; comboSpr.acceleration.y = 600; comboSpr.velocity.y -= 150; @@ -129,8 +124,7 @@ class PopUpStuff extends FlxTypedGroup var daLoop:Int = 1; for (i in seperatedScore) { - var numScore:FlxSprite = new FlxSprite().loadGraphic(Paths.image(pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2)); - numScore.y = comboSpr.y; + var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2); if (PlayState.instance.currentStageId.startsWith('school')) { @@ -163,6 +157,8 @@ class PopUpStuff extends FlxTypedGroup 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 934919b65..ff56e0919 100644 --- a/source/funkin/play/cutscene/VideoCutscene.hx +++ b/source/funkin/play/cutscene/VideoCutscene.hx @@ -19,13 +19,22 @@ import hxcodec.flixel.FlxVideoSprite; class VideoCutscene { static var blackScreen:FlxSprite; + static var cutsceneType:CutsceneType; + + #if html5 + static var vid:FlxVideo; + #end + #if hxCodec + static var vid:FlxVideoSprite; + #end /** * Play a video cutscene. * TODO: Currently this is hardcoded to start the countdown after the video is done. * @param path The path to the video file. Use Paths.file(path) to get the correct path. + * @param cutseneType The type of cutscene to play, determines what the game does after. Defaults to `CutsceneType.STARTING`. */ - public static function play(filePath:String):Void + public static function play(filePath:String, ?cutsceneType:CutsceneType = STARTING):Void { if (PlayState.instance == null) return; @@ -36,6 +45,8 @@ class VideoCutscene return; } + var rawFilePath = Paths.stripLibrary(filePath); + // Trigger the cutscene. Don't play the song in the background. PlayState.instance.isInCutscene = true; PlayState.instance.camHUD.visible = false; @@ -47,12 +58,14 @@ class VideoCutscene blackScreen.cameras = [PlayState.instance.camCutscene]; PlayState.instance.add(blackScreen); - #if html5 - playVideoHTML5(filePath); - #end + VideoCutscene.cutsceneType = cutsceneType; - #if hxCodec - playVideoNative(filePath); + #if html5 + playVideoHTML5(rawFilePath); + #elseif hxCodec + playVideoNative(rawFilePath); + #else + throw "No video support for this platform!"; #end } @@ -66,8 +79,6 @@ class VideoCutscene } #if html5 - static var vid:FlxVideo; - static function playVideoHTML5(filePath:String):Void { // Video displays OVER the FlxState. @@ -92,8 +103,6 @@ class VideoCutscene #end #if hxCodec - static var vid:FlxVideoSprite; - static function playVideoNative(filePath:String):Void { // Video displays OVER the FlxState. @@ -103,6 +112,7 @@ class VideoCutscene { vid.zIndex = 0; vid.bitmap.onEndReached.add(finishVideo.bind(0.5)); + vid.autoPause = false; vid.cameras = [PlayState.instance.camCutscene]; @@ -110,6 +120,15 @@ class VideoCutscene PlayState.instance.refresh(); vid.play(filePath, false); + + // Resize videos bigger or smaller than the screen. + vid.bitmap.onTextureSetup.add(() -> { + vid.setGraphicSize(FlxG.width, FlxG.height); + vid.updateHitbox(); + vid.x = 0; + vid.y = 0; + // vid.scale.set(0.5, 0.5); + }); } else { @@ -118,10 +137,74 @@ class VideoCutscene } #end + public static function restartVideo(resume:Bool = true):Void + { + #if html5 + if (vid != null) + { + vid.restartVideo(); + } + #end + + #if hxCodec + if (vid != null) + { + // Seek to the start of the video. + vid.bitmap.time = 0; + if (resume) + { + // Resume the video if it was paused. + vid.resume(); + } + } + #end + } + + public static function pauseVideo():Void + { + #if html5 + if (vid != null) + { + vid.pauseVideo(); + } + #end + + #if hxCodec + if (vid != null) + { + vid.pause(); + } + #end + } + + public static function resumeVideo():Void + { + #if html5 + if (vid != null) + { + vid.resumeVideo(); + } + #end + + #if hxCodec + if (vid != null) + { + vid.resume(); + } + #end + } + + /** + * Finish the active video cutscene. Done when the video is finished or when the player skips the cutscene. + * @param transitionTime The duration of the transition to the next state. Defaults to 0.5 seconds (this time is always used when cancelling the video). + * @param finishCutscene The callback to call when the transition is finished. + */ public static function finishVideo(?transitionTime:Float = 0.5):Void { trace('ALERT: Finish video cutscene called!'); + var cutsceneType:CutsceneType = VideoCutscene.cutsceneType; + #if html5 if (vid != null) { @@ -157,8 +240,32 @@ class VideoCutscene { ease: FlxEase.quadInOut, onComplete: function(twn:FlxTween) { - PlayState.instance.startCountdown(); + onCutsceneFinish(cutsceneType); } }); } + + /** + * The default callback used when a cutscene is finished. + * You can specify your own callback when calling `VideoCutscene#play()`. + */ + static function onCutsceneFinish(cutsceneType:CutsceneType):Void + { + switch (cutsceneType) + { + case CutsceneType.STARTING: + PlayState.instance.startCountdown(); + case CutsceneType.ENDING: + PlayState.instance.endSong(true); // true = right goddamn now + case CutsceneType.MIDSONG: + throw "Not implemented!"; + } + } +} + +enum CutsceneType +{ + STARTING; // The default cutscene type. Starts the countdown after the video is done. + MIDSONG; // TODO: Implement this! + ENDING; // Ends the song after the video is done. } diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx index dc3fd8c8a..f865f3b7b 100644 --- a/source/funkin/play/cutscene/dialogue/Conversation.hx +++ b/source/funkin/play/cutscene/dialogue/Conversation.hx @@ -31,10 +31,6 @@ import funkin.data.dialogue.DialogueBoxRegistry; */ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry { - static final CONVERSATION_SKIP_TIMER:Float = 1.5; - - var skipHeldTimer:Float = 0.0; - /** * The ID of the conversation. */ @@ -105,8 +101,6 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl var currentDialogueBox:DialogueBox; - var skipTimer:FlxPieDial; - public function new(id:String) { super(); @@ -124,8 +118,8 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl { // Reset the progress in the dialogue. currentDialogueEntry = 0; + currentDialogueLine = 0; this.state = ConversationState.Start; - this.alpha = 1.0; // Start the dialogue. dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, false)); @@ -151,8 +145,31 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl music.play(); } + public function pauseMusic():Void + { + if (music != null) + { + music.pause(); + } + } + + public function resumeMusic():Void + { + if (music != null) + { + music.resume(); + } + } + function setupBackdrop():Void { + if (backdrop != null) + { + backdrop.destroy(); + remove(backdrop); + backdrop = null; + } + backdrop = new FunkinSprite(0, 0); if (_data.backdrop == null) return; @@ -181,12 +198,6 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl refresh(); } - function setupSkipTimer():Void - { - add(skipTimer = new FlxPieDial(16, 16, 32, FlxColor.WHITE, 36, CIRCLE, true, 24)); - skipTimer.amount = 0; - } - public override function update(elapsed:Float):Void { super.update(elapsed); @@ -199,9 +210,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl var nextSpeakerId:String = currentDialogueEntryData.speaker; // Skip the next steps if the current speaker is already displayed. - if (currentSpeaker != null && nextSpeakerId == currentSpeaker.id) return; - - var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId); + if ((currentSpeaker != null && currentSpeaker.alive) && nextSpeakerId == currentSpeaker.id) return; if (currentSpeaker != null) { @@ -210,6 +219,8 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl currentSpeaker = null; } + var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId); + if (nextSpeaker == null) { if (nextSpeakerId == null) @@ -222,6 +233,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl } return; } + if (!nextSpeaker.alive) nextSpeaker.revive(); ScriptEventDispatcher.callEvent(nextSpeaker, new ScriptEvent(CREATE, true)); @@ -249,8 +261,8 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl { var nextDialogueBoxId:String = currentDialogueEntryData?.box; - // Skip the next steps if the current speaker is already displayed. - if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.id) return; + // Skip the next steps if the current dialogue box is already displayed. + if ((currentDialogueBox != null && currentDialogueBox.alive) && nextDialogueBoxId == currentDialogueBox.id) return; if (currentDialogueBox != null) { @@ -266,6 +278,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl trace('Dialogue box could not be retrieved.'); return; } + if (!nextDialogueBox.alive) nextDialogueBox.revive(); ScriptEventDispatcher.callEvent(nextDialogueBox, new ScriptEvent(CREATE, true)); @@ -347,29 +360,28 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl currentDialogueEntry = 0; this.state = ConversationState.Start; - advanceConversation(); - } - - public function trySkipConversation(elapsed:Float):Void - { - if (skipTimer == null || skipTimer.animation == null) return; - - if (elapsed < 0) + if (outroTween != null) { - skipHeldTimer = 0.0; - } - else - { - skipHeldTimer += elapsed; + outroTween.cancel(); } + outroTween = null; - skipTimer.visible = skipHeldTimer >= 0.05; - skipTimer.amount = Math.min(skipHeldTimer / CONVERSATION_SKIP_TIMER, 1.0); + if (this.music != null) this.music.stop(); + this.music = null; - if (skipHeldTimer >= CONVERSATION_SKIP_TIMER) - { - skipConversation(); - } + if (currentSpeaker != null) currentSpeaker.kill(); + remove(currentSpeaker); + currentSpeaker = null; + + if (currentDialogueBox != null) currentDialogueBox.kill(); + remove(currentDialogueBox); + currentDialogueBox = null; + + if (backdrop != null) backdrop.destroy(); + remove(backdrop); + backdrop = null; + + startConversation(); } /** @@ -383,7 +395,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl dispatchEvent(new DialogueScriptEvent(DIALOGUE_SKIP, this, true)); } - static var outroTween:FlxTween; + var outroTween:FlxTween; public function startOutro():Void { @@ -411,7 +423,6 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl public function endOutro():Void { - outroTween = null; ScriptEventDispatcher.callEvent(this, new ScriptEvent(DESTROY, false)); } @@ -425,7 +436,6 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl // Fade in the music and backdrop. setupMusic(); setupBackdrop(); - setupSkipTimer(); // Advance the conversation. state = ConversationState.Opening; @@ -547,19 +557,25 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl { propagateEvent(event); - if (outroTween != null) outroTween.cancel(); // Canc + if (outroTween != null) + { + outroTween.cancel(); + } outroTween = null; - this.alpha = 0.0; if (this.music != null) this.music.stop(); this.music = null; - this.skipTimer = null; if (currentSpeaker != null) currentSpeaker.kill(); + remove(currentSpeaker); currentSpeaker = null; + if (currentDialogueBox != null) currentDialogueBox.kill(); + remove(currentDialogueBox); currentDialogueBox = null; - if (backdrop != null) backdrop.kill(); + + if (backdrop != null) backdrop.destroy(); + remove(backdrop); backdrop = null; this.clear(); @@ -578,16 +594,27 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl */ function propagateEvent(event:ScriptEvent):Void { - if (this.currentDialogueBox != null) + if (this.currentDialogueBox != null && this.currentDialogueBox.exists) { ScriptEventDispatcher.callEvent(this.currentDialogueBox, event); } - if (this.currentSpeaker != null) + if (this.currentSpeaker != null && this.currentSpeaker.exists) { ScriptEventDispatcher.callEvent(this.currentSpeaker, event); } } + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function revive():Void + { + super.revive(); + this.alpha = 1; + this.visible = true; + } + /** * Calls `kill()` on the group's members and then on the group itself. * You can revive this group later via `revive()` after this. @@ -599,6 +626,12 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass impl exists = false; _skipTransformChildren = false; if (group != null) group.kill(); + + if (outroTween != null) + { + outroTween.cancel(); + outroTween = null; + } } public override function toString():String diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx index 6f8a0086a..fe4f13be0 100644 --- a/source/funkin/play/cutscene/dialogue/DialogueBox.hx +++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx @@ -4,10 +4,11 @@ import flixel.FlxSprite; import funkin.data.IRegistryEntry; import flixel.group.FlxSpriteGroup; import flixel.graphics.frames.FlxFramesCollection; -import flixel.text.FlxText; +import funkin.graphics.FunkinSprite; import flixel.addons.text.FlxTypeText; import funkin.util.assets.FlxAnimationUtil; import funkin.modding.events.ScriptEvent; +import funkin.audio.FunkinSound; import funkin.modding.IScriptedClass.IDialogueScriptedClass; import flixel.util.FlxColor; import funkin.data.dialogue.DialogueBoxData; @@ -111,9 +112,6 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass imple this.y = 0; this.alpha = 1; - this.boxSprite = new FlxSprite(0, 0); - add(this.boxSprite); - loadSpritesheet(); loadAnimations(); @@ -122,6 +120,14 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass imple function loadSpritesheet():Void { + if (this.boxSprite != null) + { + remove(this.boxSprite); + this.boxSprite = null; + } + + this.boxSprite = new FunkinSprite(0, 0); + trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}'); var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); @@ -146,6 +152,8 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass imple this.flipY = _data.flipY; this.globalOffsets = _data.offsets; this.setScale(_data.scale); + + add(this.boxSprite); } public function setText(newText:String):Void @@ -190,6 +198,34 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass imple this.boxSprite.updateHitbox(); } + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + super.kill(); + if (this.boxSprite != null) + { + this.boxSprite.kill(); + this.boxSprite = null; + } + if (this.textDisplay != null) + { + this.textDisplay.kill(); + this.textDisplay = null; + } + this.clear(); + } + + public override function revive():Void + { + super.revive(); + + this.visible = true; + this.alpha = 1.0; + } + function loadAnimations():Void { trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}'); @@ -246,7 +282,8 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass imple textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW, FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false); textDisplay.borderSize = _data.text.shadowWidth ?? 2; - textDisplay.sounds = [FlxG.sound.load(Paths.sound('pixelText'), 0.6)]; + // TODO: Add an option to configure this. + textDisplay.sounds = [FunkinSound.load(Paths.sound('pixelText'), 0.6)]; textDisplay.completeCallback = onTypingComplete; diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx index d5bffd7b0..0d29481c4 100644 --- a/source/funkin/play/cutscene/dialogue/Speaker.hx +++ b/source/funkin/play/cutscene/dialogue/Speaker.hx @@ -106,6 +106,26 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRe loadAnimations(); } + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + super.kill(); + } + + public override function revive():Void + { + super.revive(); + + this.visible = true; + this.alpha = 1.0; + + loadSpritesheet(); + loadAnimations(); + } + function loadSpritesheet():Void { trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}'); @@ -198,25 +218,25 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRe // If the animation exists, we're good. if (hasAnimation(name)) return name; - trace('[BOPPER] Animation "$name" does not exist!'); + FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...'); // Attempt to strip a `-alt` suffix, if it exists. if (name.lastIndexOf('-') != -1) { var correctName = name.substring(0, name.lastIndexOf('-')); - trace('[BOPPER] Attempting to fallback to "$correctName"'); + FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...'); return correctAnimationName(correctName); } else { if (name != 'idle') { - trace('[BOPPER] Attempting to fallback to "idle"'); + FlxG.log.warn('Speaker tried to play animation "$name" that does not exist, fallback to idle...'); return correctAnimationName('idle'); } else { - trace('[BOPPER] Failing animation playback.'); + FlxG.log.error('Speaker tried to play animation "idle" that does not exist! This is bad!'); return null; } } diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx index 847df4a60..d4ab4f24f 100644 --- a/source/funkin/play/event/FocusCameraSongEvent.hx +++ b/source/funkin/play/event/FocusCameraSongEvent.hx @@ -1,5 +1,6 @@ package funkin.play.event; +import flixel.tweens.FlxEase; // Data from the chart import funkin.data.song.SongData; import funkin.data.song.SongData.SongEventData; @@ -57,6 +58,9 @@ class FocusCameraSongEvent extends SongEvent // Does nothing if there is no PlayState camera or stage. if (PlayState.instance == null || PlayState.instance.currentStage == null) return; + // Does nothing if we are minimal mode. + if (PlayState.instance.isMinimalMode) return; + var posX:Null = data.getFloat('x'); if (posX == null) posX = 0.0; var posY:Null = data.getFloat('y'); @@ -66,6 +70,13 @@ class FocusCameraSongEvent extends SongEvent if (char == null) char = cast data.value; + var useTween:Null = data.getBool('useTween'); + if (useTween == null) useTween = false; + var duration:Null = data.getFloat('duration'); + if (duration == null) duration = 4.0; + var ease:Null = data.getString('ease'); + if (ease == null) ease = 'linear'; + switch (char) { case -1: // Position @@ -114,6 +125,26 @@ class FocusCameraSongEvent extends SongEvent default: trace('Unknown camera focus: ' + data); } + + if (useTween) + { + switch (ease) + { + case 'INSTANT': + PlayState.instance.tweenCameraToFollowPoint(0); + default: + var durSeconds = Conductor.instance.stepLengthMs * duration / 1000; + + var easeFunction:NullFloat> = Reflect.field(FlxEase, ease); + if (easeFunction == null) + { + trace('Invalid ease function: $ease'); + return; + } + + PlayState.instance.tweenCameraToFollowPoint(durSeconds, easeFunction); + } + } } public override function getTitle():String @@ -155,6 +186,51 @@ class FocusCameraSongEvent extends SongEvent step: 10.0, type: SongEventFieldType.FLOAT, units: "px" + }, + { + name: 'useTween', + title: 'Use Tween', + type: SongEventFieldType.BOOL, + defaultValue: false + }, + { + name: 'duration', + title: 'Duration', + defaultValue: 4.0, + step: 0.5, + type: SongEventFieldType.FLOAT, + units: 'steps' + }, + { + name: 'ease', + title: 'Easing Type', + defaultValue: 'linear', + type: SongEventFieldType.ENUM, + keys: [ + 'Linear' => 'linear', + 'Instant' => 'INSTANT', + 'Quad In' => 'quadIn', + 'Quad Out' => 'quadOut', + 'Quad In/Out' => 'quadInOut', + 'Cube In' => 'cubeIn', + 'Cube Out' => 'cubeOut', + 'Cube In/Out' => 'cubeInOut', + 'Quart In' => 'quartIn', + 'Quart Out' => 'quartOut', + 'Quart In/Out' => 'quartInOut', + 'Quint In' => 'quintIn', + 'Quint Out' => 'quintOut', + 'Quint In/Out' => 'quintInOut', + 'Smooth Step In' => 'smoothStepIn', + 'Smooth Step Out' => 'smoothStepOut', + 'Smooth Step In/Out' => 'smoothStepInOut', + 'Sine In' => 'sineIn', + 'Sine Out' => 'sineOut', + 'Sine In/Out' => 'sineInOut', + 'Elastic In' => 'elasticIn', + 'Elastic Out' => 'elasticOut', + 'Elastic In/Out' => 'elasticInOut', + ] } ]); } diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx index 809130499..b913aebe7 100644 --- a/source/funkin/play/event/ZoomCameraSongEvent.hx +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -52,26 +52,36 @@ class ZoomCameraSongEvent extends SongEvent super('ZoomCamera'); } + static final DEFAULT_ZOOM:Float = 1.0; + static final DEFAULT_DURATION:Float = 4.0; + static final DEFAULT_MODE:String = 'direct'; + static final DEFAULT_EASE:String = 'linear'; + public override function handleEvent(data:SongEventData):Void { // Does nothing if there is no PlayState camera or stage. - if (PlayState.instance == null) return; + if (PlayState.instance == null || PlayState.instance.currentStage == null) return; - var zoom:Null = data.getFloat('zoom'); - if (zoom == null) zoom = 1.0; - var duration:Null = data.getFloat('duration'); - if (duration == null) duration = 4.0; + // Does nothing if we are minimal mode. + if (PlayState.instance.isMinimalMode) return; - var ease:Null = data.getString('ease'); - if (ease == null) ease = 'linear'; + var zoom:Float = data.getFloat('zoom') ?? DEFAULT_ZOOM; + + var duration:Float = data.getFloat('duration') ?? DEFAULT_DURATION; + + var mode:String = data.getString('mode') ?? DEFAULT_MODE; + var isDirectMode:Bool = mode == 'direct'; + + var ease:String = data.getString('ease') ?? DEFAULT_EASE; // If it's a string, check the value. switch (ease) { case 'INSTANT': - // Set the zoom. Use defaultCameraZoom to prevent breaking camera bops. - PlayState.instance.defaultCameraZoom = zoom * FlxCamera.defaultZoom; + PlayState.instance.tweenCameraZoom(zoom, 0, isDirectMode); default: + var durSeconds = Conductor.instance.stepLengthMs * duration / 1000; + var easeFunction:NullFloat> = Reflect.field(FlxEase, ease); if (easeFunction == null) { @@ -79,8 +89,7 @@ class ZoomCameraSongEvent extends SongEvent return; } - FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.instance.stepLengthMs * duration / 1000), - {ease: easeFunction}); + PlayState.instance.tweenCameraZoom(zoom, durSeconds, isDirectMode, easeFunction); } } @@ -93,8 +102,9 @@ class ZoomCameraSongEvent extends SongEvent * ``` * { * 'zoom': FLOAT, // Target zoom level. - * 'duration': FLOAT, // Optional duration in steps - * 'ease': ENUM, // Optional easing function + * 'duration': FLOAT, // Optional duration in steps. + * 'mode': ENUM, // Whether to set additive zoom or direct zoom. + * 'ease': ENUM, // Optional easing function. * } * @return SongEventSchema */ @@ -117,6 +127,13 @@ class ZoomCameraSongEvent extends SongEvent type: SongEventFieldType.FLOAT, units: 'steps' }, + { + name: 'mode', + title: 'Mode', + defaultValue: 'direct', + type: SongEventFieldType.ENUM, + keys: ['Additive' => 'additive', 'Direct' => 'direct'] + }, { name: 'ease', title: 'Easing Type', diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx index 0ff8076c8..2411e5615 100644 --- a/source/funkin/play/notes/NoteSplash.hx +++ b/source/funkin/play/notes/NoteSplash.hx @@ -35,6 +35,7 @@ class NoteSplash extends FlxSprite */ function setup():Void { + if (frameCollection?.parent?.isDestroyed ?? false) frameCollection = null; if (frameCollection == null) preloadFrames(); this.frames = frameCollection; @@ -75,6 +76,8 @@ class NoteSplash extends FlxSprite this.playAnimation('splash${variant}Right'); } + if (animation.curAnim == null) return; + // Vary the speed of the animation a bit. animation.curAnim.frameRate = FRAMERATE_DEFAULT + FlxG.random.int(-FRAMERATE_VARIANCE, FRAMERATE_VARIANCE); diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx index 0368b18e9..b16b88466 100644 --- a/source/funkin/play/notes/NoteSprite.hx +++ b/source/funkin/play/notes/NoteSprite.hx @@ -4,9 +4,10 @@ import funkin.data.song.SongData.SongNoteData; import funkin.play.notes.notestyle.NoteStyle; import flixel.graphics.frames.FlxAtlasFrames; import flixel.FlxSprite; +import funkin.graphics.FunkinSprite; import funkin.graphics.shaders.HSVShader; -class NoteSprite extends FlxSprite +class NoteSprite extends FunkinSprite { static final DIRECTION_COLORS:Array = ['purple', 'blue', 'green', 'red']; @@ -15,26 +16,53 @@ class NoteSprite extends FlxSprite var hsvShader:HSVShader; /** - * The time at which the note should be hit, in milliseconds. + * The strum time at which the note should be hit, in milliseconds. */ - public var strumTime(default, set):Float; + public var strumTime(get, set):Float; + + function get_strumTime():Float + { + return this.noteData?.time ?? 0.0; + } function set_strumTime(value:Float):Float { - this.strumTime = value; - return this.strumTime; + if (this.noteData == null) return value; + return this.noteData.time = value; + } + + /** + * The length for which the note should be held, in milliseconds. + * Defaults to 0 for single notes. + */ + public var length(get, set):Float; + + function get_length():Float + { + return this.noteData?.length ?? 0.0; + } + + function set_length(value:Float):Float + { + if (this.noteData == null) return value; + return this.noteData.length = value; } /** * An extra attribute for the note. * For example, whether the note is an "alt" note, or whether it has custom behavior on hit. */ - public var kind(default, set):String; + public var kind(get, set):Null; + + function get_kind():Null + { + return this.noteData?.kind; + } function set_kind(value:String):String { - this.kind = value; - return this.kind; + if (this.noteData == null) return value; + return this.noteData.kind = value; } /** @@ -99,16 +127,13 @@ class NoteSprite extends FlxSprite */ public var handledMiss:Bool; - public function new(noteStyle:NoteStyle, strumTime:Float = 0, direction:Int = 0) + public function new(noteStyle:NoteStyle, direction:Int = 0) { super(0, -9999); - this.strumTime = strumTime; this.direction = direction; this.hsvShader = new HSVShader(); - if (this.strumTime < 0) this.strumTime = 0; - setupNoteGraphic(noteStyle); // Disables the update() function for performance. diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 4538696dc..aa87fa2e4 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -1,5 +1,6 @@ package funkin.play.notes; +import flixel.util.FlxSignal.FlxTypedSignal; import flixel.FlxG; import funkin.play.notes.notestyle.NoteStyle; import flixel.group.FlxSpriteGroup; @@ -39,6 +40,10 @@ class Strumline extends FlxSpriteGroup return FlxG.height / 0.45; } + /** + * Whether this strumline is controlled by the player's inputs. + * False means it's controlled by the opponent or Bot Play. + */ public var isPlayer:Bool; /** @@ -56,6 +61,8 @@ class Strumline extends FlxSpriteGroup public var holdNotes:FlxTypedSpriteGroup; + public var onNoteIncoming:FlxTypedSignalVoid>; + var strumlineNotes:FlxTypedSpriteGroup; var noteSplashes:FlxTypedSpriteGroup; var noteHoldCovers:FlxTypedSpriteGroup; @@ -113,6 +120,8 @@ class Strumline extends FlxSpriteGroup this.refresh(); + this.onNoteIncoming = new FlxTypedSignalVoid>(); + for (i in 0...KEY_COUNT) { var child:StrumlineNote = new StrumlineNote(noteStyle, isPlayer, DIRECTIONS[i]); @@ -398,6 +407,8 @@ class Strumline extends FlxSpriteGroup } nextNoteIndex = noteIndex + 1; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. + + onNoteIncoming.dispatch(noteSprite); } // Update rendering of notes. @@ -746,7 +757,6 @@ class Strumline extends FlxSpriteGroup if (noteSprite != null) { - noteSprite.strumTime = note.time; noteSprite.direction = note.getDirection(); noteSprite.noteData = note; diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx index 34c1ce9c3..d0cc09f6a 100644 --- a/source/funkin/play/notes/notestyle/NoteStyle.hx +++ b/source/funkin/play/notes/notestyle/NoteStyle.hx @@ -4,6 +4,7 @@ import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFramesCollection; import funkin.data.animation.AnimationData; import funkin.data.IRegistryEntry; +import funkin.graphics.FunkinSprite; import funkin.data.notestyle.NoteStyleData; import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.notestyle.NoteStyleRegistry; @@ -100,6 +101,14 @@ class NoteStyle implements IRegistryEntry function buildNoteFrames(force:Bool = false):FlxAtlasFrames { + if (!FunkinSprite.isTextureCached(Paths.image(getNoteAssetPath()))) + { + FlxG.log.warn('Note texture is not cached: ${getNoteAssetPath()}'); + } + + // Purge the note frames if the cached atlas is invalid. + if (noteFrames?.parent?.isDestroyed ?? false) noteFrames = null; + if (noteFrames != null && !force) return noteFrames; noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary()); @@ -109,8 +118,6 @@ class NoteStyle implements IRegistryEntry throw 'Could not load note frames for note style: $id'; } - noteFrames.parent.persist = true; - return noteFrames; } diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 970aebc57..567c388c7 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -1,6 +1,5 @@ package funkin.play.song; -import flixel.sound.FlxSound; import funkin.audio.VoicesGroup; import funkin.audio.FunkinSound; import funkin.data.IRegistryEntry; @@ -13,9 +12,8 @@ import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongData.SongTimeFormat; import funkin.data.song.SongRegistry; -import funkin.data.song.SongRegistry; +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.events.ScriptEvent; -import funkin.modding.IScriptedClass; import funkin.util.SortUtil; import openfl.utils.Assets; @@ -31,14 +29,44 @@ import openfl.utils.Assets; @:nullSafety class Song implements IPlayStateScriptedClass implements IRegistryEntry { - public static final DEFAULT_SONGNAME:String = "Unknown"; - public static final DEFAULT_ARTIST:String = "Unknown"; + /** + * The default value for the song's name + */ + public static final DEFAULT_SONGNAME:String = 'Unknown'; + + /** + * The default value for the song's artist + */ + public static final DEFAULT_ARTIST:String = 'Unknown'; + + /** + * The default value for the song's time format + */ public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; + + /** + * The default value for the song's divisions + */ public static final DEFAULT_DIVISIONS:Null = null; + + /** + * The default value for whether the song loops. + */ public static final DEFAULT_LOOPED:Bool = false; - public static final DEFAULT_STAGE:String = "mainStage"; + + /** + * The default value for the song's playable stage. + */ + public static final DEFAULT_STAGE:String = 'mainStage'; + + /** + * The default value for the song's scroll speed. + */ public static final DEFAULT_SCROLLSPEED:Float = 1.0; + /** + * The internal ID of the song. + */ public final id:String; /** @@ -53,6 +81,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry; final difficulties:Map; + /** + * The list of variations a song has. + */ public var variations(get, never):Array; function get_variations():Array @@ -65,6 +96,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry = fetchVariationMetadata(id, vari); if (variMeta != null) _metadata.set(variMeta.variation, variMeta); } } @@ -115,32 +152,87 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry, variations:Array, charts:Map, - validScore:Bool = false):Song + includeScript:Bool = true, validScore:Bool = false):Song { - var result:Song = new Song(songId); + @:privateAccess + var result:Null; + + if (includeScript && SongRegistry.instance.isScriptedEntry(songId)) + { + var songClassName:String = SongRegistry.instance.getScriptedEntryClassName(songId); + + @:privateAccess + result = SongRegistry.instance.createScriptedEntry(songClassName); + } + else + { + @:privateAccess + result = SongRegistry.instance.createEntry(songId); + } + + if (result == null) throw 'ERROR: Could not build Song instance ($songId), is the attached script bad?'; result._metadata.clear(); for (meta in metadata) + { result._metadata.set(meta.variation, meta); + } result.difficulties.clear(); result.populateDifficulties(); for (variation => chartData in charts) + { result.applyChartData(chartData, variation); + } result.validScore = validScore; return result; } + /** + * Retrieve a list of the raw metadata for the song. + * @return The metadata JSON objects for the song's variations. + */ public function getRawMetadata():Array { return _metadata.values(); } + /** + * List the album IDs for each variation of the song. + * @return A map of variation IDs to album IDs. + */ + public function listAlbums():Map + { + var result:Map = new Map(); + + for (difficultyId in difficulties.keys()) + { + var meta:Null = difficulties.get(difficultyId); + if (meta != null && meta.album != null) + { + result.set(difficultyId, meta.album); + } + } + + return result; + } + /** * Populate the difficulty data from the provided metadata. * Does not load chart data (that is triggered later when we want to play the song). @@ -192,6 +284,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry):Array + public function listDifficulties(?variationId:String, ?variationIds:Array, showHidden:Bool = false):Array { if (variationIds == null) variationIds = []; if (variationId != null) variationIds.push(variationId); @@ -314,6 +410,15 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry 2) diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 1bc0632f9..262aff7bc 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -167,10 +167,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass function update_shouldAlternate():Void { - if (hasAnimation('danceLeft')) - { - this.shouldAlternate = true; - } + this.shouldAlternate = hasAnimation('danceLeft'); } /** @@ -228,33 +225,42 @@ class Bopper extends StageProp implements IPlayStateScriptedClass /** * Ensure that a given animation exists before playing it. - * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. - * @param name + * Will gracefully check for name, then name with stripped suffixes, then fail to play. + * @param name The animation name to attempt to correct. + * @param fallback Instead of failing to play, try to play this animation instead. */ - function correctAnimationName(name:String):String + function correctAnimationName(name:String, ?fallback:String):String { // If the animation exists, we're good. if (hasAnimation(name)) return name; - trace('[BOPPER] Animation "$name" does not exist!'); + FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...'); // Attempt to strip a `-alt` suffix, if it exists. if (name.lastIndexOf('-') != -1) { var correctName = name.substring(0, name.lastIndexOf('-')); - trace('[BOPPER] Attempting to fallback to "$correctName"'); + FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...'); return correctAnimationName(correctName); } else { - if (name != 'idle') + if (fallback != null) { - trace('[BOPPER] Attempting to fallback to "idle"'); - return correctAnimationName('idle'); + if (fallback == name) + { + FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); + return null; + } + else + { + FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...'); + return correctAnimationName('idle'); + } } else { - trace('[BOPPER] Failing animation playback.'); + FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); return null; } } @@ -353,7 +359,9 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public function onGameOver(event:ScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} + public function onNoteIncoming(event:NoteScriptEvent) {} + + public function onNoteHit(event:HitNoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent) {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index af5765b25..db42b0dd3 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -109,9 +109,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements { getBoyfriend().resetCharacter(true); // Reapply the camera offsets. - var charData = _data.characters.bf; - getBoyfriend().cameraFocusPoint.x += charData.cameraOffsets[0]; - getBoyfriend().cameraFocusPoint.y += charData.cameraOffsets[1]; + var stageCharData:StageDataCharacter = _data.characters.bf; + var finalScale:Float = getBoyfriend().getBaseScale() * stageCharData.scale; + getBoyfriend().setScale(finalScale); + getBoyfriend().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getBoyfriend().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; } else { @@ -121,17 +123,21 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements { getGirlfriend().resetCharacter(true); // Reapply the camera offsets. - var charData = _data.characters.gf; - getGirlfriend().cameraFocusPoint.x += charData.cameraOffsets[0]; - getGirlfriend().cameraFocusPoint.y += charData.cameraOffsets[1]; + var stageCharData:StageDataCharacter = _data.characters.gf; + var finalScale:Float = getBoyfriend().getBaseScale() * stageCharData.scale; + getGirlfriend().setScale(finalScale); + getGirlfriend().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getGirlfriend().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; } if (getDad() != null) { getDad().resetCharacter(true); // Reapply the camera offsets. - var charData = _data.characters.dad; - getDad().cameraFocusPoint.x += charData.cameraOffsets[0]; - getDad().cameraFocusPoint.y += charData.cameraOffsets[1]; + var stageCharData:StageDataCharacter = _data.characters.dad; + var finalScale:Float = getBoyfriend().getBaseScale() * stageCharData.scale; + getDad().setScale(finalScale); + getDad().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getDad().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; } // Reset positions of named props. @@ -185,9 +191,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements switch (dataProp.animType) { case 'packer': - propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath); + propSprite.loadPacker(dataProp.assetPath); default: // 'sparrow' - propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath); + propSprite.loadSparrow(dataProp.assetPath); } } else if (isSolidColor) @@ -209,7 +215,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements else { // Initalize static sprite. - propSprite.loadGraphic(Paths.image(dataProp.assetPath)); + propSprite.loadTexture(dataProp.assetPath); // Disables calls to update() for a performance boost. propSprite.active = false; @@ -226,7 +232,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements switch (dataProp.scale) { case Left(value): - propSprite.scale.set(value); + propSprite.scale.set(value, value); case Right(values): propSprite.scale.set(values[0], values[1]); @@ -390,22 +396,25 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements #end // Apply position and z-index. - var charData:StageDataCharacter = null; + var stageCharData:StageDataCharacter = null; switch (charType) { case BF: this.characters.set('bf', character); - charData = _data.characters.bf; + stageCharData = _data.characters.bf; character.flipX = !character.getDataFlipX(); + character.name = 'bf'; character.initHealthIcon(false); case GF: this.characters.set('gf', character); - charData = _data.characters.gf; + stageCharData = _data.characters.gf; character.flipX = character.getDataFlipX(); + character.name = 'gf'; case DAD: this.characters.set('dad', character); - charData = _data.characters.dad; + stageCharData = _data.characters.dad; character.flipX = character.getDataFlipX(); + character.name = 'dad'; character.initHealthIcon(true); default: this.characters.set(character.characterId, character); @@ -415,15 +424,15 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements // This ensures positioning is based on the idle animation. character.resetCharacter(true); - if (charData != null) + if (stageCharData != null) { - character.zIndex = charData.zIndex; + character.zIndex = stageCharData.zIndex; // 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 = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; - character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + character.x = stageCharData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; + character.y = stageCharData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; @:privateAccess(funkin.play.stage.Bopper) { @@ -432,15 +441,17 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements character.originalPosition.y = character.y + character.animOffsets[1]; } - character.cameraFocusPoint.x += charData.cameraOffsets[0]; - character.cameraFocusPoint.y += charData.cameraOffsets[1]; + var finalScale = character.getBaseScale() * stageCharData.scale; + character.setScale(finalScale); // Don't use scale.set for characters! + character.cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + character.cameraFocusPoint.y += stageCharData.cameraOffsets[1]; #if debug // Draw the debug icon at the character's feet. if (charType == BF || charType == DAD) { - debugIcon.x = charData.position[0]; - debugIcon.y = charData.position[1]; + debugIcon.x = stageCharData.position[0]; + debugIcon.y = stageCharData.position[1]; debugIcon2.x = character.x; debugIcon2.y = character.y; } @@ -634,7 +645,30 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements */ public function dispatchToCharacters(event:ScriptEvent):Void { - for (characterId in characters.keys()) + var charList = this.characters.keys().array(); + + // Dad, then BF, then GF, in that order. + + if (charList.contains('dad')) + { + dispatchToCharacter('dad', event); + charList.remove('dad'); + } + + if (charList.contains('bf')) + { + dispatchToCharacter('bf', event); + charList.remove('bf'); + } + + if (charList.contains('gf')) + { + dispatchToCharacter('gf', event); + charList.remove('gf'); + } + + // Then the rest of the characters, if any. + for (characterId in charList) { dispatchToCharacter(characterId, event); } @@ -840,7 +874,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements public function onCountdownEnd(event:CountdownScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} + public function onNoteIncoming(event:NoteScriptEvent) {} + + public function onNoteHit(event:HitNoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent) {} diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index b1468bd29..34680a7f5 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -11,8 +11,7 @@ import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme; import thx.semver.Version; @:nullSafety -@:forward(volume, mute) -abstract Save(RawSaveData) +class Save { // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null. public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2"; @@ -25,6 +24,20 @@ abstract Save(RawSaveData) static final SAVE_PATH_LEGACY:String = 'ninjamuffin99'; static final SAVE_NAME_LEGACY:String = 'funkin'; + public static var instance(get, never):Save; + static var _instance:Null = null; + + static function get_instance():Save + { + if (_instance == null) + { + _instance = new Save(FlxG.save.data); + } + return _instance; + } + + var data:RawSaveData; + public static function load():Void { trace("[SAVE] Loading save..."); @@ -33,86 +46,87 @@ abstract Save(RawSaveData) loadFromSlot(1); } - public static function get():Save - { - return FlxG.save.data; - } - /** * Constructing a new Save will load the default values. */ - public function new() + public function new(data:RawSaveData) { - this = - { - version: Save.SAVE_DATA_VERSION, + this.data = data; - volume: 1.0, - mute: false, + if (this.data == null) data = Save.getDefault(); + } - api: - { - newgrounds: - { - sessionId: null, - } - }, - scores: - { - // No saved scores. - levels: [], - songs: [], - }, - options: - { - // Reasonable defaults. - naughtyness: true, - downscroll: false, - flashingLights: true, - zoomCamera: true, - debugDisplay: false, - autoPause: true, - inputOffset: 0, - audioVisualOffset: 0, + public static function getDefault():RawSaveData + { + return { + version: Save.SAVE_DATA_VERSION, - controls: - { - // Leave controls blank so defaults are loaded. - p1: - { - keyboard: {}, - gamepad: {}, - }, - p2: - { - keyboard: {}, - gamepad: {}, - }, - }, - }, + volume: 1.0, + mute: false, - mods: - { - // No mods enabled. - enabledMods: [], - modOptions: [], - }, + api: + { + newgrounds: + { + sessionId: null, + } + }, + scores: + { + // No saved scores. + levels: [], + songs: [], + }, + options: + { + // Reasonable defaults. + naughtyness: true, + downscroll: false, + flashingLights: true, + zoomCamera: true, + debugDisplay: false, + autoPause: true, + inputOffset: 0, + audioVisualOffset: 0, - optionsChartEditor: - { - // Reasonable defaults. - previousFiles: [], - noteQuant: 3, - chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None, - theme: ChartEditorTheme.Light, - playtestStartTime: false, - downscroll: false, - metronomeVolume: 1.0, - hitsoundsEnabledPlayer: true, - hitsoundsEnabledOpponent: true, - themeMusic: true - }, - }; + controls: + { + // Leave controls blank so defaults are loaded. + p1: + { + keyboard: {}, + gamepad: {}, + }, + p2: + { + keyboard: {}, + gamepad: {}, + }, + }, + }, + + mods: + { + // No mods enabled. + enabledMods: [], + modOptions: [], + }, + + optionsChartEditor: + { + // Reasonable defaults. + previousFiles: [], + noteQuant: 3, + chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None, + theme: ChartEditorTheme.Light, + playtestStartTime: false, + downscroll: false, + metronomeVolume: 1.0, + hitsoundVolumePlayer: 1.0, + hitsoundVolumeOpponent: 1.0, + themeMusic: true + }, + }; } /** @@ -122,7 +136,7 @@ abstract Save(RawSaveData) function get_options():SaveDataOptions { - return this.options; + return data.options; } /** @@ -132,7 +146,7 @@ abstract Save(RawSaveData) function get_modOptions():Map { - return this.mods.modOptions; + return data.mods.modOptions; } /** @@ -142,249 +156,232 @@ abstract Save(RawSaveData) function get_ngSessionId():Null { - return this.api.newgrounds.sessionId; + return data.api.newgrounds.sessionId; } function set_ngSessionId(value:Null):Null { - this.api.newgrounds.sessionId = value; + data.api.newgrounds.sessionId = value; flush(); - return this.api.newgrounds.sessionId; + return data.api.newgrounds.sessionId; } public var enabledModIds(get, set):Array; function get_enabledModIds():Array { - return this.mods.enabledMods; + return data.mods.enabledMods; } function set_enabledModIds(value:Array):Array { - this.mods.enabledMods = value; + data.mods.enabledMods = value; flush(); - return this.mods.enabledMods; + return data.mods.enabledMods; } public var chartEditorPreviousFiles(get, set):Array; function get_chartEditorPreviousFiles():Array { - if (this.optionsChartEditor.previousFiles == null) this.optionsChartEditor.previousFiles = []; + if (data.optionsChartEditor.previousFiles == null) data.optionsChartEditor.previousFiles = []; - return this.optionsChartEditor.previousFiles; + return data.optionsChartEditor.previousFiles; } function set_chartEditorPreviousFiles(value:Array):Array { // Set and apply. - this.optionsChartEditor.previousFiles = value; + data.optionsChartEditor.previousFiles = value; flush(); - return this.optionsChartEditor.previousFiles; + return data.optionsChartEditor.previousFiles; } public var chartEditorHasBackup(get, set):Bool; function get_chartEditorHasBackup():Bool { - if (this.optionsChartEditor.hasBackup == null) this.optionsChartEditor.hasBackup = false; + if (data.optionsChartEditor.hasBackup == null) data.optionsChartEditor.hasBackup = false; - return this.optionsChartEditor.hasBackup; + return data.optionsChartEditor.hasBackup; } function set_chartEditorHasBackup(value:Bool):Bool { // Set and apply. - this.optionsChartEditor.hasBackup = value; + data.optionsChartEditor.hasBackup = value; flush(); - return this.optionsChartEditor.hasBackup; + return data.optionsChartEditor.hasBackup; } public var chartEditorNoteQuant(get, set):Int; function get_chartEditorNoteQuant():Int { - if (this.optionsChartEditor.noteQuant == null) this.optionsChartEditor.noteQuant = 3; + if (data.optionsChartEditor.noteQuant == null) data.optionsChartEditor.noteQuant = 3; - return this.optionsChartEditor.noteQuant; + return data.optionsChartEditor.noteQuant; } function set_chartEditorNoteQuant(value:Int):Int { // Set and apply. - this.optionsChartEditor.noteQuant = value; + data.optionsChartEditor.noteQuant = value; flush(); - return this.optionsChartEditor.noteQuant; + return data.optionsChartEditor.noteQuant; } public var chartEditorLiveInputStyle(get, set):ChartEditorLiveInputStyle; function get_chartEditorLiveInputStyle():ChartEditorLiveInputStyle { - if (this.optionsChartEditor.chartEditorLiveInputStyle == null) this.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None; + if (data.optionsChartEditor.chartEditorLiveInputStyle == null) data.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None; - return this.optionsChartEditor.chartEditorLiveInputStyle; + return data.optionsChartEditor.chartEditorLiveInputStyle; } function set_chartEditorLiveInputStyle(value:ChartEditorLiveInputStyle):ChartEditorLiveInputStyle { // Set and apply. - this.optionsChartEditor.chartEditorLiveInputStyle = value; + data.optionsChartEditor.chartEditorLiveInputStyle = value; flush(); - return this.optionsChartEditor.chartEditorLiveInputStyle; + return data.optionsChartEditor.chartEditorLiveInputStyle; } public var chartEditorDownscroll(get, set):Bool; function get_chartEditorDownscroll():Bool { - if (this.optionsChartEditor.downscroll == null) this.optionsChartEditor.downscroll = false; + if (data.optionsChartEditor.downscroll == null) data.optionsChartEditor.downscroll = false; - return this.optionsChartEditor.downscroll; + return data.optionsChartEditor.downscroll; } function set_chartEditorDownscroll(value:Bool):Bool { // Set and apply. - this.optionsChartEditor.downscroll = value; + data.optionsChartEditor.downscroll = value; flush(); - return this.optionsChartEditor.downscroll; + return data.optionsChartEditor.downscroll; } public var chartEditorPlaytestStartTime(get, set):Bool; function get_chartEditorPlaytestStartTime():Bool { - if (this.optionsChartEditor.playtestStartTime == null) this.optionsChartEditor.playtestStartTime = false; + if (data.optionsChartEditor.playtestStartTime == null) data.optionsChartEditor.playtestStartTime = false; - return this.optionsChartEditor.playtestStartTime; + return data.optionsChartEditor.playtestStartTime; } function set_chartEditorPlaytestStartTime(value:Bool):Bool { // Set and apply. - this.optionsChartEditor.playtestStartTime = value; + data.optionsChartEditor.playtestStartTime = value; flush(); - return this.optionsChartEditor.playtestStartTime; + return data.optionsChartEditor.playtestStartTime; } public var chartEditorTheme(get, set):ChartEditorTheme; function get_chartEditorTheme():ChartEditorTheme { - if (this.optionsChartEditor.theme == null) this.optionsChartEditor.theme = ChartEditorTheme.Light; + if (data.optionsChartEditor.theme == null) data.optionsChartEditor.theme = ChartEditorTheme.Light; - return this.optionsChartEditor.theme; + return data.optionsChartEditor.theme; } function set_chartEditorTheme(value:ChartEditorTheme):ChartEditorTheme { // Set and apply. - this.optionsChartEditor.theme = value; + data.optionsChartEditor.theme = value; flush(); - return this.optionsChartEditor.theme; + return data.optionsChartEditor.theme; } public var chartEditorMetronomeVolume(get, set):Float; function get_chartEditorMetronomeVolume():Float { - if (this.optionsChartEditor.metronomeVolume == null) this.optionsChartEditor.metronomeVolume = 1.0; + if (data.optionsChartEditor.metronomeVolume == null) data.optionsChartEditor.metronomeVolume = 1.0; - return this.optionsChartEditor.metronomeVolume; + return data.optionsChartEditor.metronomeVolume; } function set_chartEditorMetronomeVolume(value:Float):Float { // Set and apply. - this.optionsChartEditor.metronomeVolume = value; + data.optionsChartEditor.metronomeVolume = value; flush(); - return this.optionsChartEditor.metronomeVolume; + return data.optionsChartEditor.metronomeVolume; } - public var chartEditorHitsoundVolume(get, set):Float; + public var chartEditorHitsoundVolumePlayer(get, set):Float; - function get_chartEditorHitsoundVolume():Float + function get_chartEditorHitsoundVolumePlayer():Float { - if (this.optionsChartEditor.hitsoundVolume == null) this.optionsChartEditor.hitsoundVolume = 1.0; + if (data.optionsChartEditor.hitsoundVolumePlayer == null) data.optionsChartEditor.hitsoundVolumePlayer = 1.0; - return this.optionsChartEditor.hitsoundVolume; + return data.optionsChartEditor.hitsoundVolumePlayer; } - function set_chartEditorHitsoundVolume(value:Float):Float + function set_chartEditorHitsoundVolumePlayer(value:Float):Float { // Set and apply. - this.optionsChartEditor.hitsoundVolume = value; + data.optionsChartEditor.hitsoundVolumePlayer = value; flush(); - return this.optionsChartEditor.hitsoundVolume; + return data.optionsChartEditor.hitsoundVolumePlayer; } - public var chartEditorHitsoundsEnabledPlayer(get, set):Bool; + public var chartEditorHitsoundVolumeOpponent(get, set):Float; - function get_chartEditorHitsoundsEnabledPlayer():Bool + function get_chartEditorHitsoundVolumeOpponent():Float { - if (this.optionsChartEditor.hitsoundsEnabledPlayer == null) this.optionsChartEditor.hitsoundsEnabledPlayer = true; + if (data.optionsChartEditor.hitsoundVolumeOpponent == null) data.optionsChartEditor.hitsoundVolumeOpponent = 1.0; - return this.optionsChartEditor.hitsoundsEnabledPlayer; + return data.optionsChartEditor.hitsoundVolumeOpponent; } - function set_chartEditorHitsoundsEnabledPlayer(value:Bool):Bool + function set_chartEditorHitsoundVolumeOpponent(value:Float):Float { // Set and apply. - this.optionsChartEditor.hitsoundsEnabledPlayer = value; + data.optionsChartEditor.hitsoundVolumeOpponent = value; flush(); - return this.optionsChartEditor.hitsoundsEnabledPlayer; - } - - public var chartEditorHitsoundsEnabledOpponent(get, set):Bool; - - function get_chartEditorHitsoundsEnabledOpponent():Bool - { - if (this.optionsChartEditor.hitsoundsEnabledOpponent == null) this.optionsChartEditor.hitsoundsEnabledOpponent = true; - - return this.optionsChartEditor.hitsoundsEnabledOpponent; - } - - function set_chartEditorHitsoundsEnabledOpponent(value:Bool):Bool - { - // Set and apply. - this.optionsChartEditor.hitsoundsEnabledOpponent = value; - flush(); - return this.optionsChartEditor.hitsoundsEnabledOpponent; + return data.optionsChartEditor.hitsoundVolumeOpponent; } public var chartEditorThemeMusic(get, set):Bool; function get_chartEditorThemeMusic():Bool { - if (this.optionsChartEditor.themeMusic == null) this.optionsChartEditor.themeMusic = true; + if (data.optionsChartEditor.themeMusic == null) data.optionsChartEditor.themeMusic = true; - return this.optionsChartEditor.themeMusic; + return data.optionsChartEditor.themeMusic; } function set_chartEditorThemeMusic(value:Bool):Bool { // Set and apply. - this.optionsChartEditor.themeMusic = value; + data.optionsChartEditor.themeMusic = value; flush(); - return this.optionsChartEditor.themeMusic; + return data.optionsChartEditor.themeMusic; } public var chartEditorPlaybackSpeed(get, set):Float; function get_chartEditorPlaybackSpeed():Float { - if (this.optionsChartEditor.playbackSpeed == null) this.optionsChartEditor.playbackSpeed = 1.0; + if (data.optionsChartEditor.playbackSpeed == null) data.optionsChartEditor.playbackSpeed = 1.0; - return this.optionsChartEditor.playbackSpeed; + return data.optionsChartEditor.playbackSpeed; } function set_chartEditorPlaybackSpeed(value:Float):Float { // Set and apply. - this.optionsChartEditor.playbackSpeed = value; + data.optionsChartEditor.playbackSpeed = value; flush(); - return this.optionsChartEditor.playbackSpeed; + return data.optionsChartEditor.playbackSpeed; } /** @@ -396,11 +393,11 @@ abstract Save(RawSaveData) */ public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null { - var level = this.scores.levels.get(levelId); + var level = data.scores.levels.get(levelId); if (level == null) { level = []; - this.scores.levels.set(levelId, level); + data.scores.levels.set(levelId, level); } return level.get(difficultyId); @@ -411,11 +408,11 @@ abstract Save(RawSaveData) */ public function setLevelScore(levelId:String, difficultyId:String, score:SaveScoreData):Void { - var level = this.scores.levels.get(levelId); + var level = data.scores.levels.get(levelId); if (level == null) { level = []; - this.scores.levels.set(levelId, level); + data.scores.levels.set(levelId, level); } level.set(difficultyId, score); @@ -424,11 +421,11 @@ abstract Save(RawSaveData) public function isLevelHighScore(levelId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool { - var level = this.scores.levels.get(levelId); + var level = data.scores.levels.get(levelId); if (level == null) { level = []; - this.scores.levels.set(levelId, level); + data.scores.levels.set(levelId, level); } var currentScore = level.get(difficultyId); @@ -467,11 +464,11 @@ abstract Save(RawSaveData) */ public function getSongScore(songId:String, difficultyId:String = 'normal'):Null { - var song = this.scores.songs.get(songId); + var song = data.scores.songs.get(songId); if (song == null) { song = []; - this.scores.songs.set(songId, song); + data.scores.songs.set(songId, song); } return song.get(difficultyId); } @@ -481,11 +478,11 @@ abstract Save(RawSaveData) */ public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void { - var song = this.scores.songs.get(songId); + var song = data.scores.songs.get(songId); if (song == null) { song = []; - this.scores.songs.set(songId, song); + data.scores.songs.set(songId, song); } song.set(difficultyId, score); @@ -501,11 +498,11 @@ abstract Save(RawSaveData) */ public function isSongHighScore(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool { - var song = this.scores.songs.get(songId); + var song = data.scores.songs.get(songId); if (song == null) { song = []; - this.scores.songs.set(songId, song); + data.scores.songs.set(songId, song); } var currentScore = song.get(difficultyId); @@ -546,9 +543,9 @@ abstract Save(RawSaveData) switch (inputType) { case Keys: - return (playerId == 0) ? this.options.controls.p1.keyboard : this.options.controls.p2.keyboard; + return (playerId == 0) ? data.options.controls.p1.keyboard : data.options.controls.p2.keyboard; case Gamepad(_): - return (playerId == 0) ? this.options.controls.p1.gamepad : this.options.controls.p2.gamepad; + return (playerId == 0) ? data.options.controls.p1.gamepad : data.options.controls.p2.gamepad; } } @@ -566,20 +563,20 @@ abstract Save(RawSaveData) case Keys: if (playerId == 0) { - this.options.controls.p1.keyboard = controls; + data.options.controls.p1.keyboard = controls; } else { - this.options.controls.p2.keyboard = controls; + data.options.controls.p2.keyboard = controls; } case Gamepad(_): if (playerId == 0) { - this.options.controls.p1.gamepad = controls; + data.options.controls.p1.gamepad = controls; } else { - this.options.controls.p2.gamepad = controls; + data.options.controls.p2.gamepad = controls; } } @@ -600,6 +597,36 @@ abstract Save(RawSaveData) } } + /** + * The user's current volume setting. + */ + public var volume(get, set):Float; + + function get_volume():Float + { + return data.volume; + } + + function set_volume(value:Float):Float + { + return data.volume = value; + } + + /** + * Whether the user's volume is currently muted. + */ + public var mute(get, set):Bool; + + function get_mute():Bool + { + return data.mute; + } + + function set_mute(value:Bool):Bool + { + return data.mute = value; + } + /** * Call this to make sure the save data is written to disk. */ @@ -625,17 +652,22 @@ abstract Save(RawSaveData) if (legacySaveData != null) { trace('[SAVE] Found legacy save data, converting...'); - FlxG.save.mergeData(SaveDataMigrator.migrateFromLegacy(legacySaveData)); + var gameSave = SaveDataMigrator.migrate(legacySaveData); + @:privateAccess + FlxG.save.mergeData(gameSave.data); + } + else + { + trace('[SAVE] No legacy save data found.'); } } else { trace('[SAVE] Loaded save data.'); - FlxG.save.mergeData(SaveDataMigrator.migrate(FlxG.save.data)); + @:privateAccess + var gameSave = SaveDataMigrator.migrate(FlxG.save.data); + FlxG.save.mergeData(gameSave.data); } - - trace('[SAVE] Done loading save data.'); - trace(FlxG.save.data); } static function fetchLegacySaveData():Null @@ -924,12 +956,6 @@ typedef SaveControlsData = */ var ?CUTSCENE_ADVANCE:Array; - /** - * Keybind for skipping a cutscene. - * @default `Escape` - */ - var ?CUTSCENE_SKIP:Array; - /** * Keybind for increasing volume. * @default `Plus` @@ -1004,10 +1030,16 @@ typedef SaveDataChartEditorOptions = var ?metronomeVolume:Float; /** - * Hitsound volume in the Chart Editor. + * Hitsound volume (player) in the Chart Editor. * @default `1.0` */ - var ?hitsoundVolume:Float; + var ?hitsoundVolumePlayer:Float; + + /** + * Hitsound volume (opponent) in the Chart Editor. + * @default `1.0` + */ + var ?hitsoundVolumeOpponent:Float; /** * If true, playtest songs from the current position in the Chart Editor. @@ -1015,18 +1047,6 @@ typedef SaveDataChartEditorOptions = */ var ?playtestStartTime:Bool; - /** - * Player note hit sounds in the Chart Editor. - * @default `true` - */ - var ?hitsoundsEnabledPlayer:Bool; - - /** - * Opponent note hit sounds in the Chart Editor. - * @default `true` - */ - var ?hitsoundsEnabledOpponent:Bool; - /** * Theme music in the Chart Editor. * @default `true` diff --git a/source/funkin/save/migrator/RawSaveData_v1_0_0.hx b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx index b71102cce..a516f944a 100644 --- a/source/funkin/save/migrator/RawSaveData_v1_0_0.hx +++ b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx @@ -35,7 +35,6 @@ typedef SaveControlsData_v1_0_0 = var ?ACCEPT:Array; var ?BACK:Array; var ?CUTSCENE_ADVANCE:Array; - var ?CUTSCENE_SKIP:Array; var ?NOTE_DOWN:Array; var ?NOTE_LEFT:Array; var ?NOTE_RIGHT:Array; diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx index 92bee4ceb..00637d52a 100644 --- a/source/funkin/save/migrator/SaveDataMigrator.hx +++ b/source/funkin/save/migrator/SaveDataMigrator.hx @@ -19,21 +19,21 @@ class SaveDataMigrator { trace('[SAVE] No version found in save data! Returning blank data.'); trace(inputData); - return new Save(); + return new Save(Save.getDefault()); } else { if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE)) { - // Simply cast the structured data. - var save:Save = inputData; + // Simply import the structured data. + var save:Save = new Save(inputData); return save; } else { trace('[SAVE] Invalid save data version! Returning blank data.'); trace(inputData); - return new Save(); + return new Save(Save.getDefault()); } } } @@ -45,7 +45,7 @@ class SaveDataMigrator { var inputSaveData:RawSaveData_v1_0_0 = cast inputData; - var result:Save = new Save(); + var result:Save = new Save(Save.getDefault()); result.volume = inputSaveData.volume; result.mute = inputSaveData.mute; @@ -272,7 +272,6 @@ class SaveDataMigrator ACCEPT: controlsData?.keys?.ACCEPT ?? null, BACK: controlsData?.keys?.BACK ?? null, CUTSCENE_ADVANCE: controlsData?.keys?.CUTSCENE_ADVANCE ?? null, - CUTSCENE_SKIP: controlsData?.keys?.CUTSCENE_SKIP ?? null, NOTE_DOWN: controlsData?.keys?.NOTE_DOWN ?? null, NOTE_LEFT: controlsData?.keys?.NOTE_LEFT ?? null, NOTE_RIGHT: controlsData?.keys?.NOTE_RIGHT ?? null, @@ -293,7 +292,6 @@ class SaveDataMigrator ACCEPT: controlsData?.pad?.ACCEPT ?? null, BACK: controlsData?.pad?.BACK ?? null, CUTSCENE_ADVANCE: controlsData?.pad?.CUTSCENE_ADVANCE ?? null, - CUTSCENE_SKIP: controlsData?.pad?.CUTSCENE_SKIP ?? null, NOTE_DOWN: controlsData?.pad?.NOTE_DOWN ?? null, NOTE_LEFT: controlsData?.pad?.NOTE_LEFT ?? null, NOTE_RIGHT: controlsData?.pad?.NOTE_RIGHT ?? null, diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx index 91822c557..ab62a082d 100644 --- a/source/funkin/ui/MusicBeatSubState.hx +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -1,6 +1,6 @@ package funkin.ui; -import flixel.addons.transition.FlxTransitionableSubState; +import flixel.addons.transition.FlxTransitionableState; import flixel.FlxSubState; import flixel.text.FlxText; import funkin.ui.mainmenu.MainMenuState; @@ -20,7 +20,16 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler { public var leftWatermarkText:FlxText = null; public var rightWatermarkText:FlxText = null; - public var conductorInUse:Conductor = Conductor.instance; + + public var conductorInUse(get, default):Conductor; + + var _conductorInUse:Null; + + function get_conductorInUse():Conductor + { + if (_conductorInUse == null) return Conductor.instance; + return _conductorInUse; + } public function new(bgColor:FlxColor = FlxColor.TRANSPARENT) { diff --git a/source/funkin/ui/debug/MemoryCounter.hx b/source/funkin/ui/debug/MemoryCounter.hx index 312d853e7..b25b55645 100644 --- a/source/funkin/ui/debug/MemoryCounter.hx +++ b/source/funkin/ui/debug/MemoryCounter.hx @@ -1,5 +1,6 @@ package funkin.ui.debug; +import funkin.util.MemoryUtil; import openfl.text.TextFormat; import openfl.system.System; import openfl.text.TextField; @@ -35,7 +36,7 @@ class MemoryCounter extends TextField @:noCompletion #if !flash override #end function __enterFrame(deltaTime:Float):Void { - var mem:Float = Math.round(System.totalMemory / BYTES_PER_MEG / ROUND_TO) * ROUND_TO; + var mem:Float = Math.round(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 5561a9dcd..46a095a65 100644 --- a/source/funkin/ui/debug/anim/DebugBoundingState.hx +++ b/source/funkin/ui/debug/anim/DebugBoundingState.hx @@ -40,6 +40,7 @@ import openfl.utils.ByteArray; import funkin.input.Cursor; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.util.SortUtil; +import haxe.ui.core.Screen; using flixel.util.FlxSpriteUtil; @@ -75,21 +76,19 @@ class DebugBoundingState extends FlxState var uiStuff:Component; + var haxeUIFocused(get, default):Bool = false; + + function get_haxeUIFocused():Bool + { + // get the screen position, according to the HUD camera, temp default to FlxG.camera juuust in case? + var hudMousePos:FlxPoint = FlxG.mouse.getScreenPosition(hudCam ?? FlxG.camera); + return Screen.instance.hasSolidComponentUnderPoint(hudMousePos.x, hudMousePos.y); + } + override function create() { Paths.setCurrentLevel('week1'); - var str = Paths.xml('ui/animation-editor/offset-editor-view'); - uiStuff = RuntimeComponentBuilder.fromAsset(str); - - // uiStuff.findComponent("btnViewSpriteSheet").onClick = _ -> curView = SPRITESHEET; - var dropdown:DropDown = cast uiStuff.findComponent("swapper"); - dropdown.onChange = function(e:UIEvent) { - trace(e.type); - curView = cast e.data.curView; - trace(e.data); - // trace(e.data); - }; // lv. // lv.onChange = function(e:UIEvent) // { @@ -106,23 +105,40 @@ class DebugBoundingState extends FlxState hudCam = new FlxCamera(); hudCam.bgColor.alpha = 0; - FlxG.cameras.add(hudCam, false); - bg = FlxGridOverlay.create(10, 10); // bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.GREEN); bg.scrollFactor.set(); add(bg); - initSpritesheetView(); - initOffsetView(); + // we are setting this as the default draw camera only temporarily, to trick haxeui + FlxG.cameras.add(hudCam); - Cursor.show(); + var str = Paths.xml('ui/animation-editor/offset-editor-view'); + uiStuff = RuntimeComponentBuilder.fromAsset(str); + + // uiStuff.findComponent("btnViewSpriteSheet").onClick = _ -> curView = SPRITESHEET; + var dropdown:DropDown = cast uiStuff.findComponent("swapper"); + dropdown.onChange = function(e:UIEvent) { + trace(e.type); + curView = cast e.data.curView; + trace(e.data); + // trace(e.data); + }; uiStuff.cameras = [hudCam]; add(uiStuff); + // sets the default camera back to FlxG.camera, since we set it to hudCamera for haxeui stuf + FlxG.cameras.setDefaultDrawTarget(FlxG.camera, true); + FlxG.cameras.setDefaultDrawTarget(hudCam, false); + + initSpritesheetView(); + initOffsetView(); + + Cursor.show(); + super.create(); } @@ -359,7 +375,7 @@ class DebugBoundingState extends FlxState offsetView.visible = true; offsetView.active = true; offsetControls(); - mouseOffsetMovement(); + if (!haxeUIFocused) mouseOffsetMovement(); } if (FlxG.keys.justPressed.H) hudCam.visible = !hudCam.visible; @@ -367,7 +383,7 @@ class DebugBoundingState extends FlxState if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); MouseUtil.mouseCamDrag(); - MouseUtil.mouseWheelZoom(); + if (!haxeUIFocused) MouseUtil.mouseWheelZoom(); // bg.scale.x = FlxG.camera.zoom; // bg.scale.y = FlxG.camera.zoom; diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 48a6e70c9..bdc0d311e 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -310,10 +310,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { this.songLengthInMs = value; - // Make sure playhead doesn't go outside the song. - if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs; - - onSongLengthChanged(); + updateGridHeight(); return this.songLengthInMs; } @@ -595,11 +592,22 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var playtestPracticeMode:Bool = false; + /** + * If true, playtesting a chart will make the computer do it for you! + */ + var playtestBotPlayMode:Bool = false; + /** * Enables or disables the "debugger" popup that appears when you run into a flixel error. */ var enabledDebuggerPopup:Bool = true; + /** + * Whether song scripts should be enabled during playtesting. + * You should probably check the box if the song has custom mechanics. + */ + var playtestSongScripts:Bool = true; + // Visuals /** @@ -704,19 +712,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var metronomeVolume:Float = 1.0; /** - * The volume to play hitsounds at. + * The volume to play the player's hitsounds at. */ - var hitsoundVolume:Float = 1.0; + var hitsoundVolumePlayer:Float = 1.0; /** - * Whether hitsounds are enabled for the player. + * The volume to play the opponent's hitsounds at. */ - var hitsoundsEnabledPlayer:Bool = true; - - /** - * Whether hitsounds are enabled for the opponent. - */ - var hitsoundsEnabledOpponent:Bool = true; + var hitsoundVolumeOpponent:Float = 1.0; /** * Whether hitsounds are enabled for at least one character. @@ -725,7 +728,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_hitsoundsEnabled():Bool { - return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent; + return hitsoundVolumePlayer + hitsoundVolumeOpponent > 0; } // Auto-save @@ -875,6 +878,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var noteDisplayDirty:Bool = true; + var noteTooltipsDirty:Bool = true; + /** * Whether the selected charactesr have been modified and the health icons need to be updated. */ @@ -928,12 +933,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_shouldShowBackupAvailableDialog():Bool { - return Save.get().chartEditorHasBackup; + return Save.instance.chartEditorHasBackup; } function set_shouldShowBackupAvailableDialog(value:Bool):Bool { - return Save.get().chartEditorHasBackup = value; + return Save.instance.chartEditorHasBackup = value; } /** @@ -1399,7 +1404,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_currentSongId():String { - return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-'); + return currentSongName.toLowerKebabCase().replace(' ', '-').sanitize(); } var currentSongArtist(get, set):String; @@ -1538,6 +1543,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Make sure view is updated when the variation changes. noteDisplayDirty = true; notePreviewDirty = true; + noteTooltipsDirty = true; notePreviewViewportBoundsDirty = true; switchToCurrentInstrumental(); @@ -1559,6 +1565,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Make sure view is updated when the difficulty changes. noteDisplayDirty = true; notePreviewDirty = true; + noteTooltipsDirty = true; notePreviewViewportBoundsDirty = true; // Make sure the difficulty we selected is in the list of difficulties. @@ -1757,30 +1764,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var menubarItemVolumeMetronome:Slider; - /** - * The `Audio -> Enable Player Hitsounds` menu checkbox. - */ - var menubarItemPlayerHitsounds:MenuCheckBox; - - /** - * The `Audio -> Enable Opponent Hitsounds` menu checkbox. - */ - var menubarItemOpponentHitsounds:MenuCheckBox; - /** * The `Audio -> Play Theme Music` menu checkbox. */ var menubarItemThemeMusic:MenuCheckBox; /** - * The `Audio -> Hitsound Volume` label. + * The `Audio -> Player Hitsound Volume` label. */ - var menubarLabelVolumeHitsounds:Label; + var menubarLabelVolumeHitsoundPlayer:Label; /** - * The `Audio -> Hitsound Volume` slider. + * The `Audio -> Enemy Hitsound Volume` label. */ - var menubarItemVolumeHitsounds:Slider; + var menubarLabelVolumeHitsoundOpponent:Label; + + /** + * The `Audio -> Player Hitsound Volume` slider. + */ + var menubarItemVolumeHitsoundPlayer:Slider; + + /** + * The `Audio -> Enemy Hitsound Volume` slider. + */ + var menubarItemVolumeHitsoundOpponent:Slider; /** * The `Audio -> Instrumental Volume` label. @@ -2171,7 +2178,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState public function loadPreferences():Void { - var save:Save = Save.get(); + var save:Save = Save.instance; if (previousWorkingFilePaths[0] == null) { @@ -2187,9 +2194,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState playtestStartTime = save.chartEditorPlaytestStartTime; currentTheme = save.chartEditorTheme; metronomeVolume = save.chartEditorMetronomeVolume; - hitsoundVolume = save.chartEditorHitsoundVolume; - hitsoundsEnabledPlayer = save.chartEditorHitsoundsEnabledPlayer; - hitsoundsEnabledOpponent = save.chartEditorHitsoundsEnabledOpponent; + hitsoundVolumePlayer = save.chartEditorHitsoundVolumePlayer; + hitsoundVolumePlayer = save.chartEditorHitsoundVolumeOpponent; this.welcomeMusic.active = save.chartEditorThemeMusic; // audioInstTrack.volume = save.chartEditorInstVolume; @@ -2200,7 +2206,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState public function writePreferences(hasBackup:Bool):Void { - var save:Save = Save.get(); + var save:Save = Save.instance; // Can't use filter() because of null safety checking! var filteredWorkingFilePaths:Array = []; @@ -2217,9 +2223,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState save.chartEditorPlaytestStartTime = playtestStartTime; save.chartEditorTheme = currentTheme; save.chartEditorMetronomeVolume = metronomeVolume; - save.chartEditorHitsoundVolume = hitsoundVolume; - save.chartEditorHitsoundsEnabledPlayer = hitsoundsEnabledPlayer; - save.chartEditorHitsoundsEnabledOpponent = hitsoundsEnabledOpponent; + save.chartEditorHitsoundVolumePlayer = hitsoundVolumePlayer; + save.chartEditorHitsoundVolumeOpponent = hitsoundVolumeOpponent; save.chartEditorThemeMusic = this.welcomeMusic.active; // save.chartEditorInstVolume = audioInstTrack.volume; @@ -2848,12 +2853,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menuBarItemInputStyleNone.onClick = function(event:UIEvent) { currentLiveInputStyle = None; }; + menuBarItemInputStyleNone.selected = currentLiveInputStyle == None; menuBarItemInputStyleNumberKeys.onClick = function(event:UIEvent) { currentLiveInputStyle = NumberKeys; }; + menuBarItemInputStyleNumberKeys.selected = currentLiveInputStyle == NumberKeys; menuBarItemInputStyleWASD.onClick = function(event:UIEvent) { - currentLiveInputStyle = WASD; + currentLiveInputStyle = WASDKeys; }; + menuBarItemInputStyleWASD.selected = currentLiveInputStyle == WASDKeys; menubarItemAbout.onClick = _ -> this.openAboutDialog(); menubarItemWelcomeDialog.onClick = _ -> this.openWelcomeDialog(true); @@ -2912,24 +2920,25 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState }; menubarItemVolumeMetronome.value = Std.int(metronomeVolume * 100); - menubarItemPlayerHitsounds.onChange = event -> hitsoundsEnabledPlayer = event.value; - menubarItemPlayerHitsounds.selected = hitsoundsEnabledPlayer; - - menubarItemOpponentHitsounds.onChange = event -> hitsoundsEnabledOpponent = event.value; - menubarItemOpponentHitsounds.selected = hitsoundsEnabledOpponent; - menubarItemThemeMusic.onChange = event -> { this.welcomeMusic.active = event.value; fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); }; menubarItemThemeMusic.selected = this.welcomeMusic.active; - menubarItemVolumeHitsound.onChange = event -> { + menubarItemVolumeHitsoundPlayer.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; - hitsoundVolume = volume; - menubarLabelVolumeHitsound.text = 'Hitsound - ${Std.int(event.value)}%'; + hitsoundVolumePlayer = volume; + menubarLabelVolumeHitsoundPlayer.text = 'Player - ${Std.int(event.value)}%'; }; - menubarItemVolumeHitsound.value = Std.int(hitsoundVolume * 100); + menubarItemVolumeHitsoundPlayer.value = Std.int(hitsoundVolumePlayer * 100); + + menubarItemVolumeHitsoundOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + hitsoundVolumeOpponent = volume; + menubarLabelVolumeHitsoundOpponent.text = 'Enemy - ${Std.int(event.value)}%'; + }; + menubarItemVolumeHitsoundOpponent.value = Std.int(hitsoundVolumeOpponent * 100); menubarItemVolumeInstrumental.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; @@ -2951,7 +2960,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemPlaybackSpeed.onChange = event -> { var pitch:Float = (event.value.toFloat() * 2.0) / 100.0; - pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25. + pitch = Math.floor(pitch / 0.05) * 0.05; // Round to nearest 5% + pitch = Math.max(0.05, Math.min(2.0, pitch)); // Clamp to 5% to 200% #if FLX_PITCH if (audioInstTrack != null) audioInstTrack.pitch = pitch; audioVocalTrackGroup.pitch = pitch; @@ -3417,7 +3427,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Update the event sprite's position. eventSprite.updateEventPosition(renderedEvents); // Update the sprite's graphic. TODO: Is this inefficient? - eventSprite.playAnimation(eventSprite.eventData.event); + eventSprite.playAnimation(eventSprite.eventData.eventKind); } else { @@ -3657,8 +3667,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState selectionSquare.width = eventSprite.width; selectionSquare.height = eventSprite.height; } + + // Additional cleanup on notes. + if (noteTooltipsDirty) eventSprite.updateTooltipText(); } + noteTooltipsDirty = false; + // Sort the notes DESCENDING. This keeps the sustain behind the associated note. renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort() @@ -4530,14 +4545,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { // Create an event and place it in the chart. // TODO: Figure out configuring event data. - var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.clone()); + var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.copy()); performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); } else { // Create a note and place it in the chart. - var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace.clone()); + var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); @@ -4678,9 +4693,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, eventKindToPlace, null); - if (eventKindToPlace != eventData.event) + if (eventKindToPlace != eventData.eventKind) { - eventData.event = eventKindToPlace; + eventData.eventKind = eventKindToPlace; } eventData.time = cursorSnappedMs; @@ -4942,7 +4957,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Place notes at the playhead. switch (currentLiveInputStyle) { - case ChartEditorLiveInputStyle.WASD: + case ChartEditorLiveInputStyle.WASDKeys: if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4); if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5); if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6); @@ -5245,6 +5260,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } // Would bind Ctrl+A and Ctrl+D here, but they are already bound to Select All and Select None. } + else + { + trace('Ignoring keybinds for View menu items because we are in live input mode (${currentLiveInputStyle}).'); + } } /** @@ -5267,14 +5286,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { // F1 = Open Help if (FlxG.keys.justPressed.F1) this.openUserGuideDialog(); - - // DEBUG KEYBIND: Ctrl + Alt + Shift + L = Crash the game. - #if debug - if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.L) - { - throw "DEBUG: Crashing the chart editor!"; - } - #end } function handleQuickWatch():Void @@ -5317,10 +5328,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; + var playbackRate:Float = ((menubarItemPlaybackSpeed.value ?? 1.0) * 2.0) / 100.0; + playbackRate = Math.floor(playbackRate / 0.05) * 0.05; // Round to nearest 5% + playbackRate = Math.max(0.05, Math.min(2.0, playbackRate)); // Clamp to 5% to 200% + var targetSong:Song; try { - targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false); + targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, playtestSongScripts, false); } catch (e) { @@ -5328,9 +5343,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return; } - LogStyle.WARNING.openConsole = enabledDebuggerPopup; - LogStyle.ERROR.openConsole = enabledDebuggerPopup; - // TODO: Rework asset system so we can remove this. switch (currentSongStage) { @@ -5348,7 +5360,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState Paths.setCurrentLevel('week6'); case 'tankmanBattlefield': Paths.setCurrentLevel('week7'); - case 'phillyStreets' | 'phillyBlazin': + case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2': Paths.setCurrentLevel('weekend1'); } @@ -5364,8 +5376,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState targetDifficulty: selectedDifficulty, targetVariation: selectedVariation, practiceMode: playtestPracticeMode, + botPlayMode: playtestBotPlayMode, minimalMode: minimal, startTimestamp: startTimestamp, + playbackRate: playbackRate, overrideMusic: true, }); @@ -5511,8 +5525,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.switchToInstrumental(currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent); } - function onSongLengthChanged():Void + public function updateGridHeight():Void { + // Make sure playhead doesn't go outside the song after we update the grid height. + if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs; + if (gridTiledSprite != null) { gridTiledSprite.height = songLengthInPixels; @@ -5929,7 +5946,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault()); tempNote.noteData = noteData; tempNote.scrollFactor.set(0, 0); - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, tempNote, 1, true); + var event:NoteScriptEvent = new HitNoteScriptEvent(tempNote, 0.0, 0, 'perfect', 0); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! @@ -5939,9 +5956,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState switch (noteData.getStrumlineIndex()) { case 0: // Player - if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolume); + if (hitsoundVolumePlayer > 0) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolumePlayer); case 1: // Opponent - if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolume); + if (hitsoundVolumeOpponent > 0) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolumeOpponent); } } } @@ -6139,7 +6156,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } /** - * Available input modes for the chart editor state. + * Available input modes for the chart editor state. Numbers/arrows/WASD available for other keybinds. */ enum ChartEditorLiveInputStyle { @@ -6154,9 +6171,9 @@ enum ChartEditorLiveInputStyle NumberKeys; /** - * WASD to place notes on opponent's side, arrow keys to place notes on player's side. + * WASD to place notes on opponent's side, Arrow keys to place notes on player's side. */ - WASD; + WASDKeys; } typedef ChartEditorParams = diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx index bd832fab3..a3728f337 100644 --- a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx @@ -40,6 +40,8 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.scrollPositionInPixels = 0; Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + + state.updateGridHeight(); } public function undo(state:ChartEditorState):Void @@ -62,6 +64,8 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.scrollPositionInPixels = 0; Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + + state.updateGridHeight(); } public function shouldAddToHistory(state:ChartEditorState):Bool diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx index 88f73cfed..423295f1a 100644 --- a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx @@ -34,11 +34,11 @@ class SelectItemsCommand implements ChartEditorCommand } // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event. - if (this.notes.length == 0 && this.events.length >= 1) + if (this.notes.length == 0 && this.events.length == 1) { var eventSelected = this.events[0]; - state.eventKindToPlace = eventSelected.event; + state.eventKindToPlace = eventSelected.eventKind; // This code is here to parse event data that's not built as a struct for some reason. // TODO: Clean this up or get rid of it. @@ -46,7 +46,7 @@ class SelectItemsCommand implements ChartEditorCommand var defaultKey = null; if (eventSchema == null) { - trace('[WARNING] Event schema not found for event ${eventSelected.event}.'); + trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.'); } else { @@ -60,7 +60,7 @@ class SelectItemsCommand implements ChartEditorCommand } // If we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note. - if (this.events.length == 0 && this.notes.length >= 1) + if (this.events.length == 0 && this.notes.length == 1) { var noteSelected = this.notes[0]; diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx index 5cc89e137..73cf80fa0 100644 --- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx @@ -31,11 +31,11 @@ class SetItemSelectionCommand implements ChartEditorCommand state.currentEventSelection = events; // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event. - if (this.notes.length == 0 && this.events.length >= 1) + if (this.notes.length == 0 && this.events.length == 1) { var eventSelected = this.events[0]; - state.eventKindToPlace = eventSelected.event; + state.eventKindToPlace = eventSelected.eventKind; // This code is here to parse event data that's not built as a struct for some reason. // TODO: Clean this up or get rid of it. @@ -43,7 +43,7 @@ class SetItemSelectionCommand implements ChartEditorCommand var defaultKey = null; if (eventSchema == null) { - trace('[WARNING] Event schema not found for event ${eventSelected.event}.'); + trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.'); } else { @@ -51,13 +51,18 @@ class SetItemSelectionCommand implements ChartEditorCommand } var eventData = eventSelected.valueAsStruct(defaultKey); - state.eventDataToPlace = eventData; + var eventDataClone = Reflect.copy(eventData); + + if (eventDataClone != null) + { + state.eventDataToPlace = eventDataClone; + } state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); } // IF we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note. - if (this.events.length == 0 && this.notes.length >= 1) + if (this.events.length == 0 && this.notes.length == 1) { var noteSelected = this.notes[0]; diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx index e3dae37cf..c996079bc 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx @@ -133,7 +133,7 @@ class ChartEditorEventSprite extends FlxSprite public function playAnimation(?name:String):Void { - if (name == null) name = eventData?.event ?? DEFAULT_EVENT; + if (name == null) name = eventData?.eventKind ?? DEFAULT_EVENT; var correctedName = correctAnimationName(name); this.animation.play(correctedName); @@ -160,12 +160,11 @@ class ChartEditorEventSprite extends FlxSprite else { this.visible = true; - playAnimation(value.event); + playAnimation(value.eventKind); this.eventData = value; // Update the position to match the note data. updateEventPosition(); - // Update the tooltip text. - this.tooltip.tipData = {text: this.eventData.buildTooltip()}; + updateTooltipText(); return this.eventData; } } @@ -188,6 +187,13 @@ class ChartEditorEventSprite extends FlxSprite this.updateTooltipPosition(); } + public function updateTooltipText():Void + { + if (this.eventData == null) return; + if (this.isGhost) return; + this.tooltip.tipData = {text: this.eventData.buildTooltip()}; + } + public function updateTooltipPosition():Void { // No tooltip for ghost sprites. diff --git a/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx index 1a76d1e22..2d8b62cbd 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx @@ -58,9 +58,15 @@ class ChartEditorMeasureTicks extends FlxTypedSpriteGroup var measureNumberInViewport = Math.floor(viewTopPosition / ChartEditorState.GRID_SIZE / Conductor.instance.stepsPerMeasure) + 1; var measureNumberPosition = measureNumberInViewport * ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure; - measureNumber.text = '${measureNumberInViewport + 1}'; measureNumber.y = measureNumberPosition + this.y; + // Show the measure number only if it isn't beneath the end of the note grid. + // Using measureNumber + 1 because the cut-off bar at the bottom is technically a bar, but it looks bad if a measure number shows up there. + if ((measureNumberInViewport + 1) < chartEditorState.songLengthInSteps / Conductor.instance.stepsPerMeasure) + measureNumber.text = '${measureNumberInViewport + 1}'; + else + measureNumber.text = ''; + // trace(measureNumber.text + ' at ' + measureNumber.y); } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx index 363dc1567..1e1d165f3 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -7,6 +7,7 @@ import funkin.audio.FunkinSound; import funkin.play.character.BaseCharacter.CharacterType; import funkin.util.FileUtil; import funkin.util.assets.SoundUtil; +import funkin.util.TimerUtil; import funkin.audio.waveform.WaveformData; import funkin.audio.waveform.WaveformDataParser; import funkin.audio.waveform.WaveformSprite; @@ -128,41 +129,41 @@ class ChartEditorAudioHandler public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool { - var perfA = haxe.Timer.stamp(); + var perfA:Float = TimerUtil.start(); var result:Bool = playInstrumental(state, instId); if (!result) return false; - var perfB = haxe.Timer.stamp(); + var perfB:Float = TimerUtil.start(); stopExistingVocals(state); - var perfC = haxe.Timer.stamp(); + var perfC:Float = TimerUtil.start(); result = playVocals(state, BF, playerId, instId); - var perfD = haxe.Timer.stamp(); + var perfD:Float = TimerUtil.start(); // if (!result) return false; result = playVocals(state, DAD, opponentId, instId); // if (!result) return false; - var perfE = haxe.Timer.stamp(); + var perfE:Float = TimerUtil.start(); state.hardRefreshOffsetsToolbox(); - var perfF = haxe.Timer.stamp(); + var perfF:Float = TimerUtil.start(); state.hardRefreshFreeplayToolbox(); - var perfG = haxe.Timer.stamp(); + var perfG:Float = TimerUtil.start(); - trace('Switched to instrumental in ${perfB - perfA} seconds.'); - trace('Stopped existing vocals in ${perfC - perfB} seconds.'); - trace('Played BF vocals in ${perfD - perfC} seconds.'); - trace('Played DAD vocals in ${perfE - perfD} seconds.'); - trace('Hard refreshed offsets toolbox in ${perfF - perfE} seconds.'); - trace('Hard refreshed freeplay toolbox in ${perfG - perfF} seconds.'); + trace('Switched to instrumental in ${TimerUtil.seconds(perfA, perfB)}.'); + trace('Stopped existing vocals in ${TimerUtil.seconds(perfB, perfC)}.'); + trace('Played BF vocals in ${TimerUtil.seconds(perfC, perfD)}.'); + trace('Played DAD vocals in ${TimerUtil.seconds(perfD, perfE)}.'); + trace('Hard refreshed offsets toolbox in ${TimerUtil.seconds(perfE, perfF)}.'); + trace('Hard refreshed freeplay toolbox in ${TimerUtil.seconds(perfF, perfG)}.'); return true; } @@ -174,10 +175,9 @@ class ChartEditorAudioHandler { if (instId == '') instId = 'default'; var instTrackData:Null = state.audioInstTrackData.get(instId); - var perfA = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); var instTrack:Null = SoundUtil.buildSoundFromBytes(instTrackData); - var perfB = haxe.Timer.stamp(); - trace('Built instrumental track in ${perfB - perfA} seconds.'); + trace('Built instrumental track in ${TimerUtil.seconds(perfStart)} seconds.'); if (instTrack == null) return false; stopExistingInstrumental(state); @@ -205,10 +205,9 @@ class ChartEditorAudioHandler { var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; var vocalTrackData:Null = state.audioVocalTrackData.get(trackId); - var perfStart = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); var vocalTrack:Null = SoundUtil.buildSoundFromBytes(vocalTrackData); - var perfEnd = haxe.Timer.stamp(); - trace('Built vocal track in ${perfEnd - perfStart} seconds.'); + trace('Built vocal track in ${TimerUtil.seconds(perfStart)}.'); if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup(); @@ -219,10 +218,9 @@ class ChartEditorAudioHandler case BF: state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); - var perfStart = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); var waveformData:Null = vocalTrack.waveformData; - var perfEnd = haxe.Timer.stamp(); - trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.'); + trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.'); if (waveformData != null) { @@ -246,10 +244,9 @@ class ChartEditorAudioHandler case DAD: state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); - var perfStart = haxe.Timer.stamp(); + var perfStart:Float = TimerUtil.start(); var waveformData:Null = vocalTrack.waveformData; - var perfEnd = haxe.Timer.stamp(); - trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.'); + trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.'); if (waveformData != null) { @@ -299,16 +296,14 @@ class ChartEditorAudioHandler */ public static function playSound(_state:ChartEditorState, path:String, volume:Float = 1.0):Void { - var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound(); var asset:Null = FlxG.sound.cache(path); if (asset == null) { trace('WARN: Failed to play sound $path, asset not found.'); return; } - snd.loadEmbedded(asset); + var snd:FunkinSound = FunkinSound.load(asset); snd.autoDestroy = true; - FlxG.sound.list.add(snd); snd.play(true); snd.volume = volume; } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index 970f021ac..b84c68f8d 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -808,7 +808,8 @@ class ChartEditorDialogHandler } songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel); #if FILE_DROP_SUPPORTED - addDropHandler(songVariationMetadataEntry, onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel)); + state.addDropHandler({component: songVariationMetadataEntry, handler: onDropFileMetadataVariation.bind(variation) + .bind(songVariationMetadataEntryLabel)}); #end chartContainerB.addComponent(songVariationMetadataEntry); @@ -832,7 +833,11 @@ class ChartEditorDialogHandler } songVariationChartDataEntry.onClick = onClickChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel); #if FILE_DROP_SUPPORTED - addDropHandler(songVariationChartDataEntry, onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel)); + state.addDropHandler( + { + component: songVariationChartDataEntry, + handler: onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel) + }); #end chartContainerB.addComponent(songVariationChartDataEntry); } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx index f32cc2bfb..8c7b1a8c1 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx @@ -299,6 +299,15 @@ class ChartEditorToolboxHandler state.playtestStartTime = checkboxStartTime.selected; }; + var checkboxBotPlay:Null = toolbox.findComponent('playtestBotPlayCheckbox', CheckBox); + if (checkboxBotPlay == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestBotPlayCheckbox component.'; + + checkboxBotPlay.selected = state.playtestBotPlayMode; + + checkboxBotPlay.onClick = _ -> { + state.playtestBotPlayMode = checkboxBotPlay.selected; + }; + var checkboxDebugger:Null = toolbox.findComponent('playtestDebuggerCheckbox', CheckBox); if (checkboxDebugger == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestDebuggerCheckbox component.'; @@ -309,6 +318,17 @@ class ChartEditorToolboxHandler state.enabledDebuggerPopup = checkboxDebugger.selected; }; + var checkboxSongScripts:Null = toolbox.findComponent('playtestSongScriptsCheckbox', CheckBox); + + if (checkboxSongScripts == null) + throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestSongScriptsCheckbox component.'; + + state.playtestSongScripts = checkboxSongScripts.selected; + + checkboxSongScripts.onClick = _ -> { + state.playtestSongScripts = checkboxSongScripts.selected; + }; + return toolbox; } diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx index 7b163ad3d..f0949846d 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -90,7 +90,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox // Edit the event data of any selected events. for (event in chartEditorState.currentEventSelection) { - event.event = chartEditorState.eventKindToPlace; + event.eventKind = chartEditorState.eventKindToPlace; event.value = chartEditorState.eventDataToPlace; } chartEditorState.saveDataDirty = true; @@ -237,6 +237,11 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox { value = event.target.value.value; } + else if (field.type == BOOL) + { + var chk:CheckBox = cast event.target; + value = cast(chk.selected, Null); // Need to cast to nullable bool or the compiler will get mad. + } trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}'); @@ -253,14 +258,15 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox // Edit the event data of any existing events. if (!_initializing && chartEditorState.currentEventSelection.length > 0) { - for (event in chartEditorState.currentEventSelection) + for (songEvent in chartEditorState.currentEventSelection) { - event.event = chartEditorState.eventKindToPlace; - event.value = chartEditorState.eventDataToPlace; + songEvent.eventKind = chartEditorState.eventKindToPlace; + songEvent.value = Reflect.copy(chartEditorState.eventDataToPlace); } chartEditorState.saveDataDirty = true; chartEditorState.noteDisplayDirty = true; chartEditorState.notePreviewDirty = true; + chartEditorState.noteTooltipsDirty = true; } } } diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx index c384e7a6d..c65781259 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx @@ -1,21 +1,22 @@ package funkin.ui.debug.charting.toolboxes; +import flixel.addons.display.FlxTiledSprite; +import flixel.math.FlxMath; import funkin.audio.SoundGroup; +import funkin.audio.waveform.WaveformDataParser; +import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand; +import funkin.ui.haxeui.components.WaveformPlayer; +import funkin.ui.freeplay.FreeplayState; +import funkin.util.TimerUtil; +import haxe.ui.backend.flixel.components.SpriteWrapper; import haxe.ui.components.Button; import haxe.ui.components.HorizontalSlider; import haxe.ui.components.Label; -import flixel.addons.display.FlxTiledSprite; -import flixel.math.FlxMath; import haxe.ui.components.NumberStepper; import haxe.ui.components.Slider; -import haxe.ui.backend.flixel.components.SpriteWrapper; -import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand; -import funkin.ui.haxeui.components.WaveformPlayer; -import funkin.audio.waveform.WaveformDataParser; import haxe.ui.containers.VBox; import haxe.ui.containers.Absolute; import haxe.ui.containers.ScrollView; -import funkin.ui.freeplay.FreeplayState; import haxe.ui.containers.Frame; import haxe.ui.core.Screen; import haxe.ui.events.DragEvent; @@ -288,12 +289,12 @@ class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox // Build player waveform. // waveformMusic.waveform.forceUpdate = true; - var perfStart = haxe.Timer.stamp(); - var waveformData1 = playerVoice.waveformData; - var waveformData2 = opponentVoice?.waveformData ?? playerVoice.waveformData; // this null check is for songs that only have 1 vocals file! + var perfStart:Float = TimerUtil.start(); + var waveformData1 = playerVoice?.waveformData; + var waveformData2 = opponentVoice?.waveformData ?? playerVoice?.waveformData; // this null check is for songs that only have 1 vocals file! var waveformData3 = chartEditorState.audioInstTrack.waveformData; - var waveformData = waveformData1.merge(waveformData2).merge(waveformData3); - trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds'); + var waveformData = waveformData3.merge(waveformData1).merge(waveformData2); + trace('Waveform data merging took: ${TimerUtil.seconds(perfStart)}'); waveformMusic.waveform.waveformData = waveformData; // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it. diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx index fd9209294..46721edfa 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx @@ -270,24 +270,21 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox // Build player waveform. // waveformPlayer.waveform.forceUpdate = true; - waveformPlayer.waveform.waveformData = playerVoice.waveformData; + waveformPlayer.waveform.waveformData = playerVoice?.waveformData; // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it. - waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC; + waveformPlayer.waveform.duration = (playerVoice?.length ?? 1000.0) / Constants.MS_PER_SEC; // Build opponent waveform. // waveformOpponent.waveform.forceUpdate = true; // note: if song only has one set of vocals (Vocals.ogg/mp3) then this is null and crashes charting editor // so we null check - if (opponentVoice != null) - { - waveformOpponent.waveform.waveformData = opponentVoice.waveformData; - waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC; - } + waveformOpponent.waveform.waveformData = opponentVoice?.waveformData; + waveformOpponent.waveform.duration = (opponentVoice?.length ?? 1000.0) / Constants.MS_PER_SEC; // Build instrumental waveform. // waveformInstrumental.waveform.forceUpdate = true; waveformInstrumental.waveform.waveformData = chartEditorState.audioInstTrack.waveformData; - waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC; + waveformInstrumental.waveform.duration = (instTrack?.length ?? 1000.0) / Constants.MS_PER_SEC; addOffsetsToAudioPreview(); } diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx index b26082f98..d2a0a053e 100644 --- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx +++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx @@ -117,18 +117,32 @@ class ChartEditorDropdowns "ugh" => "Ugh (Week 7)", "hehPrettyGood" => "Heh, Pretty Good (Week 7)", // Weekend 1 - "weekend-1-lightcan" => "Light Can (2hot)", - "weekend-1-kickcan" => "Kick Can (2hot)", - "weekend-1-kneecan" => "Knee Can (2hot)", - "weekend-1-cockgun" => "Cock Gun (2hot)", - "weekend-1-firegun" => "Fire Gun (2hot)", - "weekend-1-punchlow" => "Punch Low (Blazin)", - "weekend-1-punchhigh" => "Punch High (Blazin)", - "weekend-1-punchlowblocked" => "Punch Low Blocked (Blazin)", - "weekend-1-punchhighblocked" => "Punch High Blocked (Blazin)", - "weekend-1-dodgelow" => "Dodge Low (Blazin)", - "weekend-1-blockhigh" => "Block High (Blazin)", - "weekend-1-fakeout" => "Fakeout (Blazin)", + "weekend-1-punchhigh" => "Punch High (Blazin')", + "weekend-1-punchhighdodged" => "Punch High (Dodge) (Blazin')", + "weekend-1-punchhighblocked" => "Punch High (Block) (Blazin')", + "weekend-1-punchhighspin" => "Punch High (Spin) (Blazin')", + "weekend-1-punchlow" => "Punch Low (Blazin')", + "weekend-1-punchlowdodged" => "Punch Low (Dodge) (Blazin')", + "weekend-1-punchlowblocked" => "Punch Low (Block) (Blazin')", + "weekend-1-punchlowspin" => "Punch High (Spin) (Blazin')", + "weekend-1-picouppercutprep" => "Pico Uppercut (Prep) (Blazin')", + "weekend-1-picouppercut" => "Pico Uppercut (Blazin')", + "weekend-1-blockhigh" => "Block High (Blazin')", + "weekend-1-blocklow" => "Block Low (Blazin')", + "weekend-1-blockspin" => "Block High (Spin) (Blazin')", + "weekend-1-dodgehigh" => "Dodge High (Blazin')", + "weekend-1-dodgelow" => "Dodge Low (Blazin')", + "weekend-1-dodgespin" => "Dodge High (Spin) (Blazin')", + "weekend-1-hithigh" => "Hit High (Blazin')", + "weekend-1-hitlow" => "Hit Low (Blazin')", + "weekend-1-hitspin" => "Hit High (Spin) (Blazin')", + "weekend-1-darnelluppercutprep" => "Darnell Uppercut (Prep) (Blazin')", + "weekend-1-darnelluppercut" => "Darnell Uppercut (Blazin')", + "weekend-1-idle" => "Idle (Blazin')", + "weekend-1-fakeout" => "Fakeout (Blazin')", + "weekend-1-taunt" => "Taunt (If Fakeout) (Blazin')", + "weekend-1-tauntforce" => "Taunt (Forced) (Blazin')", + "weekend-1-reversefakeout" => "Fakeout (Reverse) (Blazin')", ]; public static function populateDropdownWithNoteKinds(dropDown:DropDown, startingKindId:String):DropDownEntry @@ -147,6 +161,8 @@ class ChartEditorDropdowns dropDown.dataSource.add(value); } + dropDown.dataSource.sort('id', ASCENDING); + return returnValue; } diff --git a/source/funkin/ui/debug/dialogue/ConversationDebugState.hx b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx index 33a6f365a..c2edcca5a 100644 --- a/source/funkin/ui/debug/dialogue/ConversationDebugState.hx +++ b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx @@ -11,6 +11,7 @@ import funkin.data.dialogue.DialogueBoxData; import funkin.data.dialogue.DialogueBoxRegistry; import funkin.data.dialogue.SpeakerData; import funkin.data.dialogue.SpeakerRegistry; +import funkin.data.freeplay.AlbumRegistry; import funkin.play.cutscene.dialogue.Conversation; import funkin.play.cutscene.dialogue.DialogueBox; import funkin.play.cutscene.dialogue.Speaker; @@ -36,11 +37,24 @@ class ConversationDebugState extends MusicBeatState public override function create():Void { - conversation = ConversationRegistry.instance.fetchEntry(conversationId); - conversation.completeCallback = onConversationComplete; - add(conversation); + super.create(); + startConversation(); + } - ScriptEventDispatcher.callEvent(conversation, new ScriptEvent(CREATE, false)); + function startConversation():Void + { + if (conversation != null) return; + + conversation = ConversationRegistry.instance.fetchEntry(conversationId); + if (conversation == null) return; + if (!conversation.alive) conversation.revive(); + + conversation.zIndex = 1000; + add(conversation); + refresh(); + + var event:ScriptEvent = new ScriptEvent(CREATE, false); + ScriptEventDispatcher.callEvent(conversation, event); } function onConversationComplete():Void @@ -61,15 +75,17 @@ class ConversationDebugState extends MusicBeatState if (conversation != null) { - if (controls.CUTSCENE_ADVANCE) conversation.advanceConversation(); - - if (controls.CUTSCENE_SKIP) + if (controls.CUTSCENE_ADVANCE) { - conversation.trySkipConversation(elapsed); + conversation.advanceConversation(); } - else + else if (controls.PAUSE) { - conversation.trySkipConversation(-1); + conversation.kill(); + remove(conversation); + conversation = null; + + FlxG.switchState(() -> new ConversationDebugState()); } } } diff --git a/source/funkin/ui/debug/stage/StageOffsetSubState.hx b/source/funkin/ui/debug/stage/StageOffsetSubState.hx index e8a5d0a23..fa5056220 100644 --- a/source/funkin/ui/debug/stage/StageOffsetSubState.hx +++ b/source/funkin/ui/debug/stage/StageOffsetSubState.hx @@ -49,8 +49,11 @@ class StageOffsetSubState extends HaxeUISubState { super.create(); + var playState = PlayState.instance; + FlxG.mouse.visible = true; - PlayState.instance.pauseMusic(); + playState.pauseMusic(); + playState.cancelAllCameraTweens(); FlxG.camera.target = null; setupUIListeners(); @@ -63,8 +66,8 @@ class StageOffsetSubState extends HaxeUISubState // add(uiStuff); - PlayState.instance.persistentUpdate = true; - component.cameras = [PlayState.instance.camHUD]; + playState.persistentUpdate = true; + component.cameras = [playState.camHUD]; // uiStuff.cameras = [PlayState.instance.camHUD]; // btn.cameras = [PlayState.instance.camHUD]; @@ -72,7 +75,7 @@ class StageOffsetSubState extends HaxeUISubState var layerList:ListView = findComponent("prop-layers"); - for (thing in PlayState.instance.currentStage) + for (thing in playState.currentStage) { var prop:StageProp = cast thing; if (prop != null && prop.name != null) diff --git a/source/funkin/ui/freeplay/Album.hx b/source/funkin/ui/freeplay/Album.hx new file mode 100644 index 000000000..7291c7357 --- /dev/null +++ b/source/funkin/ui/freeplay/Album.hx @@ -0,0 +1,89 @@ +package funkin.ui.freeplay; + +import funkin.data.freeplay.AlbumData; +import funkin.data.freeplay.AlbumRegistry; +import funkin.data.IRegistryEntry; +import flixel.graphics.FlxGraphic; + +/** + * A class representing the data for an album as displayed in Freeplay. + */ +class Album implements IRegistryEntry +{ + /** + * The internal ID for this album. + */ + public final id:String; + + /** + * The full data for an album. + */ + public final _data:AlbumData; + + public function new(id:String) + { + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse album data for id: $id'; + } + } + + /** + * Return the name of the album. + * @ + */ + public function getAlbumName():String + { + return _data.name; + } + + /** + * Return the artists of the album. + * @return The list of artists + */ + public function getAlbumArtists():Array + { + return _data.artists; + } + + /** + * Get the asset key for the album art. + * @return The asset key + */ + public function getAlbumArtAssetKey():String + { + return _data.albumArtAsset; + } + + /** + * Get the album art as a graphic, ready to apply to a sprite. + * @return The built graphic + */ + public function getAlbumArtGraphic():FlxGraphic + { + return FlxG.bitmap.add(Paths.image(getAlbumArtAssetKey())); + } + + /** + * Get the asset key for the album title. + */ + public function getAlbumTitleAssetKey():String + { + return _data.albumTitleAsset; + } + + public function toString():String + { + return 'Album($id)'; + } + + public function destroy():Void {} + + static function _fetchData(id:String):Null + { + return AlbumRegistry.instance.parseEntryDataWithMigration(id, AlbumRegistry.instance.fetchEntryVersion(id)); + } +} diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx new file mode 100644 index 000000000..a1e63c9a1 --- /dev/null +++ b/source/funkin/ui/freeplay/AlbumRoll.hx @@ -0,0 +1,192 @@ +package funkin.ui.freeplay; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup; +import flixel.util.FlxSort; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import flixel.tweens.FlxEase; +import funkin.data.freeplay.AlbumRegistry; +import funkin.graphics.FunkinSprite; +import funkin.util.SortUtil; +import openfl.utils.Assets; + +/** + * The graphic for the album roll in the FreeplayState. + * Simply set `albumID` to fetch the required data and update the textures. + */ +class AlbumRoll extends FlxSpriteGroup +{ + /** + * The ID of the album to display. + * Modify this value to automatically update the album art and title. + */ + public var albumId(default, set):String; + + function set_albumId(value:String):String + { + if (this.albumId != value) + { + this.albumId = value; + updateAlbum(); + } + + return value; + } + + var albumArt:FunkinSprite; + var albumTitle:FunkinSprite; + var difficultyStars:DifficultyStars; + + var _exitMovers:Null; + + var albumData:Album; + + public function new() + { + super(); + + albumTitle = new FunkinSprite(947, 491); + albumTitle.visible = true; + albumTitle.zIndex = 200; + add(albumTitle); + + difficultyStars = new DifficultyStars(140, 39); + + difficultyStars.stars.visible = true; + albumTitle.visible = false; + // albumArtist.visible = false; + + // var albumArtist:FlxSprite = new FlxSprite(1010, 607).loadGraphic(Paths.image('freeplay/albumArtist-kawaisprite')); + } + + /** + * Load the album data by ID and update the textures. + */ + function updateAlbum():Void + { + albumData = AlbumRegistry.instance.fetchEntry(albumId); + + if (albumData == null) + { + FlxG.log.warn('Could not find album data for album ID: ${albumId}'); + + return; + }; + + if (albumArt != null) + { + FlxTween.cancelTweensOf(albumArt); + albumArt.visible = false; + albumArt.destroy(); + remove(albumArt); + } + + // Paths.animateAtlas('freeplay/albumRoll'), + albumArt = FunkinSprite.create(1500, 360, albumData.getAlbumArtAssetKey()); + albumArt.setGraphicSize(262, 262); // Magic number for size IG + albumArt.zIndex = 100; + + // playIntro(); + add(albumArt); + + applyExitMovers(); + + if (Assets.exists(Paths.image(albumData.getAlbumTitleAssetKey()))) + { + albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey())); + } + else + { + albumTitle.visible = false; + } + + refresh(); + } + + public function refresh():Void + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + /** + * Apply exit movers for the album roll. + * @param exitMovers The exit movers to apply. + */ + public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData):Void + { + if (exitMovers == null) + { + exitMovers = _exitMovers; + } + else + { + _exitMovers = exitMovers; + } + + if (exitMovers == null) return; + + exitMovers.set([albumArt], + { + x: FlxG.width, + speed: 0.4, + wait: 0 + }); + exitMovers.set([albumTitle], + { + x: FlxG.width, + speed: 0.2, + wait: 0.1 + }); + + /* + exitMovers.set([albumArtist], + { + x: FlxG.width * 1.1, + speed: 0.2, + wait: 0.2 + }); + */ + exitMovers.set([difficultyStars], + { + x: FlxG.width * 1.2, + speed: 0.2, + wait: 0.3 + }); + } + + /** + * Play the intro animation on the album art. + */ + public function playIntro():Void + { + albumArt.visible = true; + FlxTween.tween(albumArt, {x: 950, y: 320, angle: -340}, 0.5, {ease: FlxEase.elasticOut}); + + albumTitle.visible = false; + new FlxTimer().start(0.75, function(_) { + showTitle(); + }); + } + + public function setDifficultyStars(?difficulty:Int):Void + { + if (difficulty == null) return; + + difficultyStars.difficulty = difficulty; + } + + public function showTitle():Void + { + albumTitle.visible = true; + } + + /** + * Make the album stars visible. + */ + public function showStars():Void + { + // albumArtist.visible = false; + difficultyStars.stars.visible = false; + } +} diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx index 2417cdf9a..55f43d2ef 100644 --- a/source/funkin/ui/freeplay/DJBoyfriend.hx +++ b/source/funkin/ui/freeplay/DJBoyfriend.hx @@ -4,7 +4,7 @@ import flixel.FlxSprite; import flixel.util.FlxSignal; import funkin.util.assets.FlxAnimationUtil; import funkin.graphics.adobeanimate.FlxAtlasSprite; -import flixel.system.FlxSound; +import flixel.sound.FlxSound; import flixel.util.FlxTimer; import funkin.audio.FlxStreamSound; @@ -156,8 +156,6 @@ class DJBoyfriend extends FlxAtlasSprite function setupAnimations():Void { - // frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk')); - // animation.addByPrefix('intro', "boyfriend dj intro", 24, false); addOffset('boyfriend dj intro', 8, 3); diff --git a/source/funkin/ui/freeplay/FreeplayFlames.hx b/source/funkin/ui/freeplay/FreeplayFlames.hx index a116fb813..c20d85898 100644 --- a/source/funkin/ui/freeplay/FreeplayFlames.hx +++ b/source/funkin/ui/freeplay/FreeplayFlames.hx @@ -23,7 +23,7 @@ class FreeplayFlames extends FlxSpriteGroup { var flame:FlxSprite = new FlxSprite(flameX + (flameSpreadX * i), flameY + (flameSpreadY * i)); flame.frames = Paths.getSparrowAtlas("freeplay/freeplayFlame"); - flame.animation.addByPrefix("flame", "fire loop", FlxG.random.int(23, 25), false); + flame.animation.addByPrefix("flame", "fire loop full instance 1", FlxG.random.int(23, 25), false); flame.animation.play("flame"); flame.visible = false; flameCount = 0; diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx index e266efca1..413b182e0 100644 --- a/source/funkin/ui/freeplay/FreeplayScore.hx +++ b/source/funkin/ui/freeplay/FreeplayScore.hx @@ -111,7 +111,7 @@ class ScoreNum extends FlxSprite for (i in 0...10) { var stringNum:String = numToString[i]; - animation.addByPrefix(stringNum, stringNum, 24, false); + animation.addByPrefix(stringNum, '$stringNum DIGITAL', 24, false); } this.digit = initDigit; diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index 39cab8759..fc11eec28 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -1,20 +1,14 @@ package funkin.ui.freeplay; -import flash.text.TextField; -import flixel.addons.display.FlxGridOverlay; import flixel.addons.transition.FlxTransitionableState; import flixel.addons.ui.FlxInputText; import flixel.FlxCamera; -import flixel.FlxGame; import flixel.FlxSprite; -import flixel.FlxState; import flixel.group.FlxGroup; import flixel.group.FlxGroup.FlxTypedGroup; -import flixel.group.FlxSpriteGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.input.touch.FlxTouch; import flixel.math.FlxAngle; -import flixel.math.FlxMath; -import funkin.graphics.FunkinCamera; import flixel.math.FlxPoint; import flixel.system.debug.watch.Tracker.TrackerProfile; import flixel.text.FlxText; @@ -23,36 +17,26 @@ import flixel.tweens.FlxTween; import flixel.util.FlxColor; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; import funkin.data.level.LevelRegistry; import funkin.data.song.SongRegistry; -import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; import funkin.graphics.shaders.AngleMask; import funkin.graphics.shaders.HSVShader; import funkin.graphics.shaders.PureColor; import funkin.graphics.shaders.StrokeShader; import funkin.input.Controls; -import funkin.input.Controls.Control; -import funkin.play.components.HealthIcon; -import funkin.play.PlayState; import funkin.play.PlayStatePlaylist; import funkin.play.song.Song; import funkin.save.Save; import funkin.save.Save.SaveScoreData; import funkin.ui.AtlasText; -import funkin.ui.freeplay.BGScrollingText; -import funkin.ui.freeplay.DifficultyStars; -import funkin.ui.freeplay.DJBoyfriend; -import funkin.ui.freeplay.FreeplayScore; -import funkin.ui.freeplay.LetterSort; -import funkin.ui.freeplay.SongMenuItem; import funkin.ui.mainmenu.MainMenuState; -import funkin.ui.MusicBeatState; import funkin.ui.MusicBeatSubState; import funkin.ui.transition.LoadingState; import funkin.ui.transition.StickerSubState; import funkin.util.MathUtil; -import funkin.util.MathUtil; -import lime.app.Future; import lime.utils.Assets; /** @@ -63,6 +47,9 @@ typedef FreeplayStateParams = ?character:String, }; +/** + * The state for the freeplay menu, allowing the player to select any song to play. + */ class FreeplayState extends MusicBeatSubState { // @@ -118,30 +105,31 @@ class FreeplayState extends MusicBeatSubState var grpDifficulties:FlxTypedSpriteGroup; var coolColors:Array = [ - 0xff9271fd, - 0xff9271fd, - 0xff223344, + 0xFF9271FD, + 0xFF9271FD, + 0xFF223344, 0xFF941653, - 0xFFfc96d7, - 0xFFa0d1ff, - 0xffff78bf, - 0xfff6b604 + 0xFFFC96D7, + 0xFFA0D1FF, + 0xFFFF78BF, + 0xFFF6B604 ]; var grpSongs:FlxTypedGroup; var grpCapsules:FlxTypedGroup; var curCapsule:SongMenuItem; var curPlaying:Bool = false; - var ostName:FlxText; - var difficultyStars:DifficultyStars; var displayedVariations:Array; var dj:DJBoyfriend; + var ostName:FlxText; + var albumRoll:AlbumRoll; + var letterSort:LetterSort; var typing:FlxInputText; - var exitMovers:Map, MoveData> = new Map(); + var exitMovers:ExitMoverData = new Map(); var stickerSubState:StickerSubState; @@ -177,7 +165,7 @@ class FreeplayState extends MusicBeatSubState #if discord_rpc // Updating Discord Rich Presence - DiscordClient.changePresence("In the Menus", null); + DiscordClient.changePresence('In the Menus', null); #end var isDebug:Bool = false; @@ -186,17 +174,14 @@ class FreeplayState extends MusicBeatSubState isDebug = true; #end - if (FlxG.sound.music == null || (FlxG.sound.music != null && !FlxG.sound.music.playing)) - { - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); - } + FunkinSound.playMusic('freakyMenu'); // Add a null entry that represents the RANDOM option songs.push(null); // TODO: This makes custom variations disappear from Freeplay. Figure out a better solution later. // Default character (BF) shows default and Erect variations. Pico shows only Pico variations. - displayedVariations = (currentCharacter == "bf") ? [Constants.DEFAULT_VARIATION, "erect"] : [currentCharacter]; + displayedVariations = (currentCharacter == 'bf') ? [Constants.DEFAULT_VARIATION, 'erect'] : [currentCharacter]; // programmatically adds the songs via LevelRegistry and SongRegistry for (levelId in LevelRegistry.instance.listBaseGameLevelIds()) @@ -206,7 +191,7 @@ class FreeplayState extends MusicBeatSubState var song:Song = SongRegistry.instance.fetchEntry(songId); // Only display songs which actually have available charts for the current character. - var availableDifficultiesForSong = song.listDifficulties(displayedVariations); + var availableDifficultiesForSong:Array = song.listDifficulties(displayedVariations); if (availableDifficultiesForSong.length == 0) continue; songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations)); @@ -226,17 +211,17 @@ class FreeplayState extends MusicBeatSubState trace(FlxG.camera.initialZoom); trace(FlxCamera.defaultZoom); - var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack')); - pinkBack.color = 0xFFffd4e9; // sets it to pink! + var pinkBack:FunkinSprite = FunkinSprite.create('freeplay/pinkBack'); + pinkBack.color = 0xFFFFD4E9; // sets it to pink! pinkBack.x -= pinkBack.width; FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); add(pinkBack); - var orangeBackShit:FlxSprite = new FlxSprite(84, 440).makeGraphic(Std.int(pinkBack.width), 75, 0xFFfeda00); + var orangeBackShit:FunkinSprite = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00); add(orangeBackShit); - var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400); + var alsoOrangeLOL:FunkinSprite = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400); add(alsoOrangeLOL); exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL], @@ -255,10 +240,10 @@ class FreeplayState extends MusicBeatSubState add(grpTxtScrolls); grpTxtScrolls.visible = false; - FlxG.debugger.addTrackerProfile(new TrackerProfile(BGScrollingText, ["x", "y", "speed", "size"])); + FlxG.debugger.addTrackerProfile(new TrackerProfile(BGScrollingText, ['x', 'y', 'speed', 'size'])); - var moreWays:BGScrollingText = new BGScrollingText(0, 160, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width, true, 43); - moreWays.funnyColor = 0xFFfff383; + var moreWays:BGScrollingText = 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); @@ -268,8 +253,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.4, }); - var funnyScroll:BGScrollingText = new BGScrollingText(0, 220, "BOYFRIEND", FlxG.width / 2, false, 60); - funnyScroll.funnyColor = 0xFFff9963; + var funnyScroll:BGScrollingText = new BGScrollingText(0, 220, 'BOYFRIEND', FlxG.width / 2, false, 60); + funnyScroll.funnyColor = 0xFFFF9963; funnyScroll.speed = -3.8; grpTxtScrolls.add(funnyScroll); @@ -281,7 +266,7 @@ class FreeplayState extends MusicBeatSubState wait: 0 }); - var txtNuts:BGScrollingText = new BGScrollingText(0, 285, "PROTECT YO NUTS", FlxG.width / 2, true, 43); + var txtNuts:BGScrollingText = new BGScrollingText(0, 285, 'PROTECT YO NUTS', FlxG.width / 2, true, 43); txtNuts.speed = 3.5; grpTxtScrolls.add(txtNuts); exitMovers.set([txtNuts], @@ -290,8 +275,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.4, }); - var funnyScroll2:BGScrollingText = new BGScrollingText(0, 335, "BOYFRIEND", FlxG.width / 2, false, 60); - funnyScroll2.funnyColor = 0xFFff9963; + var funnyScroll2:BGScrollingText = new BGScrollingText(0, 335, 'BOYFRIEND', FlxG.width / 2, false, 60); + funnyScroll2.funnyColor = 0xFFFF9963; funnyScroll2.speed = -3.8; grpTxtScrolls.add(funnyScroll2); @@ -301,8 +286,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.5, }); - var moreWays2:BGScrollingText = new BGScrollingText(0, 397, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width, true, 43); - moreWays2.funnyColor = 0xFFfff383; + var moreWays2:BGScrollingText = 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); @@ -312,8 +297,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.4 }); - var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y + 10, "BOYFRIEND", FlxG.width / 2, 60); - funnyScroll3.funnyColor = 0xFFfea400; + var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y + 10, 'BOYFRIEND', FlxG.width / 2, 60); + funnyScroll3.funnyColor = 0xFFFEA400; funnyScroll3.speed = -3.8; grpTxtScrolls.add(funnyScroll3); @@ -329,8 +314,10 @@ class FreeplayState extends MusicBeatSubState x: -dj.width * 1.6, speed: 0.5 }); + // TODO: Replace this. - if (currentCharacter == "pico") dj.visible = false; + if (currentCharacter == 'pico') dj.visible = false; + add(dj); var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad')); @@ -388,62 +375,23 @@ class FreeplayState extends MusicBeatSubState if (diffSprite.difficultyId == currentDifficulty) diffSprite.visible = true; } - // NOTE: This is an AtlasSprite because we use an animation to bring it into view. - // TODO: Add the ability to select the album graphic. - var albumArt:FlxAtlasSprite = new FlxAtlasSprite(640, 360, Paths.animateAtlas("freeplay/albumRoll")); - albumArt.visible = false; - add(albumArt); + albumRoll = new AlbumRoll(); + albumRoll.albumId = 'volume1'; + add(albumRoll); - exitMovers.set([albumArt], - { - x: FlxG.width, - speed: 0.4, - wait: 0 - }); - - var albumTitle:FlxSprite = new FlxSprite(947, 491).loadGraphic(Paths.image('freeplay/albumTitle-fnfvol1')); - var albumArtist:FlxSprite = new FlxSprite(1010, 607).loadGraphic(Paths.image('freeplay/albumArtist-kawaisprite')); - difficultyStars = new DifficultyStars(140, 39); - - difficultyStars.stars.visible = false; - albumTitle.visible = false; - albumArtist.visible = false; - - exitMovers.set([albumTitle], - { - x: FlxG.width, - speed: 0.2, - wait: 0.1 - }); - - exitMovers.set([albumArtist], - { - x: FlxG.width * 1.1, - speed: 0.2, - wait: 0.2 - }); - exitMovers.set([difficultyStars], - { - x: FlxG.width * 1.2, - speed: 0.2, - wait: 0.3 - }); - - add(albumTitle); - add(albumArtist); - add(difficultyStars); + albumRoll.applyExitMovers(exitMovers); var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK); overhangStuff.y -= overhangStuff.height; add(overhangStuff); FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut}); - var fnfFreeplay:FlxText = new FlxText(8, 8, 0, "FREEPLAY", 48); - fnfFreeplay.font = "VCR OSD Mono"; + 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 = new FlxText(8, 8, FlxG.width - 8 - 8, 'OFFICIAL OST', 48); + ostName.font = 'VCR OSD Mono'; ostName.alignment = RIGHT; ostName.visible = false; @@ -455,21 +403,21 @@ class FreeplayState extends MusicBeatSubState wait: 0 }); - var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2); + var sillyStroke:StrokeShader = new StrokeShader(0xFFFFFFFF, 2, 2); fnfFreeplay.shader = sillyStroke; add(fnfFreeplay); add(ostName); var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70); fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); - fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false); + fnfHighscoreSpr.animation.addByPrefix('highscore', 'highscore small instance 1', 24, false); fnfHighscoreSpr.visible = false; fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1)); fnfHighscoreSpr.updateHitbox(); add(fnfHighscoreSpr); new FlxTimer().start(FlxG.random.float(12, 50), function(tmr) { - fnfHighscoreSpr.animation.play("highscore"); + fnfHighscoreSpr.animation.play('highscore'); tmr.time = FlxG.random.float(20, 60); }, 0); @@ -480,7 +428,7 @@ class FreeplayState extends MusicBeatSubState var clearBoxSprite:FlxSprite = new FlxSprite(1165, 65).loadGraphic(Paths.image('freeplay/clearBox')); add(clearBoxSprite); - txtCompletion = new AtlasText(1185, 87, "69", AtlasFont.FREEPLAY_CLEAR); + txtCompletion = new AtlasText(1185, 87, '69', AtlasFont.FREEPLAY_CLEAR); txtCompletion.visible = false; add(txtCompletion); @@ -497,9 +445,9 @@ class FreeplayState extends MusicBeatSubState letterSort.changeSelectionCallback = (str) -> { switch (str) { - case "fav": + case 'fav': generateSongList({filterType: FAVORITE}, true); - case "ALL": + case 'ALL': generateSongList(null, true); default: generateSongList({filterType: REGEXP, filterData: str}, true); @@ -515,25 +463,20 @@ class FreeplayState extends MusicBeatSubState dj.onIntroDone.add(function() { // when boyfriend hits dat shiii - albumArt.visible = true; - albumArt.anim.play(""); - albumArt.anim.onComplete = function() { - albumArt.anim.pause(); - }; + albumRoll.playIntro(); - new FlxTimer().start(1, function(_) { - albumTitle.visible = true; + new FlxTimer().start(0.75, function(_) { + albumRoll.showTitle(); }); new FlxTimer().start(35 / 24, function(_) { - albumArtist.visible = true; - difficultyStars.stars.visible = true; + albumRoll.showStars(); }); FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut}); - var diffSelLeft = new DifficultySelector(20, grpDifficulties.y - 10, false, controls); - var diffSelRight = new DifficultySelector(325, grpDifficulties.y - 10, true, controls); + var diffSelLeft:DifficultySelector = new DifficultySelector(20, grpDifficulties.y - 10, false, controls); + var diffSelRight:DifficultySelector = new DifficultySelector(325, grpDifficulties.y - 10, true, controls); add(diffSelLeft); add(diffSelRight); @@ -563,7 +506,7 @@ class FreeplayState extends MusicBeatSubState }); }); - pinkBack.color = 0xFFffd863; + pinkBack.color = 0xFFFFD863; bgDad.visible = true; orangeBackShit.visible = true; alsoOrangeLOL.visible = true; @@ -572,9 +515,9 @@ class FreeplayState extends MusicBeatSubState generateSongList(null, false); - var swag:Alphabet = new Alphabet(1, 0, "swag"); + // var swag:Alphabet = new Alphabet(1, 0, 'swag'); - var funnyCam = new FunkinCamera(0, 0, FlxG.width, FlxG.height); + var funnyCam:FunkinCamera = new FunkinCamera(0, 0, FlxG.width, FlxG.height); funnyCam.bgColor = FlxColor.TRANSPARENT; FlxG.cameras.add(funnyCam); @@ -589,12 +532,20 @@ class FreeplayState extends MusicBeatSubState }); } - public function generateSongList(?filterStuff:SongFilter, force:Bool = false) + /** + * Given the current filter, rebuild the current song list. + * + * @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite) + * @param force + */ + public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void { curSelected = 1; for (cap in grpCapsules.members) + { cap.kill(); + } var tempSongs:Array = songs; @@ -605,7 +556,7 @@ class FreeplayState extends MusicBeatSubState case REGEXP: // filterStuff.filterData has a string with the first letter of the sorting range, and the second one // this creates a filter to return all the songs that start with a letter between those two - var filterRegexp = new EReg("^[" + filterStuff.filterData + "].*", "i"); + var filterRegexp:EReg = new EReg('^[' + filterStuff.filterData + '].*', 'i'); tempSongs = tempSongs.filter(str -> { if (str == null) return true; // Random return filterRegexp.match(str.songName); @@ -661,14 +612,19 @@ class FreeplayState extends MusicBeatSubState funnyMenu.favIcon.visible = tempSongs[i].isFav; funnyMenu.hsvShader = hsvShader; - if (i < 8) funnyMenu.initJumpIn(Math.min(i, 4), force); + if (i < 8) + { + funnyMenu.initJumpIn(Math.min(i, 4), force); + } else + { funnyMenu.forcePosition(); + } grpCapsules.add(funnyMenu); } - FlxG.console.registerFunction("changeSelection", changeSelection); + FlxG.console.registerFunction('changeSelection', changeSelection); rememberSelection(); @@ -692,7 +648,7 @@ class FreeplayState extends MusicBeatSubState var busy:Bool = false; // Set to true once the user has pressed enter to select a song. - override function update(elapsed:Float) + override function update(elapsed:Float):Void { super.update(elapsed); @@ -700,7 +656,7 @@ class FreeplayState extends MusicBeatSubState { if (songs[curSelected] != null) { - var realShit = curSelected; + var realShit:Int = curSelected; songs[curSelected].isFav = !songs[curSelected].isFav; if (songs[curSelected].isFav) { @@ -709,7 +665,7 @@ class FreeplayState extends MusicBeatSubState ease: FlxEase.elasticOut, onComplete: _ -> { grpCapsules.members[realShit].favIcon.visible = true; - grpCapsules.members[realShit].favIcon.animation.play("fav"); + grpCapsules.members[realShit].favIcon.animation.play('fav'); } }); } @@ -773,9 +729,9 @@ class FreeplayState extends MusicBeatSubState { if (busy) return; - var upP = controls.UI_UP_P; - var downP = controls.UI_DOWN_P; - var accepted = controls.ACCEPT; + var upP:Bool = controls.UI_UP_P; + var downP:Bool = controls.UI_DOWN_P; + var accepted:Bool = controls.ACCEPT; if (FlxG.onMobile) { @@ -787,14 +743,14 @@ class FreeplayState extends MusicBeatSubState } if (touch.pressed) { - var dx = initTouchPos.x - touch.screenX; - var dy = initTouchPos.y - touch.screenY; + var dx:Float = initTouchPos.x - touch.screenX; + var dy:Float = initTouchPos.y - touch.screenY; - var angle = Math.atan2(dy, dx); - var length = Math.sqrt(dx * dx + dy * dy); + var angle:Float = Math.atan2(dy, dx); + var length:Float = Math.sqrt(dx * dx + dy * dy); - FlxG.watch.addQuick("LENGTH", length); - FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle))); + FlxG.watch.addQuick('LENGTH', length); + FlxG.watch.addQuick('ANGLE', Math.round(FlxAngle.asDegrees(angle))); } } @@ -859,9 +815,14 @@ class FreeplayState extends MusicBeatSubState { spamTimer = 0; - if (controls.UI_UP) changeSelection(-1); + if (controls.UI_UP) + { + changeSelection(-1); + } else + { changeSelection(1); + } } } else if (spamTimer >= 0.9) spamming = true; @@ -900,16 +861,6 @@ class FreeplayState extends MusicBeatSubState changeDiff(1); } - // TODO: DEBUG REMOVE THIS - if (FlxG.keys.justPressed.P) - { - var newParams:FreeplayStateParams = - { - character: currentCharacter == "bf" ? "pico" : "bf", - }; - openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(newParams, sticker))); - } - if (controls.BACK && !typing.hasFocus) { FlxTween.globalManager.clear(); @@ -975,18 +926,18 @@ class FreeplayState extends MusicBeatSubState public override function destroy():Void { super.destroy(); - var daSong = songs[curSelected]; + var daSong:Null = songs[curSelected]; if (daSong != null) { clearDaCache(daSong.songName); } } - function changeDiff(change:Int = 0) + function changeDiff(change:Int = 0):Void { touchTimer = 0; - var currentDifficultyIndex = diffIdsCurrent.indexOf(currentDifficulty); + var currentDifficultyIndex:Int = diffIdsCurrent.indexOf(currentDifficulty); if (currentDifficultyIndex == -1) currentDifficultyIndex = diffIdsCurrent.indexOf(Constants.DEFAULT_DIFFICULTY); @@ -997,10 +948,10 @@ class FreeplayState extends MusicBeatSubState currentDifficulty = diffIdsCurrent[currentDifficultyIndex]; - var daSong = songs[curSelected]; + var daSong:Null = songs[curSelected]; if (daSong != null) { - var songScore:SaveScoreData = Save.get().getSongScore(songs[curSelected].songId, currentDifficulty); + var songScore:SaveScoreData = Save.instance.getSongScore(songs[curSelected].songId, currentDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore?.accuracy ?? 0.0; rememberedDifficulty = currentDifficulty; @@ -1061,11 +1012,12 @@ class FreeplayState extends MusicBeatSubState } // Set the difficulty star count on the right. - difficultyStars.difficulty = daSong?.songRating ?? difficultyStars.difficulty; // yay haxe 4.3 + albumRoll.setDifficultyStars(daSong?.songRating); + albumRoll.albumId = daSong?.albumId ?? Constants.DEFAULT_ALBUM_ID; } // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String) - function clearDaCache(actualSongTho:String) + function clearDaCache(actualSongTho:String):Void { for (song in songs) { @@ -1080,7 +1032,7 @@ class FreeplayState extends MusicBeatSubState function capsuleOnConfirmRandom(randomCapsule:SongMenuItem):Void { - trace("RANDOM SELECTED"); + trace('RANDOM SELECTED'); busy = true; letterSort.inputEnabled = false; @@ -1096,7 +1048,7 @@ class FreeplayState extends MusicBeatSubState if (availableSongCapsules.length == 0) { - trace("No songs available!"); + trace('No songs available!'); busy = false; letterSort.inputEnabled = true; FlxG.sound.play(Paths.sound('cancelMenu')); @@ -1142,6 +1094,12 @@ class FreeplayState extends MusicBeatSubState targetSong: targetSong, targetDifficulty: targetDifficulty, targetVariation: targetVariation, + practiceMode: false, + minimalMode: false, + // TODO: Make these an option! It's currently only accessible via chart editor. + // startTimestamp: 0.0, + // playbackRate: 0.5, + // botPlayMode: true, }, true); }); } @@ -1162,27 +1120,26 @@ class FreeplayState extends MusicBeatSubState } // Set the difficulty star count on the right. - var daSong = songs[curSelected]; - difficultyStars.difficulty = daSong?.songRating ?? 0; + var daSong:Null = songs[curSelected]; + albumRoll.setDifficultyStars(daSong?.songRating ?? 0); } - function changeSelection(change:Int = 0) + function changeSelection(change:Int = 0):Void { - // NGio.logEvent('Fresh'); FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName)); - var prevSelected = curSelected; + var prevSelected:Int = curSelected; curSelected += change; if (curSelected < 0) curSelected = grpCapsules.countLiving() - 1; if (curSelected >= grpCapsules.countLiving()) curSelected = 0; - var daSongCapsule = grpCapsules.members[curSelected]; + var daSongCapsule:SongMenuItem = grpCapsules.members[curSelected]; if (daSongCapsule.songData != null) { - var songScore:SaveScoreData = Save.get().getSongScore(daSongCapsule.songData.songId, currentDifficulty); + var songScore:SaveScoreData = Save.instance.getSongScore(daSongCapsule.songData.songId, currentDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore?.accuracy ?? 0.0; diffIdsCurrent = daSongCapsule.songData.songDifficulties; @@ -1221,7 +1178,7 @@ class FreeplayState extends MusicBeatSubState // TODO: Stream the instrumental of the selected song? if (prevSelected == 0) { - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); + FunkinSound.playMusic('freakyMenu'); FlxG.sound.music.fadeIn(2, 0, 0.8); } } @@ -1230,6 +1187,9 @@ class FreeplayState extends MusicBeatSubState } } +/** + * The difficulty selector arrows to the left and right of the difficulty. + */ class DifficultySelector extends FlxSprite { var controls:Controls; @@ -1242,7 +1202,7 @@ class DifficultySelector extends FlxSprite this.controls = controls; frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); - animation.addByPrefix('shine', "arrow pointer loop", 24); + animation.addByPrefix('shine', 'arrow pointer loop', 24); animation.play('shine'); whiteShader = new PureColor(FlxColor.WHITE); @@ -1252,7 +1212,7 @@ class DifficultySelector extends FlxSprite flipX = flipped; } - override function update(elapsed:Float) + override function update(elapsed:Float):Void { if (flipX && controls.UI_RIGHT_P) moveShitDown(); if (!flipX && controls.UI_LEFT_P) moveShitDown(); @@ -1260,7 +1220,7 @@ class DifficultySelector extends FlxSprite super.update(elapsed); } - function moveShitDown() + function moveShitDown():Void { offset.y -= 5; @@ -1276,34 +1236,62 @@ class DifficultySelector extends FlxSprite } } +/** + * Structure for the current song filter. + */ typedef SongFilter = { var filterType:FilterType; var ?filterData:Dynamic; } +/** + * Possible types to use for the song filter. + */ enum abstract FilterType(String) { - var STARTSWITH; - var REGEXP; - var FAVORITE; - var ALL; + /** + * Filter to songs which start with a string + */ + public var STARTSWITH; + + /** + * Filter to songs which match a regular expression + */ + public var REGEXP; + + /** + * Filter to songs which are favorited + */ + public var FAVORITE; + + /** + * Filter to all songs + */ + public var ALL; } +/** + * Data about a specific song in the freeplay menu. + */ class FreeplaySongData { + /** + * Whether or not the song has been favorited. + */ public var isFav:Bool = false; var song:Song; - public var levelId(default, null):String = ""; - public var songId(default, null):String = ""; + public var levelId(default, null):String = ''; + public var songId(default, null):String = ''; public var songDifficulties(default, null):Array = []; - public var songName(default, null):String = ""; - public var songCharacter(default, null):String = ""; + public var songName(default, null):String = ''; + public var songCharacter(default, null):String = ''; public var songRating(default, null):Int = 0; + public var albumId(default, null):String = ''; public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; public var displayedVariations(default, null):Array = [Constants.DEFAULT_VARIATION]; @@ -1327,19 +1315,28 @@ class FreeplaySongData updateValues(displayedVariations); } - function updateValues(displayedVariations:Array):Void + function updateValues(variations:Array):Void { - this.songDifficulties = song.listDifficulties(displayedVariations); + this.songDifficulties = song.listDifficulties(variations); if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY; - var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, displayedVariations); + var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, variations); if (songDifficulty == null) return; this.songName = songDifficulty.songName; this.songCharacter = songDifficulty.characters.opponent; this.songRating = songDifficulty.difficultyRating; + this.albumId = songDifficulty.album; } } +/** + * The map storing information about the exit movers. + */ +typedef ExitMoverData = Map, MoveData>; + +/** + * The data for an exit mover. + */ typedef MoveData = { var ?x:Float; @@ -1348,8 +1345,14 @@ typedef MoveData = var ?wait:Float; } +/** + * The sprite for the difficulty + */ class DifficultySprite extends FlxSprite { + /** + * The difficulty id which this sprite represents. + */ public var difficultyId:String; public function new(diffId:String) diff --git a/source/funkin/ui/freeplay/ScriptedAlbum.hx b/source/funkin/ui/freeplay/ScriptedAlbum.hx new file mode 100644 index 000000000..737f97ad2 --- /dev/null +++ b/source/funkin/ui/freeplay/ScriptedAlbum.hx @@ -0,0 +1,9 @@ +package funkin.ui.freeplay; + +/** + * A script that can be tied to an Album. + * Create a scripted class that extends Album to use this. + * This allows you to customize how a specific album appears. + */ +@:hscriptClass +class ScriptedAlbum extends funkin.ui.freeplay.Album implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx index 06d113468..c20d81328 100644 --- a/source/funkin/ui/freeplay/SongMenuItem.hx +++ b/source/funkin/ui/freeplay/SongMenuItem.hx @@ -65,25 +65,26 @@ class SongMenuItem extends FlxSpriteGroup var rank:String = FlxG.random.getObject(ranks); ranking = new FlxSprite(capsule.width * 0.84, 30); - ranking.loadGraphic(Paths.image("freeplay/ranks/" + rank)); + ranking.loadGraphic(Paths.image('freeplay/ranks/' + rank)); ranking.scale.x = ranking.scale.y = realScaled; - ranking.alpha = 0.75; + // ranking.alpha = 0.75; + ranking.visible = false; ranking.origin.set(capsule.origin.x - ranking.x, capsule.origin.y - ranking.y); add(ranking); grpHide.add(ranking); switch (rank) { - case "perfect": + case 'perfect': ranking.x -= 10; } grayscaleShader = new Grayscale(1); - diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRatings/diff00")); + diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image('freeplay/diffRatings/diff00')); diffRatingSprite.shader = grayscaleShader; - diffRatingSprite.visible = false; - add(diffRatingSprite); + // TODO: Readd once ratings are fully implemented + // add(diffRatingSprite); diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y); grpHide.add(diffRatingSprite); @@ -104,7 +105,7 @@ class SongMenuItem extends FlxSpriteGroup favIcon = new FlxSprite(400, 40); favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart'); - favIcon.animation.addByPrefix('fav', "favorite heart", 24, false); + favIcon.animation.addByPrefix('fav', 'favorite heart', 24, false); favIcon.animation.play('fav'); favIcon.setGraphicSize(50, 50); favIcon.visible = false; @@ -114,10 +115,11 @@ class SongMenuItem extends FlxSpriteGroup setVisibleGrp(false); } - function updateDifficultyRating(newRating:Int) + function updateDifficultyRating(newRating:Int):Void { var ratingPadded:String = newRating < 10 ? '0$newRating' : '$newRating'; diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}')); + diffRatingSprite.visible = false; } function set_hsvShader(value:HSVShader):HSVShader @@ -129,7 +131,7 @@ class SongMenuItem extends FlxSpriteGroup return value; } - function textAppear() + function textAppear():Void { songText.scale.x = 1.7; songText.scale.y = 0.2; @@ -144,7 +146,7 @@ class SongMenuItem extends FlxSpriteGroup }); } - function setVisibleGrp(value:Bool) + function setVisibleGrp(value:Bool):Void { for (spr in grpHide.members) { @@ -156,7 +158,7 @@ class SongMenuItem extends FlxSpriteGroup updateSelected(); } - public function init(?x:Float, ?y:Float, songData:Null) + public function init(?x:Float, ?y:Float, songData:Null):Void { if (x != null) this.x = x; if (y != null) this.y = y; @@ -176,7 +178,7 @@ class SongMenuItem extends FlxSpriteGroup * @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) + public function setCharacter(char:String):Void { var charPath:String = "freeplay/icons/"; @@ -186,18 +188,18 @@ class SongMenuItem extends FlxSpriteGroup // 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"; + 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"; + charPath += '${char}pixel'; } if (!openfl.utils.Assets.exists(Paths.image(charPath))) @@ -211,7 +213,7 @@ class SongMenuItem extends FlxSpriteGroup switch (char) { - case "parents-christmas": + case 'parents-christmas': pixelIcon.origin.x = 140; default: pixelIcon.origin.x = 100; @@ -262,7 +264,7 @@ class SongMenuItem extends FlxSpriteGroup var grpHide:FlxGroup; - public function forcePosition() + public function forcePosition():Void { visible = true; capsule.alpha = 1; @@ -287,7 +289,7 @@ class SongMenuItem extends FlxSpriteGroup setVisibleGrp(true); } - override function update(elapsed:Float) + override function update(elapsed:Float):Void { if (doJumpIn) { diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx index c7171fac7..77b23d68a 100644 --- a/source/funkin/ui/haxeui/components/CharacterPlayer.hx +++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx @@ -2,6 +2,7 @@ package funkin.ui.haxeui.components; import funkin.modding.events.ScriptEvent.GhostMissNoteScriptEvent; import funkin.modding.events.ScriptEvent.NoteScriptEvent; +import funkin.modding.events.ScriptEvent.HitNoteScriptEvent; import funkin.modding.events.ScriptEvent.SongTimeScriptEvent; import funkin.modding.events.ScriptEvent.UpdateScriptEvent; import haxe.ui.core.IDataComponent; @@ -216,12 +217,17 @@ class CharacterPlayer extends Box if (character != null) character.onStepHit(event); } + public function onNoteIncoming(event:NoteScriptEvent) + { + if (character != null) character.onNoteIncoming(event); + } + /** * Called when a note is hit in the song * Used to play character animations. * @param event The event. */ - public function onNoteHit(event:NoteScriptEvent):Void + public function onNoteHit(event:HitNoteScriptEvent):Void { if (character != null) character.onNoteHit(event); } diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index 8842c37de..f4d48dba3 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -1,6 +1,6 @@ package funkin.ui.mainmenu; -import flixel.addons.transition.FlxTransitionableSubState; +import flixel.addons.transition.FlxTransitionableState; import funkin.ui.debug.DebugMenuSubState; import flixel.FlxObject; import flixel.FlxSprite; @@ -12,8 +12,10 @@ import flixel.util.typeLimit.NextState; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.input.touch.FlxTouch; import flixel.text.FlxText; +import funkin.data.song.SongData.SongMusicData; import flixel.tweens.FlxEase; import funkin.graphics.FunkinCamera; +import funkin.audio.FunkinSound; import flixel.tweens.FlxTween; import funkin.ui.MusicBeatState; import flixel.util.FlxTimer; @@ -51,7 +53,7 @@ class MainMenuState extends MusicBeatState if (!(FlxG?.sound?.music?.playing ?? false)) { - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); + playMenuMusic(); } persistentUpdate = persistentDraw = true; @@ -101,8 +103,8 @@ class MainMenuState extends MusicBeatState persistentDraw = true; persistentUpdate = false; // Freeplay has its own custom transition - FlxTransitionableSubState.skipNextTransIn = true; - FlxTransitionableSubState.skipNextTransOut = true; + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; openSubState(new FreeplayState()); }); @@ -151,6 +153,11 @@ class MainMenuState extends MusicBeatState // NG.core.calls.event.logEvent('swag').send(); } + function playMenuMusic():Void + { + FunkinSound.playMusic('freakyMenu'); + } + function resetCamStuff() { FlxG.cameras.reset(new FunkinCamera()); diff --git a/source/funkin/ui/options/ColorsMenu.hx b/source/funkin/ui/options/ColorsMenu.hx index 928f74ba8..146384564 100644 --- a/source/funkin/ui/options/ColorsMenu.hx +++ b/source/funkin/ui/options/ColorsMenu.hx @@ -23,7 +23,7 @@ class ColorsMenu extends Page for (i in 0...4) { - var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), 0, i); + var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), i); note.x = (100 * i) + i; note.screenCenter(Y); diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx index bda071846..62ae4b1a9 100644 --- a/source/funkin/ui/options/ControlsMenu.hx +++ b/source/funkin/ui/options/ControlsMenu.hx @@ -27,7 +27,7 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page static var controlGroups:Array> = [ [NOTE_UP, NOTE_DOWN, NOTE_LEFT, NOTE_RIGHT], [UI_UP, UI_DOWN, UI_LEFT, UI_RIGHT, ACCEPT, BACK], - [CUTSCENE_ADVANCE, CUTSCENE_SKIP], + [CUTSCENE_ADVANCE], [VOLUME_UP, VOLUME_DOWN, VOLUME_MUTE], [DEBUG_MENU, DEBUG_CHART] ]; diff --git a/source/funkin/ui/options/FunkinSoundTray.hx b/source/funkin/ui/options/FunkinSoundTray.hx new file mode 100644 index 000000000..4af94569b --- /dev/null +++ b/source/funkin/ui/options/FunkinSoundTray.hx @@ -0,0 +1,147 @@ +package funkin.ui.options; + +import flixel.system.ui.FlxSoundTray; +import flixel.tweens.FlxTween; +import flixel.system.FlxAssets; +import flixel.tweens.FlxEase; +import openfl.display.Bitmap; +import openfl.display.BitmapData; +import openfl.utils.Assets; +import funkin.util.MathUtil; + +/** + * Extends the default flixel soundtray, but with some art + * and lil polish! + * + * Gets added to the game in Main.hx, right after FlxGame is new'd + * since it's a Sprite rather than Flixel related object + */ +class FunkinSoundTray extends FlxSoundTray +{ + var graphicScale:Float = 0.30; + var lerpYPos:Float = 0; + + var volumeMaxSound:String; + + public function new() + { + // calls super, then removes all children to add our own + // graphics + super(); + removeChildren(); + + var bg:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/volumebox"))); + bg.scaleX = graphicScale; + bg.scaleY = graphicScale; + addChild(bg); + + y = -height; + visible = false; + + // makes an alpha'd version of all the bars (bar_10.png) + var backingBar:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/bars_10"))); + backingBar.x = 10; + backingBar.y = 5; + backingBar.scaleX = graphicScale; + backingBar.scaleY = graphicScale; + addChild(backingBar); + backingBar.alpha = 0.4; + + // clear the bars array entirely, it was initialized + // in the super class + _bars = []; + + // 1...11 due to how block named the assets, + // we are trying to get assets bars_1-10 + for (i in 1...11) + { + var bar:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/bars_" + i))); + bar.x = 10; + bar.y = 5; + bar.scaleX = graphicScale; + bar.scaleY = graphicScale; + addChild(bar); + _bars.push(bar); + } + + y = -height; + screenCenter(); + + volumeUpSound = Paths.sound("soundtray/Volup"); + volumeDownSound = Paths.sound("soundtray/Voldown"); + volumeMaxSound = Paths.sound("soundtray/VolMAX"); + + trace("Custom tray added!"); + } + + override public function update(MS:Float):Void + { + y = MathUtil.coolLerp(y, lerpYPos, 0.1); + + // Animate sound tray thing + if (_timer > 0) + { + _timer -= (MS / 1000); + } + else if (y > -height) + { + lerpYPos = -height - 10; + + if (y <= -height) + { + visible = false; + active = false; + + #if FLX_SAVE + // Save sound preferences + if (FlxG.save.isBound) + { + FlxG.save.data.mute = FlxG.sound.muted; + FlxG.save.data.volume = FlxG.sound.volume; + FlxG.save.flush(); + } + #end + } + } + } + + /** + * Makes the little volume tray slide out. + * + * @param up Whether the volume is increasing. + */ + override public function show(up:Bool = false):Void + { + _timer = 1; + lerpYPos = 10; + visible = true; + active = true; + var globalVolume:Int = Math.round(FlxG.sound.volume * 10); + + if (FlxG.sound.muted) + { + globalVolume = 0; + } + + if (!silent) + { + var sound = up ? volumeUpSound : volumeDownSound; + + if (globalVolume == 10) sound = volumeMaxSound; + + if (sound != null) FlxG.sound.load(sound).play(); + } + + for (i in 0..._bars.length) + { + if (i < globalVolume) + { + _bars[i].visible = true; + } + else + { + _bars[i].visible = false; + } + } + } +} diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx index c93ad41a6..626fb8e52 100644 --- a/source/funkin/ui/story/Level.hx +++ b/source/funkin/ui/story/Level.hx @@ -51,6 +51,7 @@ class Level implements IRegistryEntry /** * Retrieve the title of the level for display on the menu. + * @return Title of the level as a string */ public function getTitle():String { @@ -58,16 +59,21 @@ class Level implements IRegistryEntry return _data.name; } + /** + * Construct the title graphic for the level. + * @return The constructed graphic as a sprite. + */ public function buildTitleGraphic():FlxSprite { - var result = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset)); + var result:FlxSprite = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset)); return result; } /** * Get the list of songs in this level, as an array of names, for display on the menu. - * @return Array + * @param difficulty The difficulty of the level being displayed + * @return The display names of the songs in this level */ public function getSongDisplayNames(difficulty:String):Array { @@ -88,7 +94,9 @@ class Level implements IRegistryEntry /** * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon. - * TODO: Change this behavior in a later release. + * Override this in a script. + * @default `true` + * @return Whether this level is unlocked */ public function isUnlocked():Bool { @@ -97,6 +105,9 @@ class Level implements IRegistryEntry /** * Whether this level is visible. If not, it will not be shown on the menu at all. + * Override this in a script. + * @default `true` + * @return Whether this level is visible in the menu */ public function isVisible():Bool { @@ -106,6 +117,7 @@ class Level implements IRegistryEntry /** * Build a sprite for the background of the level. * Can be overriden by ScriptedLevel. Not used if `isBackgroundSimple` returns true. + * @return The constructed sprite */ public function buildBackground():FlxSprite { @@ -124,6 +136,7 @@ class Level implements IRegistryEntry /** * Returns true if the background is a solid color. * If you have a ScriptedLevel with a fancy background, you may want to override this to false. + * @return Whether the background is a simple color */ public function isBackgroundSimple():Bool { @@ -133,30 +146,36 @@ class Level implements IRegistryEntry /** * Returns true if the background is a solid color. * If you have a ScriptedLevel with a fancy background, you may want to override this to false. + * @return The background as a simple color. May not be valid if `isBackgroundSimple` returns false. */ public function getBackgroundColor():FlxColor { return FlxColor.fromString(_data.background); } + /** + * The list of difficulties the player can select from for this level. + * @return The difficulty IDs. + */ public function getDifficulties():Array { var difficulties:Array = []; - var songList = getSongs(); + var songList:Array = getSongs(); var firstSongId:String = songList[0]; var firstSong:Song = SongRegistry.instance.fetchEntry(firstSongId); if (firstSong != null) { - // Don't display alternate characters in Story Mode. - for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, "erect"])) + // Don't display alternate characters in Story Mode. Only show `default` and `erect` variations. + for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, 'erect'])) { difficulties.push(difficulty); } } + // Sort in a specific order! Fall back to alphabetical. difficulties.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST)); // Filter to only include difficulties that are present in all songs @@ -169,7 +188,7 @@ class Level implements IRegistryEntry for (difficulty in difficulties) { - if (!song.hasDifficulty(difficulty, [Constants.DEFAULT_VARIATION, "erect"])) + if (!song.hasDifficulty(difficulty, [Constants.DEFAULT_VARIATION, 'erect'])) { difficulties.remove(difficulty); } @@ -181,6 +200,11 @@ class Level implements IRegistryEntry return difficulties; } + /** + * Build the props for display over the colored background. + * @param existingProps The existing prop sprites, if any. + * @return The constructed prop sprites + */ public function buildProps(?existingProps:Array):Array { var props:Array = existingProps == null ? [] : [for (x in existingProps) x]; @@ -189,11 +213,13 @@ class Level implements IRegistryEntry var hiddenProps:Array = props.splice(_data.props.length - 1, props.length - 1); for (hiddenProp in hiddenProps) + { hiddenProp.visible = false; + } for (propIndex in 0..._data.props.length) { - var propData = _data.props[propIndex]; + var propData:LevelPropData = _data.props[propIndex]; // Attempt to reuse the `LevelProp` object. // This prevents animations from resetting. @@ -201,8 +227,15 @@ class Level implements IRegistryEntry if (existingProp != null) { existingProp.propData = propData; - existingProp.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; - existingProp.visible = true; + if (existingProp.propData == null) + { + existingProp.visible = false; + } + else + { + existingProp.visible = true; + existingProp.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; + } } else { @@ -217,6 +250,10 @@ class Level implements IRegistryEntry return props; } + /** + * Called when the level is destroyed. + * TODO: Document when this gets called + */ public function destroy():Void {} public function toString():String @@ -224,6 +261,11 @@ class Level implements IRegistryEntry return 'Level($id)'; } + /** + * Retrieve and parse the JSON data for a level by ID. + * @param id The ID of the level + * @return The parsed level data, or null if not found or invalid + */ static function _fetchData(id:String):Null { return LevelRegistry.instance.parseEntryDataWithMigration(id, LevelRegistry.instance.fetchEntryVersion(id)); diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx index 5af383de9..d8eae9c77 100644 --- a/source/funkin/ui/story/LevelProp.hx +++ b/source/funkin/ui/story/LevelProp.hx @@ -11,11 +11,11 @@ class LevelProp extends Bopper function set_propData(value:LevelPropData):LevelPropData { // Only reset the prop if the asset path has changed. - if (propData == null || value.assetPath != this.propData.assetPath) + if (propData == null || value?.assetPath != propData?.assetPath) { this.visible = (value != null); this.propData = value; - danceEvery = this.propData.danceEvery; + danceEvery = this.propData?.danceEvery ?? 0; applyData(); } @@ -35,6 +35,19 @@ class LevelProp extends Bopper function applyData():Void { + if (propData == null) + { + this.visible = false; + return; + } + else + { + this.visible = true; + } + + // Reset animation state. + this.shouldAlternate = null; + var isAnimated:Bool = propData.animations.length > 0; if (isAnimated) { diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index bd7a05f91..9ce110c73 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -1,36 +1,33 @@ package funkin.ui.story; -import funkin.ui.mainmenu.MainMenuState; -import funkin.save.Save; -import funkin.save.Save.SaveScoreData; -import openfl.utils.Assets; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.text.FlxText; -import flixel.addons.transition.FlxTransitionableState; import flixel.tweens.FlxEase; -import funkin.graphics.FunkinSprite; -import funkin.ui.MusicBeatState; import flixel.tweens.FlxTween; import flixel.util.FlxColor; import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; import funkin.data.level.LevelRegistry; +import funkin.data.song.SongRegistry; +import funkin.graphics.FunkinSprite; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.PlayState; import funkin.play.PlayStatePlaylist; -import funkin.ui.mainmenu.MainMenuState; import funkin.play.song.Song; -import funkin.data.song.SongData.SongMusicData; -import funkin.data.song.SongRegistry; -import funkin.util.MathUtil; +import funkin.save.Save; +import funkin.save.Save.SaveScoreData; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatState; import funkin.ui.transition.LoadingState; import funkin.ui.transition.StickerSubState; +import funkin.util.MathUtil; +import openfl.utils.Assets; class StoryMenuState extends MusicBeatState { - static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString("#F9CF51"); + static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString('#F9CF51'); static final BACKGROUND_HEIGHT:Int = 400; var currentDifficultyId:String = 'normal'; @@ -141,10 +138,10 @@ class StoryMenuState extends MusicBeatState persistentUpdate = persistentDraw = true; - updateData(); - rememberSelection(); + updateData(); + // Explicitly define the background color. this.bgColor = FlxColor.BLACK; @@ -165,25 +162,25 @@ class StoryMenuState extends MusicBeatState updateProps(); tracklistText = new FlxText(FlxG.width * 0.05, levelBackground.x + levelBackground.height + 100, 0, "Tracks", 32); - tracklistText.setFormat("VCR OSD Mono", 32); + tracklistText.setFormat('VCR OSD Mono', 32); tracklistText.alignment = CENTER; - tracklistText.color = 0xFFe55777; + tracklistText.color = 0xFFE55777; add(tracklistText); scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420'); - scoreText.setFormat("VCR OSD Mono", 32); + scoreText.setFormat('VCR OSD Mono', 32); scoreText.zIndex = 1000; add(scoreText); modeText = new FlxText(10, 10, 0, 'Base Game Levels [TAB to switch]'); - modeText.setFormat("VCR OSD Mono", 32); + modeText.setFormat('VCR OSD Mono', 32); modeText.screenCenter(X); modeText.visible = hasModdedLevels(); modeText.zIndex = 1000; add(modeText); levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1'); - levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT); + levelTitleText.setFormat('VCR OSD Mono', 32, FlxColor.WHITE, RIGHT); levelTitleText.alpha = 0.7; levelTitleText.zIndex = 1000; add(levelTitleText); @@ -216,7 +213,7 @@ class StoryMenuState extends MusicBeatState #if discord_rpc // Updating Discord Rich Presence - DiscordClient.changePresence("In the Menus", null); + DiscordClient.changePresence('In the Menus', null); #end } @@ -234,17 +231,7 @@ class StoryMenuState extends MusicBeatState function playMenuMusic():Void { - if (FlxG.sound.music == null || !FlxG.sound.music.playing) - { - var freakyMenuMetadata:Null = SongRegistry.instance.parseMusicData('freakyMenu'); - if (freakyMenuMetadata != null) - { - Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges); - } - - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); - FlxG.sound.music.fadeIn(4, 0, 0.7); - } + FunkinSound.playMusic('freakyMenu'); } function updateData():Void @@ -316,11 +303,11 @@ class StoryMenuState extends MusicBeatState changeDifficulty(0); } - override function update(elapsed:Float) + override function update(elapsed:Float):Void { Conductor.instance.update(); - highScoreLerp = Std.int(MathUtil.coolLerp(highScoreLerp, highScore, 0.5)); + highScoreLerp = Std.int(MathUtil.smoothLerp(highScoreLerp, highScore, elapsed, 0.5)); scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}'; @@ -403,7 +390,8 @@ class StoryMenuState extends MusicBeatState function hasModdedLevels():Bool { - return LevelRegistry.instance.listModdedLevelIds().length > 0; + return false; + // return LevelRegistry.instance.listModdedLevelIds().length > 0; } /** @@ -433,7 +421,7 @@ class StoryMenuState extends MusicBeatState { var item:LevelTitle = levelTitles.members[index]; - item.targetY = (index - currentIndex) * 120 + 480; + item.targetY = (index - currentIndex) * 125 + 480; if (index == currentIndex) { @@ -519,7 +507,7 @@ class StoryMenuState extends MusicBeatState } } - function selectLevel() + function selectLevel():Void { if (!currentLevel.isUnlocked()) { @@ -554,14 +542,19 @@ class StoryMenuState extends MusicBeatState PlayStatePlaylist.campaignTitle = currentLevel.getTitle(); PlayStatePlaylist.campaignDifficulty = currentDifficultyId; + Highscore.talliesLevel = new funkin.Highscore.Tallies(); + new FlxTimer().start(1, function(tmr:FlxTimer) { FlxTransitionableState.skipNextTransIn = false; FlxTransitionableState.skipNextTransOut = false; + var targetVariation:String = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty); + LoadingState.loadPlayState( { targetSong: targetSong, targetDifficulty: PlayStatePlaylist.campaignDifficulty, + targetVariation: targetVariation }, true); }); } @@ -649,7 +642,7 @@ class StoryMenuState extends MusicBeatState tracklistText.screenCenter(X); tracklistText.x -= FlxG.width * 0.35; - var levelScore:Null = Save.get().getLevelScore(currentLevelId, currentDifficultyId); + var levelScore:Null = Save.instance.getLevelScore(currentLevelId, currentDifficultyId); highScore = levelScore?.score ?? 0; // levelScore.accuracy } diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx index 5424e2255..5cc7b8cc1 100644 --- a/source/funkin/ui/title/TitleState.hx +++ b/source/funkin/ui/title/TitleState.hx @@ -18,6 +18,7 @@ import funkin.graphics.FunkinSprite; import funkin.ui.MusicBeatState; import funkin.data.song.SongData.SongMusicData; import funkin.graphics.shaders.TitleOutline; +import funkin.audio.FunkinSound; import funkin.ui.freeplay.FreeplayState; import funkin.ui.AtlasText; import openfl.Assets; @@ -219,16 +220,12 @@ class TitleState extends MusicBeatState function playMenuMusic():Void { - if (FlxG.sound.music == null || !FlxG.sound.music.playing) - { - var freakyMenuMetadata:Null = SongRegistry.instance.parseMusicData('freakyMenu'); - if (freakyMenuMetadata != null) - { - Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges); - } - FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); - FlxG.sound.music.fadeIn(4, 0, 0.7); - } + var shouldFadeIn = (FlxG.sound.music == null); + // Load music. Includes logic to handle BPM changes. + FunkinSound.playMusic('freakyMenu', false, true); + FlxG.sound.music.volume = 0; + // Fade from 0.0 to 0.7 over 4 seconds + if (shouldFadeIn) FlxG.sound.music.fadeIn(4, 0, 0.7); } function getIntroTextShit():Array> @@ -456,9 +453,9 @@ class TitleState extends MusicBeatState switch (i + 1) { case 1: - createCoolText(['ninjamuffin99', 'phantomArcade', 'kawaisprite', 'evilsk8r']); + createCoolText(['The', 'Funkin Crew Inc']); case 3: - addMoreText('present'); + addMoreText('presents'); case 4: deleteCoolText(); case 5: diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index 86f443d1d..23b3db6a9 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -3,6 +3,7 @@ package funkin.ui.transition; import flixel.FlxSprite; import flixel.math.FlxMath; import flixel.tweens.FlxEase; +import funkin.graphics.FunkinSprite; import flixel.tweens.FlxTween; import flixel.util.FlxTimer; import funkin.graphics.shaders.ScreenWipeShader; @@ -44,11 +45,10 @@ class LoadingState extends MusicBeatState override function create():Void { - var bg:FlxSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d); + var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d); add(bg); - funkay = new FlxSprite(); - funkay.loadGraphic(Paths.image('funkay')); + funkay = FunkinSprite.create('funkay'); funkay.setGraphicSize(0, FlxG.height); funkay.updateHitbox(); add(funkay); @@ -209,6 +209,70 @@ class LoadingState extends MusicBeatState params.targetSong.cacheCharts(true); } + // TODO: This section is a hack! Redo this later when we have a proper asset caching system. + FunkinSprite.preparePurgeCache(); + FunkinSprite.cacheTexture(Paths.image('combo')); + 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')); + 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 + + // 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. + // TODO: Get rid of this junk! + var library = openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId); + var assets = library.list(lime.utils.AssetType.IMAGE); + trace('Got ${assets.length} assets: ${assets}'); + + // TODO: assets includes non-images! This is a bug with Polymod + for (asset in assets) + { + // Exclude items of the wrong type. + var path = '${PlayStatePlaylist.campaignId}:${asset}'; + // TODO DUMB HACK DUMB HACK why doesn't filtering by AssetType.IMAGE above work + // I will fix this properly later I swear -eric + if (!path.endsWith('.png')) continue; + + FunkinSprite.cacheTexture(path); + + // Another dumb hack: FlxAnimate fetches from OpenFL's BitmapData cache directly and skips the FlxGraphic cache. + // Since FlxGraphic tells OpenFL to not cache it, we have to do it manually. + if (path.endsWith('spritemap1.png')) + { + openfl.Assets.getBitmapData(path, true); + } + } + + // FunkinSprite.cacheAllNoteStyleTextures(noteStyle) // This will replace the stuff above! + // FunkinSprite.cacheAllCharacterTextures(player) + // FunkinSprite.cacheAllCharacterTextures(girlfriend) + // FunkinSprite.cacheAllCharacterTextures(opponent) + // FunkinSprite.cacheAllStageTextures(stage) + // FunkinSprite.cacheAllSongTextures(stage) + + FunkinSprite.purgeCache(); + FlxG.switchState(playStateCtor); #end } @@ -352,9 +416,15 @@ class MultiCallback public function getUnfired():ArrayVoid> return unfired.array(); + /** + * Perform an FlxG.switchState with a nice transition + * @param state + * @param transitionTex + * @param time + */ public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2) { - var screenShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("shaderTransitionStuff/coolDots")); + var screenShit:FunkinSprite = FunkinSprite.create('shaderTransitionStuff/coolDots'); var screenWipeShit:ScreenWipeShader = new ScreenWipeShader(); screenWipeShit.funnyShit.input = screenShit.pixels; diff --git a/source/funkin/ui/transition/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx index e94eed7d5..981a30e09 100644 --- a/source/funkin/ui/transition/StickerSubState.hx +++ b/source/funkin/ui/transition/StickerSubState.hx @@ -3,6 +3,7 @@ package funkin.ui.transition; import flixel.FlxSprite; import haxe.Json; import lime.utils.Assets; +import funkin.graphics.FunkinSprite; // import flxtyped group import funkin.ui.MusicBeatSubState; import funkin.ui.story.StoryMenuState; @@ -245,6 +246,10 @@ class StickerSubState extends MusicBeatSubState FlxTransitionableState.skipNextTransIn = true; FlxTransitionableState.skipNextTransOut = true; + // TODO: Rework this asset caching stuff + FunkinSprite.preparePurgeCache(); + FunkinSprite.purgeCache(); + // I think this grabs the screen and puts it under the stickers? // Leaving this commented out rather than stripping it out because it's cool... /* @@ -301,14 +306,14 @@ class StickerSubState extends MusicBeatSubState } } -class StickerSprite extends FlxSprite +class StickerSprite extends FunkinSprite { public var timing:Float = 0; public function new(x:Float, y:Float, stickerSet:String, stickerName:String):Void { super(x, y); - loadGraphic(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName)); + loadTexture('transitionSwag/' + stickerSet + '/' + stickerName); updateHitbox(); scrollFactor.set(); } diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index d18956a8a..d939f3194 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -175,17 +175,22 @@ class Constants /** * The default name for songs. */ - public static final DEFAULT_SONGNAME:String = "Unknown"; + public static final DEFAULT_SONGNAME:String = 'Unknown'; /** * The default artist for songs. */ - public static final DEFAULT_ARTIST:String = "Unknown"; + public static final DEFAULT_ARTIST:String = 'Unknown'; /** * The default note style for songs. */ - public static final DEFAULT_NOTE_STYLE:String = "funkin"; + public static final DEFAULT_NOTE_STYLE:String = 'funkin'; + + /** + * The default album for songs in Freeplay. + */ + public static final DEFAULT_ALBUM_ID:String = 'volume1'; /** * The default timing format for songs. diff --git a/source/funkin/util/MathUtil.hx b/source/funkin/util/MathUtil.hx index 5fed1d3e1..532e92f15 100644 --- a/source/funkin/util/MathUtil.hx +++ b/source/funkin/util/MathUtil.hx @@ -13,12 +13,24 @@ class MathUtil /** * Perform linear interpolation between the base and the target, based on the current framerate. + * @param base The starting value, when `progress <= 0`. + * @param target The ending value, when `progress >= 1`. + * @param ratio Value used to interpolate between `base` and `target`. + * + * @return The interpolated value. */ public static function coolLerp(base:Float, target:Float, ratio:Float):Float { return base + cameraLerp(ratio) * (target - base); } + /** + * Perform linear interpolation based on the current framerate. + * @param lerp Value used to interpolate between `base` and `target`. + * + * @return The interpolated value. + */ + @:deprecated('Use smoothLerp instead') public static function cameraLerp(lerp:Float):Float { return lerp * (FlxG.elapsed / (1 / 60)); @@ -30,26 +42,30 @@ class MathUtil * @param value The value to get the logarithm of. * @return `log_base(value)` */ - public static function logBase(base:Float, value:Float) + public static function logBase(base:Float, value:Float):Float { return Math.log(value) / Math.log(base); } /** - * @returns `2^x` + * Get the base-2 logarithm of a value. + * @param x value + * @return `2^x` */ - public static function exp2(x:Float) + public static function exp2(x:Float):Float { return Math.pow(2, x); } /** * Linearly interpolate between two values. + * * @param base The starting value, when `progress <= 0`. * @param target The ending value, when `progress >= 1`. * @param progress Value used to interpolate between `base` and `target`. + * @return The interpolated value. */ - public static function lerp(base:Float, target:Float, progress:Float) + public static function lerp(base:Float, target:Float, progress:Float):Float { return base + progress * (target - base); } @@ -62,12 +78,23 @@ class MathUtil * @param duration The total duration of the interpolation. Nominal duration until remaining distance is less than `precision`. * @param precision The target precision of the interpolation. Defaults to 1% of distance remaining. * @see https://twitter.com/FreyaHolmer/status/1757918211679650262 + * + * @return A value between the current value and the target value. */ public static function smoothLerp(current:Float, target:Float, elapsed:Float, duration:Float, precision:Float = 1 / 100):Float { + // An alternative algorithm which uses a separate half-life value: // var halfLife:Float = -duration / logBase(2, precision); // lerp(current, target, 1 - exp2(-elapsed / halfLife)); - return lerp(current, target, 1 - Math.pow(precision, elapsed / duration)); + if (current == target) return target; + + var result:Float = lerp(current, target, 1 - Math.pow(precision, elapsed / duration)); + + // TODO: Is there a better way to ensure a lerp which actually reaches the target? + // Research a framerate-independent PID lerp. + if (Math.abs(result - target) < (precision * target)) result = target; + + return result; } } diff --git a/source/funkin/util/MemoryUtil.hx b/source/funkin/util/MemoryUtil.hx new file mode 100644 index 000000000..f5935ed67 --- /dev/null +++ b/source/funkin/util/MemoryUtil.hx @@ -0,0 +1,113 @@ +package funkin.util; + +/** + * Utilities for working with the garbage collector. + * + * HXCPP is built on Immix. + * HTML5 builds use the browser's built-in mark-and-sweep and JS has no APIs to interact with it. + * @see https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/immix/ + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management + * @see https://betterprogramming.pub/deep-dive-into-garbage-collection-in-javascript-6881610239a + * @see https://github.com/HaxeFoundation/hxcpp/blob/master/docs/build_xml/Defines.md + * @see cpp.vm.Gc + */ +class MemoryUtil +{ + public static function buildGCInfo():String + { + #if cpp + var result:String = 'HXCPP-Immix:'; + result += '\n- Memory Used: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_USAGE)} bytes'; + result += '\n- Memory Reserved: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_RESERVED)} bytes'; + result += '\n- Memory Current Pool: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_CURRENT)} bytes'; + result += '\n- Memory Large Pool: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_LARGE)} bytes'; + result += '\n- HXCPP Debugger: ${#if HXCPP_DEBUGGER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Generational Mode: ${#if HXCPP_GC_GENERATIONAL 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_MOVING 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_DYNAMIC_SIZE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_BIG_BLOCKS 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Debug Link: ${#if HXCPP_DEBUG_LINK 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Stack Trace: ${#if HXCPP_STACK_TRACE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Stack Trace Line Numbers: ${#if HXCPP_STACK_LINE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Pointer Validation: ${#if HXCPP_CHECK_POINTER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Profiler: ${#if HXCPP_PROFILER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Local Telemetry: ${#if HXCPP_TELEMETRY 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP C++11: ${#if HXCPP_CPP11 'Enabled' #else 'Disabled' #end}'; + result += '\n- Source Annotation: ${#if annotate_source 'Enabled' #else 'Disabled' #end}'; + #elseif js + var result:String = 'JS-MNS:'; + result += '\n- Memory Used: ${getMemoryUsed()} bytes'; + #else + var result:String = 'Unknown GC'; + #end + + return result; + } + + /** + * Calculate the total memory usage of the program, in bytes. + * @return Int + */ + public static function getMemoryUsed():Int + { + #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); + #else + return openfl.system.System.totalMemory; + #end + } + + /** + * Enable garbage collection if it was previously disabled. + */ + public static function enable():Void + { + #if cpp + cpp.vm.Gc.enable(true); + #else + throw 'Not implemented!'; + #end + } + + /** + * Disable garbage collection entirely. + */ + public static function disable():Void + { + #if cpp + cpp.vm.Gc.enable(false); + #else + throw 'Not implemented!'; + #end + } + + /** + * Manually perform garbage collection once. + * Should only be called from the main thread. + * @param major `true` to perform major collection, whatever that means. + */ + public static function collect(major:Bool = false):Void + { + #if cpp + cpp.vm.Gc.run(major); + #else + throw 'Not implemented!'; + #end + } + + /** + * Perform major garbage collection repeatedly until less than 16kb of memory is freed in one operation. + * Should only be called from the main thread. + * + * NOTE: This is DIFFERENT from actual compaction, + */ + public static function compact():Void + { + #if cpp + cpp.vm.Gc.compact(); + #else + throw 'Not implemented!'; + #end + } +} diff --git a/source/funkin/util/MouseUtil.hx b/source/funkin/util/MouseUtil.hx index c3f46819e..5248437a8 100644 --- a/source/funkin/util/MouseUtil.hx +++ b/source/funkin/util/MouseUtil.hx @@ -38,6 +38,9 @@ class MouseUtil } } + /** + * Increment the zoom level of the current camera by the mouse wheel scroll value. + */ public static function mouseWheelZoom():Void { if (FlxG.mouse.wheel != 0) FlxG.camera.zoom += FlxG.mouse.wheel * (0.1 * FlxG.camera.zoom); diff --git a/source/funkin/util/PlatformUtil.hx b/source/funkin/util/PlatformUtil.hx index eecf6a7ea..ae8c1dcee 100644 --- a/source/funkin/util/PlatformUtil.hx +++ b/source/funkin/util/PlatformUtil.hx @@ -16,9 +16,9 @@ class PlatformUtil #if mac return true; #elseif html5 - return js.Browser.window.navigator.platform.startsWith("Mac") - || js.Browser.window.navigator.platform.startsWith("iPad") - || js.Browser.window.navigator.platform.startsWith("iPhone"); + return js.Browser.window.navigator.platform.startsWith('Mac') + || js.Browser.window.navigator.platform.startsWith('iPad') + || js.Browser.window.navigator.platform.startsWith('iPhone'); #else return false; #end @@ -27,7 +27,7 @@ class PlatformUtil /** * Detects and returns the current host platform. * Always returns `HTML5` on web, regardless of the computer running that browser. - * Returns `null` if the platform could not be detected. + * @return The host platform, or `null` if the platform could not be detected. */ public static function detectHostPlatform():Null { diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx index d87592c91..c5ac175be 100644 --- a/source/funkin/util/SortUtil.hx +++ b/source/funkin/util/SortUtil.hx @@ -27,29 +27,53 @@ class SortUtil /** * You can use this function in FlxTypedGroup.sort() to sort FlxObjects by their z-index values. * The value defaults to 0, but by assigning it you can easily rearrange objects as desired. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first FlxObject to compare. + * @param b The second FlxObject to compare. + * @return 1 if `a` has a higher z-index, -1 if `b` has a higher z-index. */ - public static inline function byZIndex(Order:Int, Obj1:FlxBasic, Obj2:FlxBasic):Int + public static inline function byZIndex(order:Int, a:FlxBasic, b:FlxBasic):Int { - if (Obj1 == null || Obj2 == null) return 0; - return FlxSort.byValues(Order, Obj1.zIndex, Obj2.zIndex); + if (a == null || b == null) return 0; + return FlxSort.byValues(order, a.zIndex, b.zIndex); } /** * Given two Notes, returns 1 or -1 based on whether `a` or `b` has an earlier strumtime. * * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Note to compare. + * @param b The second Note to compare. + * @return 1 if `a` has an earlier strumtime, -1 if `b` has an earlier strumtime. */ - public static inline function byStrumtime(order:Int, a:NoteSprite, b:NoteSprite) + public static inline function byStrumtime(order:Int, a:NoteSprite, b:NoteSprite):Int { return noteDataByTime(order, a.noteData, b.noteData); } - public static inline function noteDataByTime(order:Int, a:SongNoteData, b:SongNoteData) + /** + * Given two Note Data objects, returns 1 or -1 based on whether `a` or `b` has an earlier time. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Event to compare. + * @param b The second Event to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. + */ + public static inline function noteDataByTime(order:Int, a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(order, a.time, b.time); } - public static inline function eventDataByTime(order:Int, a:SongEventData, b:SongEventData) + /** + * Given two Event Data objects, returns 1 or -1 based on whether `a` or `b` has an earlier time. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Event to compare. + * @param b The second Event to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. + */ + public static inline function eventDataByTime(order:Int, a:SongEventData, b:SongEventData):Int { return FlxSort.byValues(order, a.time, b.time); } @@ -58,8 +82,11 @@ class SortUtil * Given two FlxFrames, sort their names alphabetically. * * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Frame to compare. + * @param b The second Frame to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. */ - public static inline function byFrameName(a:FlxFrame, b:FlxFrame) + public static inline function byFrameName(a:FlxFrame, b:FlxFrame):Int { return alphabetically(a.name, b.name); } @@ -68,6 +95,7 @@ class SortUtil * Sort predicate for sorting strings alphabetically. * @param a The first string to compare. * @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 { @@ -81,9 +109,11 @@ class SortUtil /** * Sort predicate which sorts two strings alphabetically, but prioritizes a specific string first. * Example usage: `array.sort(defaultThenAlphabetical.bind('test'))` will sort the array so that the string 'test' is first. + * + * @param defaultValue The value to prioritize. * @param a The first string to compare. * @param b The second string to compare. - * @param defaultValue The value to prioritize. + * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal */ public static function defaultThenAlphabetically(defaultValue:String, a:String, b:String):Int { @@ -96,9 +126,11 @@ class SortUtil /** * Sort predicate which sorts two strings alphabetically, but prioritizes a specific string first. * Example usage: `array.sort(defaultsThenAlphabetical.bind(['test']))` will sort the array so that the string 'test' is first. + * + * @param defaultValues The values to prioritize. * @param a The first string to compare. * @param b The second string to compare. - * @param defaultValues The values to prioritize. + * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal */ public static function defaultsThenAlphabetically(defaultValues:Array, a:String, b:String):Int { diff --git a/source/funkin/util/TimerUtil.hx b/source/funkin/util/TimerUtil.hx new file mode 100644 index 000000000..eb2690e16 --- /dev/null +++ b/source/funkin/util/TimerUtil.hx @@ -0,0 +1,49 @@ +package funkin.util; + +import funkin.util.tools.FloatTools; +import haxe.Timer; + +class TimerUtil +{ + /** + * Store the current time. + */ + public static function start():Float + { + return Timer.stamp(); + } + + /** + * Return the elapsed time. + */ + static function took(start:Float, ?end:Float):Float + { + var endOrNow:Float = end != null ? end : Timer.stamp(); + return endOrNow - start; + } + + /** + * Return the elapsed time in seconds as a string. + * @param start The start time. + * @param end The end time. + * @param precision The number of decimal places to round to. + * @return The elapsed time in seconds as a string. + */ + public static function seconds(start:Float, ?end:Float, ?precision = 2):String + { + var seconds:Float = FloatTools.round(took(start, end), precision); + return '${seconds} seconds'; + } + + /** + * Return the elapsed time in milliseconds as a string. + * @param start The start time. + * @param end The end time. + * @return The elapsed time in milliseconds as a string. + */ + public static function ms(start:Float, ?end:Float):String + { + var seconds:Float = took(start, end); + return '${seconds * 1000} ms'; + } +} diff --git a/source/funkin/util/TrackerUtil.hx b/source/funkin/util/TrackerUtil.hx new file mode 100644 index 000000000..a2552f63e --- /dev/null +++ b/source/funkin/util/TrackerUtil.hx @@ -0,0 +1,25 @@ +package funkin.util; + +// This may or may not already be imported via imports.hx... +import flixel.system.debug.watch.Tracker; +import flixel.FlxG; + +/** + * Utility class that helps manage adding profiles to the flixel debugger tracker + */ +class TrackerUtil +{ + /** + * Adds profiles to the debugger to help track certain game objects. + * NOTE: This isn't the full list of profiles made, as sometimes they're made + * on in various places in code for random debugging purposes! + * Might be good to put them all here though! + */ + public static function initTrackers():Void + { + #if FLX_DEBUG + Tracker.addProfile(new TrackerProfile(Highscore, ['tallies'])); + FlxG.console.registerClass(Highscore); + #end + } +} diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx index e8d3d3652..247ba19db 100644 --- a/source/funkin/util/VersionUtil.hx +++ b/source/funkin/util/VersionUtil.hx @@ -15,6 +15,9 @@ class VersionUtil /** * Checks that a given verison number satisisfies a given version rule. * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports. + * @param version The semantic version to validate. + * @param versionRule The version rule to validate against. + * @return `true` if the version satisfies the rule, `false` otherwise. */ public static function validateVersion(version:thx.semver.Version, versionRule:thx.semver.VersionRule):Bool { @@ -32,6 +35,9 @@ class VersionUtil /** * Checks that a given verison number satisisfies a given version rule. * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports. + * @param version The semantic version to validate. + * @param versionRule The version rule to validate against. + * @return `true` if the version satisfies the rule, `false` otherwise. */ public static function validateVersionStr(version:String, versionRule:String):Bool { @@ -56,7 +62,7 @@ class VersionUtil public static function getVersionFromJSON(input:Null):Null { if (input == null) return null; - var parsed = SerializerUtil.fromJSON(input); + var parsed:Dynamic = SerializerUtil.fromJSON(input); if (parsed == null) return null; if (parsed.version == null) return null; var versionStr:String = parsed.version; // Dynamic -> String cast @@ -64,6 +70,11 @@ class VersionUtil return version; } + /** + * Get and parse the semantic version from a JSON string. + * @param input The JSON string to parse. + * @return The semantic version, or null if it could not be parsed. + */ public static function parseVersion(input:Dynamic):Null { if (input == null) return null; diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx index 9f623c39d..f73fe11ce 100644 --- a/source/funkin/util/WindowUtil.hx +++ b/source/funkin/util/WindowUtil.hx @@ -24,7 +24,7 @@ class WindowUtil { #if CAN_OPEN_LINKS #if linux - Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]); + Sys.command('/usr/bin/xdg-open', [targetUrl, '&']); #else // This should work on Windows and HTML5. FlxG.openURL(targetUrl); @@ -42,7 +42,7 @@ class WindowUtil { #if CAN_OPEN_LINKS #if windows - Sys.command('explorer', [targetPath.replace("/", "\\")]); + Sys.command('explorer', [targetPath.replace('/', '\\')]); #elseif mac Sys.command('open', [targetPath]); #elseif linux @@ -61,9 +61,9 @@ class WindowUtil { #if CAN_OPEN_LINKS #if windows - Sys.command('explorer', ["/select," + targetPath.replace("/", "\\")]); + Sys.command('explorer', ['/select,' + targetPath.replace('/', '\\')]); #elseif mac - Sys.command('open', ["-R", targetPath]); + Sys.command('open', ['-R', targetPath]); #elseif linux // TODO: unsure of the linux equivalent to opening a folder and then "selecting" a file. Sys.command('open', [targetPath]); @@ -82,7 +82,7 @@ class WindowUtil * Wires up FlxSignals that happen based on window activity. * For example, we can run a callback when the window is closed. */ - public static function initWindowEvents() + public static function initWindowEvents():Void { // onUpdate is called every frame just before rendering. @@ -95,7 +95,7 @@ class WindowUtil /** * Turns off that annoying "Report to Microsoft" dialog that pops up when the game crashes. */ - public static function disableCrashHandler() + public static function disableCrashHandler():Void { #if (cpp && windows) untyped __cpp__('SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);'); diff --git a/source/funkin/util/logging/AnsiTrace.hx b/source/funkin/util/logging/AnsiTrace.hx index c8d27b86f..9fdc19e1b 100644 --- a/source/funkin/util/logging/AnsiTrace.hx +++ b/source/funkin/util/logging/AnsiTrace.hx @@ -52,7 +52,12 @@ class AnsiTrace public static function traceBF() { #if sys - if (colorSupported) Sys.println(ansiBF.join("\n")); + if (colorSupported) + { + for (line in ansiBF) + Sys.stdout().writeString(line + "\n"); + Sys.stdout().flush(); + } #end } diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx index ad5983e52..ef293486d 100644 --- a/source/funkin/util/logging/CrashHandler.hx +++ b/source/funkin/util/logging/CrashHandler.hx @@ -125,6 +125,24 @@ class CrashHandler fullContents += '=====================\n'; + fullContents += '\n'; + + fullContents += MemoryUtil.buildGCInfo(); + + fullContents += '\n\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += 'Flixel Current State: ${Type.getClassName(Type.getClass(FlxG.state))}\n'; + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + fullContents += 'Haxelibs: \n'; for (lib in Constants.LIBRARY_VERSIONS) diff --git a/source/funkin/util/logging/Perf.hx b/source/funkin/util/logging/Perf.hx new file mode 100644 index 000000000..83da7a32f --- /dev/null +++ b/source/funkin/util/logging/Perf.hx @@ -0,0 +1,76 @@ +package funkin.util.logging; + +/** + * A small utility class for timing how long functions take. + * Specify a string as a label (or don't, by default it uses the name of the function it was called from.) + * + * Example: + * ```haxe + * + * var perf = new Perf(); + * ... + * perf.print(); + * ``` + */ +class Perf +{ + final startTime:Float; + final label:Null; + final posInfos:Null; + + /** + * Create a new performance marker. + * @param label Optionally specify a label to use for the performance marker. Defaults to the function name. + * @param posInfos The position of the calling function. Used to build the default label. + * Note: `haxe.PosInfos` is magic and automatically populated by the compiler! + */ + public function new(?label:String, ?posInfos:haxe.PosInfos) + { + this.label = label; + this.posInfos = posInfos; + startTime = current(); + } + + /** + * The current timestamp, in fractional seconds. + * @return The current timestamp. + */ + static function current():Float + { + #if sys + // This one is more accurate if it's available. + return Sys.time(); + #else + return haxe.Timer.stamp(); + #end + } + + /** + * The duration in seconds since this `Perf` was created. + * @return The duration in seconds + */ + public function duration():Float + { + return current() - startTime; + } + + /** + * A rounded millisecond duration + * @return The duration in milliseconds + */ + public function durationClean():Float + { + var round:Float = 100; + return Math.floor(duration() * Constants.MS_PER_SEC * round) / round; + } + + /** + * Cleanly prints the duration since this `Perf` was created. + */ + public function print():Void + { + var label:String = label ?? (posInfos == null ? 'unknown' : '${posInfos.className}#${posInfos.methodName}()'); + + trace('[PERF] [$label] Took ${durationClean()}ms.'); + } +} diff --git a/source/funkin/util/plugins/ForceCrashPlugin.hx b/source/funkin/util/plugins/ForceCrashPlugin.hx new file mode 100644 index 000000000..e8094eb3c --- /dev/null +++ b/source/funkin/util/plugins/ForceCrashPlugin.hx @@ -0,0 +1,37 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which forcibly crashes the application. + * TODO: Should we disable this in release builds? + */ +class ForceCrashPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new ForceCrashPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Ctrl + Shift + L = Crash the game for debugging purposes + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.pressed.L) + { + // TODO: Make this message 87% funnier. + throw "DEBUG: Crashing the game via debug keybind!"; + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/MemoryGCPlugin.hx b/source/funkin/util/plugins/MemoryGCPlugin.hx new file mode 100644 index 000000000..4b89fa637 --- /dev/null +++ b/source/funkin/util/plugins/MemoryGCPlugin.hx @@ -0,0 +1,37 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; +import funkin.util.TimerUtil; + +/** + * A plugin which adds functionality to press `Ins` to immediately perform memory garbage collection. + */ +class MemoryGCPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new MemoryGCPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.INSERT) + { + var perfStart:Float = TimerUtil.start(); + funkin.util.MemoryUtil.collect(true); + trace('Memory GC took: ${TimerUtil.seconds(perfStart)}'); + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx index a43317cce..f69609531 100644 --- a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx +++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx @@ -22,7 +22,11 @@ class ReloadAssetsDebugPlugin extends FlxBasic { super.update(elapsed); + #if html5 + if (FlxG.keys.justPressed.FIVE && FlxG.keys.pressed.SHIFT) + #else if (FlxG.keys.justPressed.F5) + #end { funkin.modding.PolymodHandler.forceReloadAssets(); diff --git a/source/funkin/util/plugins/ScreenshotPlugin.hx b/source/funkin/util/plugins/ScreenshotPlugin.hx index 16d0c7244..d7e8109b8 100644 --- a/source/funkin/util/plugins/ScreenshotPlugin.hx +++ b/source/funkin/util/plugins/ScreenshotPlugin.hx @@ -13,6 +13,7 @@ import flixel.util.FlxSignal; import flixel.util.FlxTimer; import funkin.graphics.FunkinSprite; import funkin.input.Cursor; +import funkin.audio.FunkinSound; import openfl.display.Bitmap; import openfl.display.Sprite; import openfl.display.BitmapData; @@ -162,7 +163,7 @@ class ScreenshotPlugin extends FlxBasic final CAMERA_FLASH_DURATION = 0.25; /** - * Visual (and audio?) feedback when a screenshot is taken. + * Visual and audio feedback when a screenshot is taken. */ function showCaptureFeedback():Void { @@ -171,6 +172,9 @@ class ScreenshotPlugin extends FlxBasic flashSpr.addChild(flashBitmap); FlxG.stage.addChild(flashSpr); FlxTween.tween(flashSpr, {alpha: 0}, 0.15, {ease: FlxEase.quadOut, onComplete: _ -> FlxG.stage.removeChild(flashSpr)}); + + // Play a sound (auto-play is true). + FunkinSound.load(Paths.sound('screenshot'), 1.0, false, true, true); } static final PREVIEW_INITIAL_DELAY = 0.25; // How long before the preview starts fading in. diff --git a/source/funkin/util/plugins/WatchPlugin.hx b/source/funkin/util/plugins/WatchPlugin.hx index defd9797d..a03115820 100644 --- a/source/funkin/util/plugins/WatchPlugin.hx +++ b/source/funkin/util/plugins/WatchPlugin.hx @@ -35,7 +35,10 @@ class WatchPlugin extends FlxBasic FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition); FlxG.watch.addQuick("songPositionNoOffset", Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); + + FlxG.watch.addQuick("musicLength", FlxG.sound?.music?.length ?? 0.0); FlxG.watch.addQuick("musicTime", FlxG.sound?.music?.time ?? 0.0); + FlxG.watch.addQuick("bpm", Conductor.instance.bpm); FlxG.watch.addQuick("currentMeasureTime", Conductor.instance.currentMeasureTime); FlxG.watch.addQuick("currentBeatTime", Conductor.instance.currentBeatTime); diff --git a/source/funkin/util/tools/DynamicTools.hx b/source/funkin/util/tools/DynamicTools.hx deleted file mode 100644 index 47501ea22..000000000 --- a/source/funkin/util/tools/DynamicTools.hx +++ /dev/null @@ -1,14 +0,0 @@ -package funkin.util.tools; - -class DynamicTools -{ - /** - * Creates a full clone of the input `Dynamic`. Only guaranteed to work on anonymous structures. - * @param input The `Dynamic` to clone. - * @return A clone of the input `Dynamic`. - */ - public static function clone(input:Dynamic):Dynamic - { - return Reflect.copy(input); - } -} diff --git a/source/funkin/util/tools/FloatTools.hx b/source/funkin/util/tools/FloatTools.hx index e07ae5cb9..e34110490 100644 --- a/source/funkin/util/tools/FloatTools.hx +++ b/source/funkin/util/tools/FloatTools.hx @@ -12,4 +12,13 @@ class FloatTools { return Math.max(min, Math.min(max, value)); } + + /** + Round a float to a certain number of decimal places. + **/ + public static function round(number:Float, ?precision = 2):Float + { + number *= Math.pow(10, precision); + return Math.round(number) / Math.pow(10, precision); + } } diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx index 0585ffeae..cb660db05 100644 --- a/source/funkin/util/tools/StringTools.hx +++ b/source/funkin/util/tools/StringTools.hx @@ -13,20 +13,50 @@ class StringTools */ public static function toTitleCase(value:String):String { - var words:Array = value.split(" "); - var result:String = ""; + var words:Array = value.split(' '); + var result:String = ''; for (i in 0...words.length) { var word:String = words[i]; result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase(); if (i < words.length - 1) { - result += " "; + result += ' '; } } return result; } + /** + * Strip a given prefix from a string. + * @param value The string to strip. + * @param prefix The prefix to strip. If the prefix isn't found, the original string is returned. + * @return The stripped string. + */ + public static function stripPrefix(value:String, prefix:String):String + { + if (value.startsWith(prefix)) + { + return value.substr(prefix.length); + } + return value; + } + + /** + * Strip a given suffix from a string. + * @param value The string to strip. + * @param suffix The suffix to strip. If the suffix isn't found, the original string is returned. + * @return The stripped string. + */ + public static function stripSuffix(value:String, suffix:String):String + { + if (value.endsWith(suffix)) + { + return value.substr(0, value.length - suffix.length); + } + return value; + } + /** * Converts a string to lower kebab case. For example, "Hello World" becomes "hello-world". * @@ -35,7 +65,7 @@ class StringTools */ public static function toLowerKebabCase(value:String):String { - return value.toLowerCase().replace(' ', "-"); + return value.toLowerCase().replace(' ', '-'); } /** @@ -46,13 +76,30 @@ class StringTools */ public static function toUpperKebabCase(value:String):String { - return value.toUpperCase().replace(' ', "-"); + return value.toUpperCase().replace(' ', '-'); + } + + /** + * The regular expression to sanitize strings. + */ + static final SANTIZE_REGEX:EReg = ~/[^-a-zA-Z0-9]/g; + + /** + * Remove all instances of symbols other than alpha-numeric characters (and dashes)from a string. + * @param value The string to sanitize. + * @return The sanitized string. + */ + public static function sanitize(value:String):String + { + return SANTIZE_REGEX.replace(value, ''); } /** * Parses the string data as JSON and returns the resulting object. * This is here so you can use `string.parseJSON()` when `using StringTools`. * + * TODO: Remove this and replace with `json2object` + * @param value The * @return The parsed object. */ public static function parseJSON(value:String):Dynamic diff --git a/source/haxe/ui/backend/flixel/UIStateBase.hx b/source/haxe/ui/backend/flixel/UIStateBase.hx index 9f1a53722..38377206c 100644 --- a/source/haxe/ui/backend/flixel/UIStateBase.hx +++ b/source/haxe/ui/backend/flixel/UIStateBase.hx @@ -1,3 +1,6 @@ package haxe.ui.backend.flixel; +/** + * Override HaxeUI to use `MusicBeatState` instead of `FlxState`. + */ typedef UIStateBase = funkin.ui.MusicBeatState; diff --git a/source/haxe/ui/backend/flixel/UISubStateBase.hx b/source/haxe/ui/backend/flixel/UISubStateBase.hx index 306c9b633..52c9a7231 100644 --- a/source/haxe/ui/backend/flixel/UISubStateBase.hx +++ b/source/haxe/ui/backend/flixel/UISubStateBase.hx @@ -1,3 +1,6 @@ package haxe.ui.backend.flixel; +/** + * Override HaxeUI to use `MusicBeatSubState` instead of `FlxSubState`. + */ typedef UISubStateBase = funkin.ui.MusicBeatSubState; diff --git a/tests/unit/assets/shared/images/arrows.xml b/tests/unit/assets/shared/images/arrows.xml index 8f3355462..96a73a388 100644 --- a/tests/unit/assets/shared/images/arrows.xml +++ b/tests/unit/assets/shared/images/arrows.xml @@ -1,32 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + +