diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml index cb5e68f61..9c1fae0b1 100644 --- a/.github/actions/setup-haxeshit/action.yml +++ b/.github/actions/setup-haxeshit/action.yml @@ -3,9 +3,9 @@ description: "sets up haxe shit, using HMM!" runs: using: "composite" steps: - - uses: krdlab/setup-haxe@v1.5.1 + - uses: funkincrew/ci-haxe@v3 with: - haxe-version: 4.3.1 + haxe-version: 4.3.3 - name: Config haxelib run: | haxelib config @@ -19,7 +19,7 @@ runs: shell: bash - name: dependency install cache id: cache-hmm - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .haxelib key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }} diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml index 7a4b45427..2f7d3027d 100644 --- a/.github/actions/upload-itch/action.yml +++ b/.github/actions/upload-itch/action.yml @@ -13,32 +13,32 @@ inputs: runs: using: "composite" steps: - - name: Install butler Windows - if: runner.os == 'Windows' - run: | - curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default - 7z x butler.zip - ./butler -v - shell: bash - - name: Install butler Mac - if: runner.os == 'macOS' - run: | - curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default - unzip butler.zip - ./butler -V - shell: bash - - name: Install butler Linux - if: runner.os == 'Linux' - run: | - curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default - unzip butler.zip - chmod +x butler - ./butler -V - shell: bash - - name: Upload game to itch.io - env: - BUTLER_API_KEY: ${{inputs.butler-key}} - run: | - ./butler login - ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME} - shell: bash + - name: Install butler Windows + if: runner.os == 'Windows' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default + 7z x butler.zip + ./butler -v + shell: bash + - name: Install butler Mac + if: runner.os == 'macOS' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default + unzip butler.zip + ./butler -V + shell: bash + - name: Install butler Linux + if: runner.os == 'Linux' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default + unzip butler.zip + chmod +x butler + ./butler -V + shell: bash + - name: Upload game to itch.io + env: + BUTLER_API_KEY: ${{inputs.butler-key}} + run: | + ./butler login + ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME} + shell: bash diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index 8a58596b9..76126d106 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -4,59 +4,33 @@ on: push: jobs: - check_date: - runs-on: [self-hosted, linux] - container: ubuntu:latest - name: Check latest commit - outputs: - should_run: ${{ steps.should_run.outputs.should_run }} - steps: - - name: ensure git cli is installed - run: apt update && apt install sudo git -y - - uses: actions/checkout@v4 - with: - submodules: 'recursive' - fetch-depth: 0 - token: ${{ secrets.GH_RO_PAT }} - - name: check whether submodules exist - run: | - git config --global --add safe.directory $GITHUB_WORKSPACE - - # debug output - echo gh=${{ github.sha }} - echo head=$(git rev-parse HEAD) - echo art=$(git -C art rev-parse HEAD) - echo assets=$(git -C assets rev-parse HEAD) - - # checks if HEAD commit hash in submodules is diff from current repo, and therefore exists - test $(git rev-parse HEAD) != $(git -C art rev-parse HEAD) - test $(git rev-parse HEAD) != $(git -C assets rev-parse HEAD) - - id: should_run - continue-on-error: true - name: check latest commit is less than a day - if: ${{ github.event_name == 'schedule' }} - run: test -z $(git rev-list --after="24 hours" ${{ github.sha }}) && echo "::set-output name=should_run::false" create-nightly-html5: - needs: check_date - if: ${{ needs.check_date.outputs.should_run != 'false'}} runs-on: [self-hosted, linux] - container: ubuntu:latest + container: ubuntu:23.10 steps: - name: prepare container run: | apt update apt install sudo git curl unzip -y - echo $GITHUB_WORKSPACE git config --global --add safe.directory $GITHUB_WORKSPACE - - uses: actions/checkout@v4 + - 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' - fetch-depth: 0 - token: ${{ secrets.GH_RO_PAT }} + token: ${{ steps.app_token.outputs.token }} - uses: ./.github/actions/setup-haxeshit - - name: Build game + - name: gather game 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 + run: | haxelib run lime build html5 -release --times ls - uses: ./.github/actions/upload-itch @@ -65,32 +39,34 @@ jobs: build-dir: export/release/html5/bin target: html5 create-nightly-win: - needs: check_date - if: ${{ needs.check_date.outputs.should_run != 'false'}} runs-on: windows-latest - permissions: - contents: write - actions: write steps: - - uses: actions/checkout@v4 + - 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' - fetch-depth: 0 - token: ${{ secrets.GH_RO_PAT }} + token: ${{ steps.app_token.outputs.token }} - uses: ./.github/actions/setup-haxeshit - name: Make HXCPP cache dir run: | mkdir -p ${{ runner.temp }}\hxcpp_cache - name: Restore build cache id: cache-build-win - uses: actions/cache@v3 + 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 + - name: build game run: | haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER dir @@ -101,20 +77,81 @@ jobs: butler-key: ${{ secrets.BUTLER_API_KEY }} build-dir: export/release/windows/bin target: win + create-nightly-mac: + runs-on: [self-hosted, macos] + steps: + - name: prepare container + run: | + git config --global --add safe.directory $GITHUB_WORKSPACE + - 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 }} + - uses: ./.github/actions/setup-haxeshit + - 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 }}-build-mac-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }} + - name: Build game + run: | + haxelib run lime build macos -release --times + ls + env: + HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache" + - 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 -# permissions: -# contents: write -# actions: write # steps: -# - uses: actions/checkout@v4 +# - name: get token from gh app +# uses: actions/create-github-app-token@v1 +# id: app_token # with: -# submodules: 'recursive' -# fetch-depth: 0 -# token: ${{ secrets.GH_RO_PAT }} +# 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/.vscode/settings.json b/.vscode/settings.json index cefbadcf6..3d1f488f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -110,6 +110,11 @@ "target": "windows", "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"] }, + { + "label": "Windows / Debug (Conversation Test)", + "target": "windows", + "args": ["-debug", "-DDIALOGUE"] + }, { "label": "Windows / Debug (Straight to Chart Editor)", "target": "windows", @@ -125,6 +130,11 @@ "target": "windows", "args": ["-debug", "-DLATENCY"] }, + { + "label": "Windows / Debug (Waveform Test)", + "target": "windows", + "args": ["-debug", "-DWAVEFORM"] + }, { "label": "HTML5 / Debug", "target": "html5", diff --git a/Project.xml b/Project.xml index e0677b026..40f309e1f 100644 --- a/Project.xml +++ b/Project.xml @@ -52,6 +52,7 @@ <library name="week7" preload="false" /> <library name="weekend1" preload="false" /> </section> + <library name="art" preload="false" /> <assets path="assets/songs" library="songs" exclude="*.fla|*.ogg" if="web" /> <assets path="assets/songs" library="songs" exclude="*.fla|*.mp3" unless="web" /> <assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" /> @@ -82,14 +83,15 @@ If we can exclude the `mods` folder from the manifest, we can re-enable this line. <assets path='example_mods' rename='mods' embed='false' exclude="*.md" /> --> - <assets path="art/readme.txt" rename="do NOT readme.txt" /> - <assets path="CHANGELOG.md" rename="changelog.txt" /> + <assets path="art/readme.txt" rename="do NOT readme.txt" library="art"/> + <assets path="CHANGELOG.md" rename="changelog.txt" library="art"/> <!-- NOTE FOR FUTURE SELF SINCE FONTS ARE ALWAYS FUCKY TO FIX ONE OF THEM, I CONVERTED IT TO OTF. DUNNO IF YOU NEED TO THEN UHHH I USED THE NAME OF THE FONT WITH SETFORMAT() ON THE TEXT!!! NOT USING A DIRECT THING TO THE ASSET!!! --> <assets path="assets/fonts" embed="true" /> + <!-- _______________________________ Libraries ______________________________ --> <haxelib name="lime" /> <!-- Game engine backend --> <haxelib name="openfl" /> <!-- Game engine backend --> @@ -108,9 +110,9 @@ <haxelib name="hxCodec" /> <!-- Video playback --> <haxelib name="json2object" /> <!-- JSON parsing --> - <haxelib name="tink_json" /> <!-- JSON parsing (DEPRECATED) --> <haxelib name="thx.semver" /> <!-- Version string handling --> + <haxelib name="hmm" /> <!-- Read library version data at compile time so it can be baked into logs --> <haxelib name="hxcpp-debug-server" if="desktop debug" /> <!-- VSCode debug support --> <!--Disable the Flixel core focus lost screen--> @@ -127,11 +129,11 @@ <haxeflag name="-w" value="-WDeprecated" /> <!-- Haxe 4.3.0+: Enable pretty syntax errors and stuff. --> - <haxedef name="message-reporting" value="pretty" /> + <haxedef name="message.reporting" value="pretty" /> <!-- _________________________________ Custom _______________________________ --> - <!-- Disable trace() calls in release builds to bump up performance. --> - <haxeflag name="--no-traces" unless="debug" /> + <!-- Disable trace() calls in release builds to bump up performance. + <haxeflag name="- -no-traces" unless="debug" />--> <!-- HScript relies heavily on Reflection, which means we can't use DCE. --> <haxeflag name="-dce no" /> <!-- Ensure all Funkin' classes are available at runtime. --> diff --git a/art b/art index 1656bea53..03e7c2a23 160000 --- a/art +++ b/art @@ -1 +1 @@ -Subproject commit 1656bea5370c65879aaeb323e329f403c78071c5 +Subproject commit 03e7c2a2353b184e45955c96d763b7cdf1acbc34 diff --git a/assets b/assets index 2cba31629..6825d762b 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 2cba3162990db807be054889b23cf214a1d8dd2d +Subproject commit 6825d762bd007a50405a4e38939862f5ba70481f diff --git a/hmm.json b/hmm.json index 96ee75bc1..4a0895034 100644 --- a/hmm.json +++ b/hmm.json @@ -11,14 +11,14 @@ "name": "flixel", "type": "git", "dir": null, - "ref": "9bdea914f3d0485b9b3ec158f28875b5ac95d476", + "ref": "07c6018008801972d12275690fc144fcc22e3de6", "url": "https://github.com/FunkinCrew/flixel" }, { "name": "flixel-addons", "type": "git", "dir": null, - "ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885", + "ref": "a523c3b56622f0640933944171efed46929e360e", "url": "https://github.com/FunkinCrew/flixel-addons" }, { @@ -37,7 +37,7 @@ "name": "flxanimate", "type": "git", "dir": null, - "ref": "d7c5621be742e2c98d523dfe5af7528835eaff1e", + "ref": "9bacdd6ea39f5e3a33b0f5dfb7bc583fe76060d4", "url": "https://github.com/FunkinCrew/flxanimate" }, { @@ -54,14 +54,14 @@ "name": "haxeui-core", "type": "git", "dir": null, - "ref": "7021f1fbab928268d9196a73e7f47461ca3c3e4d", + "ref": "8a7846b", "url": "https://github.com/haxeui/haxeui-core" }, { "name": "haxeui-flixel", "type": "git", "dir": null, - "ref": "26b6bb132c92dfa9b77b4a61eaeda8f9a9efda98", + "ref": "e9f880522e27134b29df4067f82df7d7e5237b70", "url": "https://github.com/haxeui/haxeui-flixel" }, { @@ -100,14 +100,14 @@ "name": "json2object", "type": "git", "dir": null, - "ref": "a0a78b60c41e47bae8bfa422488a199a58b4474e", + "ref": "a8c26f18463c98da32f744c214fe02273e1823fa", "url": "https://github.com/FunkinCrew/json2object" }, { "name": "lime", "type": "git", "dir": null, - "ref": "737b86f121cdc90358d59e2e527934f267c94a2c", + "ref": "fff39ba6fc64969cd51987ef7491d9345043dc5d", "url": "https://github.com/FunkinCrew/lime" }, { @@ -149,18 +149,13 @@ "name": "polymod", "type": "git", "dir": null, - "ref": "80d1d309803c1b111866524f9769325e3b8b0b1b", + "ref": "6cec79e4f322fbb262170594ed67ab72b4714810", "url": "https://github.com/larsiusprime/polymod" }, { "name": "thx.semver", "type": "haxelib", "version": "0.2.2" - }, - { - "name": "tink_json", - "type": "haxelib", - "version": "0.11.0" } ] } diff --git a/source/Main.hx b/source/Main.hx index 5fbb6747b..86e520e69 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -112,5 +112,6 @@ class Main extends Sprite Toolkit.theme = 'dark'; // don't be cringe Toolkit.autoScale = false; funkin.input.Cursor.registerHaxeUICursors(); + haxe.ui.tooltips.ToolTipManager.defaultDelay = 200; } } diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index c531678ad..05c23108f 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -11,6 +11,7 @@ import funkin.data.song.SongDataUtils; * A core class which handles musical timing throughout the game, * both in gameplay and in menus. */ +@:nullSafety class Conductor { // onBeatHit is called every quarter note @@ -28,29 +29,53 @@ class Conductor // 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second // 7/8 = 3.5 beats per measure = 14 steps per measure + /** + * The current instance of the Conductor. + * If one doesn't currently exist, a new one will be created. + * + * You can also do stuff like store a reference to the Conductor and pass it around or temporarily replace it, + * or have a second Conductor running at the same time, or other weird stuff like that if you need to. + */ + public static var instance:Conductor = new Conductor(); + + /** + * Signal fired when the current Conductor instance advances to a new measure. + */ + public static var measureHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the current Conductor instance advances to a new beat. + */ + public static var beatHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the current Conductor instance advances to a new step. + */ + public static var stepHit(default, null):FlxSignal = new FlxSignal(); + /** * The list of time changes in the song. * There should be at least one time change (at the beginning of the song) to define the BPM. */ - static var timeChanges:Array<SongTimeChange> = []; + var timeChanges:Array<SongTimeChange> = []; /** * The most recent time change for the current song position. */ - public static var currentTimeChange(default, null):SongTimeChange; + public var currentTimeChange(default, null):Null<SongTimeChange>; /** * The current position in the song in milliseconds. - * Update this every frame based on the audio position using `Conductor.update()`. + * Update this every frame based on the audio position using `Conductor.instance.update()`. */ - public static var songPosition(default, null):Float = 0; + public var songPosition(default, null):Float = 0; /** * Beats per minute of the current song at the current time. */ - public static var bpm(get, never):Float; + public var bpm(get, never):Float; - static function get_bpm():Float + function get_bpm():Float { if (bpmOverride != null) return bpmOverride; @@ -62,9 +87,9 @@ class Conductor /** * Beats per minute of the current song at the start time. */ - public static var startingBPM(get, never):Float; + public var startingBPM(get, never):Float; - static function get_startingBPM():Float + function get_startingBPM():Float { if (bpmOverride != null) return bpmOverride; @@ -78,14 +103,14 @@ class Conductor * The current value set by `forceBPM`. * If false, BPM is determined by time changes. */ - static var bpmOverride:Null<Float> = null; + var bpmOverride:Null<Float> = null; /** * Duration of a measure in milliseconds. Calculated based on bpm. */ - public static var measureLengthMs(get, never):Float; + public var measureLengthMs(get, never):Float; - static function get_measureLengthMs():Float + function get_measureLengthMs():Float { return beatLengthMs * timeSignatureNumerator; } @@ -93,9 +118,9 @@ class Conductor /** * Duration of a beat (quarter note) in milliseconds. Calculated based on bpm. */ - public static var beatLengthMs(get, never):Float; + public var beatLengthMs(get, never):Float; - static function get_beatLengthMs():Float + function get_beatLengthMs():Float { // Tied directly to BPM. return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC); @@ -104,25 +129,25 @@ class Conductor /** * Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm. */ - public static var stepLengthMs(get, never):Float; + public var stepLengthMs(get, never):Float; - static function get_stepLengthMs():Float + function get_stepLengthMs():Float { return beatLengthMs / timeSignatureNumerator; } - public static var timeSignatureNumerator(get, never):Int; + public var timeSignatureNumerator(get, never):Int; - static function get_timeSignatureNumerator():Int + function get_timeSignatureNumerator():Int { if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM; return currentTimeChange.timeSignatureNum; } - public static var timeSignatureDenominator(get, never):Int; + public var timeSignatureDenominator(get, never):Int; - static function get_timeSignatureDenominator():Int + function get_timeSignatureDenominator():Int { if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN; @@ -132,44 +157,44 @@ class Conductor /** * Current position in the song, in measures. */ - public static var currentMeasure(default, null):Int; + public var currentMeasure(default, null):Int = 0; /** * Current position in the song, in beats. */ - public static var currentBeat(default, null):Int; + public var currentBeat(default, null):Int = 0; /** * Current position in the song, in steps. */ - public static var currentStep(default, null):Int; + public var currentStep(default, null):Int = 0; /** * Current position in the song, in measures and fractions of a measure. */ - public static var currentMeasureTime(default, null):Float; + public var currentMeasureTime(default, null):Float = 0; /** * Current position in the song, in beats and fractions of a measure. */ - public static var currentBeatTime(default, null):Float; + public var currentBeatTime(default, null):Float = 0; /** * Current position in the song, in steps and fractions of a step. */ - public static var currentStepTime(default, null):Float; + public var currentStepTime(default, null):Float = 0; /** * An offset tied to the current chart file to compensate for a delay in the instrumental. */ - public static var instrumentalOffset:Float = 0; + public var instrumentalOffset:Float = 0; /** * The instrumental offset, in terms of steps. */ - public static var instrumentalOffsetSteps(get, never):Float; + public var instrumentalOffsetSteps(get, never):Float; - static function get_instrumentalOffsetSteps():Float + function get_instrumentalOffsetSteps():Float { var startingStepLengthMs:Float = ((Constants.SECS_PER_MIN / startingBPM) * Constants.MS_PER_SEC) / timeSignatureNumerator; @@ -179,19 +204,19 @@ class Conductor /** * An offset tied to the file format of the audio file being played. */ - public static var formatOffset:Float = 0; + public var formatOffset:Float = 0; /** * An offset set by the user to compensate for input lag. */ - public static var inputOffset:Float = 0; + public var inputOffset:Float = 0; /** * The number of beats in a measure. May be fractional depending on the time signature. */ - public static var beatsPerMeasure(get, never):Float; + public var beatsPerMeasure(get, never):Float; - static function get_beatsPerMeasure():Float + function get_beatsPerMeasure():Float { // NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure return stepsPerMeasure / Constants.STEPS_PER_BEAT; @@ -201,30 +226,15 @@ class Conductor * The number of steps in a measure. * TODO: I don't think this can be fractional? */ - public static var stepsPerMeasure(get, never):Int; + public var stepsPerMeasure(get, never):Int; - static function get_stepsPerMeasure():Int + function get_stepsPerMeasure():Int { // TODO: Is this always an integer? return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT); } - /** - * Signal fired when the Conductor advances to a new measure. - */ - public static var measureHit(default, null):FlxSignal = new FlxSignal(); - - /** - * Signal fired when the Conductor advances to a new beat. - */ - public static var beatHit(default, null):FlxSignal = new FlxSignal(); - - /** - * Signal fired when the Conductor advances to a new step. - */ - public static var stepHit(default, null):FlxSignal = new FlxSignal(); - - function new() {} + public function new() {} /** * Forcibly defines the current BPM of the song. @@ -235,7 +245,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 static function forceBPM(?bpm:Float = null) + public function forceBPM(?bpm:Float = null) { if (bpm != null) { @@ -246,7 +256,7 @@ class Conductor // trace('[CONDUCTOR] Resetting BPM to default'); } - Conductor.bpmOverride = bpm; + this.bpmOverride = bpm; } /** @@ -256,29 +266,29 @@ class Conductor * @param songPosition The current position in the song in milliseconds. * Leave blank to use the FlxG.sound.music position. */ - public static function update(?songPosition:Float) + public function update(?songPos:Float) { - if (songPosition == null) + if (songPos == null) { // Take into account instrumental and file format song offsets. - songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0; + songPos = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0; } - var oldMeasure = currentMeasure; - var oldBeat = currentBeat; - var oldStep = currentStep; + var oldMeasure = this.currentMeasure; + var oldBeat = this.currentBeat; + var oldStep = this.currentStep; // Set the song position we are at (for purposes of calculating note positions, etc). - Conductor.songPosition = songPosition; + this.songPosition = songPos; currentTimeChange = timeChanges[0]; - if (Conductor.songPosition > 0.0) + if (this.songPosition > 0.0) { for (i in 0...timeChanges.length) { - if (songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; + if (this.songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; - if (songPosition < timeChanges[i].timeStamp) break; + if (this.songPosition < timeChanges[i].timeStamp) break; } } @@ -286,45 +296,49 @@ class Conductor { trace('WARNING: Conductor is broken, timeChanges is empty.'); } - else if (currentTimeChange != null && Conductor.songPosition > 0.0) + else if (currentTimeChange != null && this.songPosition > 0.0) { // roundDecimal prevents representing 8 as 7.9999999 - currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6); - currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; - currentMeasureTime = currentStepTime / stepsPerMeasure; - currentStep = Math.floor(currentStepTime); - currentBeat = Math.floor(currentBeatTime); - currentMeasure = Math.floor(currentMeasureTime); + this.currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (this.songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6); + this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; + this.currentMeasureTime = currentStepTime / stepsPerMeasure; + this.currentStep = Math.floor(currentStepTime); + this.currentBeat = Math.floor(currentBeatTime); + this.currentMeasure = Math.floor(currentMeasureTime); } else { // Assume a constant BPM equal to the forced value. - currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4); - currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; - currentMeasureTime = currentStepTime / stepsPerMeasure; - currentStep = Math.floor(currentStepTime); - currentBeat = Math.floor(currentBeatTime); - currentMeasure = Math.floor(currentMeasureTime); + this.currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4); + this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; + this.currentMeasureTime = currentStepTime / stepsPerMeasure; + this.currentStep = Math.floor(currentStepTime); + this.currentBeat = Math.floor(currentBeatTime); + this.currentMeasure = Math.floor(currentMeasureTime); } - // FlxSignals are really cool. - if (currentStep != oldStep) + // Only fire the signal if we are THE Conductor. + if (this == Conductor.instance) { - stepHit.dispatch(); - } + // FlxSignals are really cool. + if (currentStep != oldStep) + { + Conductor.stepHit.dispatch(); + } - if (currentBeat != oldBeat) - { - beatHit.dispatch(); - } + if (currentBeat != oldBeat) + { + Conductor.beatHit.dispatch(); + } - if (currentMeasure != oldMeasure) - { - measureHit.dispatch(); + if (currentMeasure != oldMeasure) + { + Conductor.measureHit.dispatch(); + } } } - public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>) + public function mapTimeChanges(songTimeChanges:Array<SongTimeChange>) { timeChanges = []; @@ -338,24 +352,21 @@ class Conductor // Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`. if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0; - if (currentTimeChange.beatTime == null) + if (currentTimeChange.timeStamp <= 0.0) { - if (currentTimeChange.timeStamp <= 0.0) - { - currentTimeChange.beatTime = 0.0; - } - else - { - // Calculate the beat time of this timestamp. - currentTimeChange.beatTime = 0.0; + currentTimeChange.beatTime = 0.0; + } + else + { + // Calculate the beat time of this timestamp. + currentTimeChange.beatTime = 0.0; - if (currentTimeChange.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), - 4); - } + if (currentTimeChange.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), + 4); } } @@ -368,13 +379,13 @@ class Conductor } // Update currentStepTime - Conductor.update(Conductor.songPosition); + this.update(Conductor.instance.songPosition); } /** * Given a time in milliseconds, return a time in steps. */ - public static function getTimeInSteps(ms:Float):Float + public function getTimeInSteps(ms:Float):Float { if (timeChanges.length == 0) { @@ -411,7 +422,7 @@ class Conductor /** * Given a time in steps and fractional steps, return a time in milliseconds. */ - public static function getStepTimeInMs(stepTime:Float):Float + public function getStepTimeInMs(stepTime:Float):Float { if (timeChanges.length == 0) { @@ -447,7 +458,7 @@ class Conductor /** * Given a time in beats and fractional beats, return a time in milliseconds. */ - public static function getBeatTimeInMs(beatTime:Float):Float + public function getBeatTimeInMs(beatTime:Float):Float { if (timeChanges.length == 0) { @@ -480,13 +491,20 @@ class Conductor } } + public static function watchQuick():Void + { + FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition); + FlxG.watch.addQuick("bpm", Conductor.instance.bpm); + FlxG.watch.addQuick("currentMeasureTime", Conductor.instance.currentMeasureTime); + FlxG.watch.addQuick("currentBeatTime", Conductor.instance.currentBeatTime); + FlxG.watch.addQuick("currentStepTime", Conductor.instance.currentStepTime); + } + + /** + * Reset the Conductor, replacing the current instance with a fresh one. + */ public static function reset():Void { - beatHit.removeAll(); - stepHit.removeAll(); - - mapTimeChanges([]); - forceBPM(null); - update(0); + Conductor.instance = new Conductor(); } } diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx index 2c18ffa2d..24b65832b 100644 --- a/source/funkin/Highscore.hx +++ b/source/funkin/Highscore.hx @@ -21,7 +21,6 @@ abstract Tallies(RawTallies) bad: 0, good: 0, sick: 0, - killer: 0, totalNotes: 0, totalNotesHit: 0, maxCombo: 0, @@ -43,7 +42,6 @@ typedef RawTallies = var bad:Int; var good:Int; var sick:Int; - var killer:Int; var maxCombo:Int; var isNewHighscore:Bool; diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 13bcd306e..625a33ad7 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -19,12 +19,12 @@ import funkin.play.PlayStatePlaylist; import openfl.display.BitmapData; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; -import funkin.data.event.SongEventData.SongEventParser; -import funkin.play.cutscene.dialogue.ConversationDataParser; -import funkin.play.cutscene.dialogue.DialogueBoxDataParser; -import funkin.play.cutscene.dialogue.SpeakerDataParser; +import funkin.data.event.SongEventRegistry; +import funkin.data.stage.StageRegistry; +import funkin.data.dialogue.ConversationRegistry; +import funkin.data.dialogue.DialogueBoxRegistry; +import funkin.data.dialogue.SpeakerRegistry; import funkin.data.song.SongRegistry; -import funkin.play.stage.StageData.StageDataParser; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.modding.module.ModuleHandler; import funkin.ui.title.TitleState; @@ -197,25 +197,40 @@ class InitState extends FlxState FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK]; #end + // + // FLIXEL PLUGINS + // + funkin.util.plugins.EvacuateDebugPlugin.initialize(); + funkin.util.plugins.ReloadAssetsDebugPlugin.initialize(); + funkin.util.plugins.WatchPlugin.initialize(); + // // GAME DATA PARSING // - // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names, + // 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(); + SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry. SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); NoteStyleRegistry.instance.loadEntries(); - SongEventParser.loadEventCache(); - ConversationDataParser.loadConversationCache(); - DialogueBoxDataParser.loadDialogueBoxCache(); - SpeakerDataParser.loadSpeakerCache(); - StageDataParser.loadStageCache(); - CharacterDataParser.loadCharacterCache(); + ConversationRegistry.instance.loadEntries(); + DialogueBoxRegistry.instance.loadEntries(); + SpeakerRegistry.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. + ModuleHandler.buildModuleCallbacks(); ModuleHandler.loadModuleCache(); - ModuleHandler.callOnCreate(); + + var perfEnd = haxe.Timer.stamp(); + + trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.'); } /** @@ -233,8 +248,12 @@ class InitState extends FlxState startLevel(defineLevel(), defineDifficulty()); #elseif FREEPLAY // -DFREEPLAY FlxG.switchState(new FreeplayState()); + #elseif DIALOGUE // -DDIALOGUE + FlxG.switchState(new funkin.ui.debug.dialogue.ConversationDebugState()); #elseif ANIMATE // -DANIMATE FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest()); + #elseif WAVEFORM // -DWAVEFORM + FlxG.switchState(new funkin.ui.debug.WaveformTestState()); #elseif CHARTING // -DCHARTING FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState()); #elseif STAGEBUILD // -DSTAGEBUILD diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx index 40293b0ce..e7ce68d08 100644 --- a/source/funkin/audio/FunkinSound.hx +++ b/source/funkin/audio/FunkinSound.hx @@ -7,6 +7,10 @@ import flash.utils.ByteArray; import flixel.sound.FlxSound; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.system.FlxAssets.FlxSoundAsset; +import funkin.util.tools.ICloneable; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import flixel.math.FlxMath; import openfl.Assets; #if (openfl >= "8.0.0") import openfl.utils.AssetType; @@ -17,10 +21,38 @@ import openfl.utils.AssetType; * - Delayed playback via negative song position. */ @:nullSafety -class FunkinSound extends FlxSound +class FunkinSound extends FlxSound implements ICloneable<FunkinSound> { + static final MAX_VOLUME:Float = 2.0; + static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>(); + public var muted(default, set):Bool = false; + + function set_muted(value:Bool):Bool + { + if (value == muted) return value; + muted = value; + updateTransform(); + return value; + } + + override function set_volume(value:Float):Float + { + // Uncap the volume. + fixMaxVolume(); + _volume = FlxMath.bound(value, 0.0, MAX_VOLUME); + updateTransform(); + return _volume; + } + + public var paused(get, never):Bool; + + function get_paused():Bool + { + return this._paused; + } + public var isPlaying(get, never):Bool; function get_isPlaying():Bool @@ -28,6 +60,24 @@ class FunkinSound extends FlxSound return this.playing || this._shouldPlay; } + /** + * Waveform data for this sound. + * This is lazily loaded, so it will be built the first time it is accessed. + */ + public var waveformData(get, never):WaveformData; + + var _waveformData:Null<WaveformData> = null; + + function get_waveformData():WaveformData + { + if (_waveformData == null) + { + _waveformData = WaveformDataParser.interpretFlxSound(this); + if (_waveformData == null) throw 'Could not interpret waveform data!'; + } + return _waveformData; + } + /** * Are we in a state where the song should play but time is negative? */ @@ -63,6 +113,30 @@ class FunkinSound extends FlxSound } } + public function togglePlayback():FunkinSound + { + if (playing) + { + pause(); + } + else + { + resume(); + } + 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; @@ -107,6 +181,26 @@ class FunkinSound extends FlxSound return this; } + /** + * Called when the user clicks to focus on the window. + */ + override function onFocus():Void + { + if (!_alreadyPaused && this._shouldPlay) + { + resume(); + } + } + + /** + * Called when the user tabs away from the window. + */ + override function onFocusLost():Void + { + _alreadyPaused = _paused; + pause(); + } + public override function resume():FunkinSound { if (this._time < 0) @@ -120,6 +214,37 @@ class FunkinSound extends FlxSound return this; } + /** + * Call after adjusting the volume to update the sound channel's settings. + */ + @:allow(flixel.sound.FlxSoundGroup) + override function updateTransform():Void + { + _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end + (group != null ? group.volume : 1) * _volume * _volumeAdjust; + + if (_channel != null) _channel.soundTransform = _transform; + } + + public function clone():FunkinSound + { + var sound:FunkinSound = new FunkinSound(); + + // Clone the sound by creating one with the same data buffer. + // Reusing the `Sound` object directly causes issues with playback. + @:privateAccess + sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer); + + // Call init to ensure the FlxSound is properly initialized. + sound.init(this.looped, this.autoDestroy, this.onComplete); + + // Oh yeah, the waveform data is the same too! + @:privateAccess + sound._waveformData = this._waveformData; + + return sound; + } + /** * Creates a new `FunkinSound` object. * diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx index 15c2296ca..df3a67ae1 100644 --- a/source/funkin/audio/SoundGroup.hx +++ b/source/funkin/audio/SoundGroup.hx @@ -3,6 +3,7 @@ package funkin.audio; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.sound.FlxSound; import funkin.audio.FunkinSound; +import flixel.tweens.FlxTween; /** * A group of FunkinSounds that are all synced together. @@ -14,8 +15,12 @@ class SoundGroup extends FlxTypedGroup<FunkinSound> public var volume(get, set):Float; + public var muted(get, set):Bool; + public var pitch(get, set):Float; + public var playing(get, never):Bool; + public function new() { super(); @@ -122,6 +127,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound> }); } + /** + * Fade in all the sounds in the group. + */ + public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void + { + forEachAlive(function(sound:FunkinSound) { + sound.fadeIn(duration, from, to, onComplete); + }); + } + + /** + * Fade out all the sounds in the group. + */ + public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void + { + forEachAlive(function(sound:FunkinSound) { + sound.fadeOut(duration, to, onComplete); + }); + } + /** * Stop all the sounds in the group. */ @@ -132,6 +157,12 @@ class SoundGroup extends FlxTypedGroup<FunkinSound> }); } + public override function destroy() + { + stop(); + super.destroy(); + } + /** * Remove all sounds from the group. */ @@ -159,6 +190,13 @@ class SoundGroup extends FlxTypedGroup<FunkinSound> return time; } + function get_playing():Bool + { + if (getFirstAlive != null) return getFirstAlive().playing; + else + return false; + } + function get_volume():Float { if (getFirstAlive() != null) return getFirstAlive().volume; @@ -176,6 +214,22 @@ class SoundGroup extends FlxTypedGroup<FunkinSound> return volume; } + function get_muted():Bool + { + if (getFirstAlive() != null) return getFirstAlive().muted; + else + return false; + } + + function set_muted(muted:Bool):Bool + { + forEachAlive(function(snd:FunkinSound) { + snd.muted = muted; + }); + + return muted; + } + function get_pitch():Float { #if FLX_PITCH diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx index 42f31af70..5daebc89d 100644 --- a/source/funkin/audio/VoicesGroup.hx +++ b/source/funkin/audio/VoicesGroup.hx @@ -2,6 +2,8 @@ package funkin.audio; import funkin.audio.FunkinSound; import flixel.group.FlxGroup.FlxTypedGroup; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; class VoicesGroup extends SoundGroup { @@ -104,6 +106,50 @@ class VoicesGroup extends SoundGroup return opponentVolume = volume; } + public function getPlayerVoice(index:Int = 0):Null<FunkinSound> + { + return playerVoices.members[index]; + } + + public function getOpponentVoice(index:Int = 0):Null<FunkinSound> + { + return opponentVoices.members[index]; + } + + public function getPlayerVoiceWaveform():Null<WaveformData> + { + if (playerVoices.members.length == 0) return null; + + return playerVoices.members[0].waveformData; + } + + public function getOpponentVoiceWaveform():Null<WaveformData> + { + if (opponentVoices.members.length == 0) return null; + + return opponentVoices.members[0].waveformData; + } + + /** + * The length of the player's vocal track, in milliseconds. + */ + public function getPlayerVoiceLength():Float + { + if (playerVoices.members.length == 0) return 0.0; + + return playerVoices.members[0].length; + } + + /** + * The length of the opponent's vocal track, in milliseconds. + */ + public function getOpponentVoiceLength():Float + { + if (opponentVoices.members.length == 0) return 0.0; + + return opponentVoices.members[0].length; + } + public override function clear():Void { playerVoices.clear(); diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx index 681287808..89b004df4 100644 --- a/source/funkin/audio/visualize/ABotVis.hx +++ b/source/funkin/audio/visualize/ABotVis.hx @@ -64,7 +64,7 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite> if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, vis.numSamples)); else - remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, vis.numSamples)); + remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, vis.numSamples)); var fftSamples:Array<Float> = []; diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx index 37a6c15d1..948027a8d 100644 --- a/source/funkin/audio/visualize/PolygonSpectogram.hx +++ b/source/funkin/audio/visualize/PolygonSpectogram.hx @@ -102,7 +102,7 @@ class PolygonSpectogram extends MeshRender coolPoint.x = (curAud.balanced * waveAmplitude); coolPoint.y = (i / funnyPixels * daHeight); - add_quad(prevPoint.x, prevPoint.y, prevPoint.x + build_quad(prevPoint.x, prevPoint.y, prevPoint.x + thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x + thickness, coolPoint.y + thickness); diff --git a/source/funkin/audio/visualize/PolygonVisGroup.hx b/source/funkin/audio/visualize/PolygonVisGroup.hx index 2903eaccd..cc68f4ae0 100644 --- a/source/funkin/audio/visualize/PolygonVisGroup.hx +++ b/source/funkin/audio/visualize/PolygonVisGroup.hx @@ -8,8 +8,7 @@ class PolygonVisGroup extends FlxTypedGroup<PolygonSpectogram> { public var playerVis:PolygonSpectogram; public var opponentVis:PolygonSpectogram; - - var instVis:PolygonSpectogram; + public var instVis:PolygonSpectogram; public function new() { @@ -51,6 +50,43 @@ class PolygonVisGroup extends FlxTypedGroup<PolygonSpectogram> instVis = vis; } + public function clearPlayerVis():Void + { + if (playerVis != null) + { + remove(playerVis); + playerVis.destroy(); + playerVis = null; + } + } + + public function clearOpponentVis():Void + { + if (opponentVis != null) + { + remove(opponentVis); + opponentVis.destroy(); + opponentVis = null; + } + } + + public function clearInstVis():Void + { + if (instVis != null) + { + remove(instVis); + instVis.destroy(); + instVis = null; + } + } + + public function clearAllVis():Void + { + clearPlayerVis(); + clearOpponentVis(); + clearInstVis(); + } + /** * Overrides the add function to add a visualizer to the group. * @param vis The visualizer to add. diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx index 63d0fcd2e..b4e024a4c 100644 --- a/source/funkin/audio/visualize/SpectogramSprite.hx +++ b/source/funkin/audio/visualize/SpectogramSprite.hx @@ -164,7 +164,7 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite> if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples)); else - remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, numSamples)); + remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples)); var fftSamples:Array<Float> = []; var i = remappedShit; @@ -235,15 +235,15 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite> if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples)); else { - if (curTime == Conductor.songPosition) + if (curTime == Conductor.instance.songPosition) { wavOptimiz = 3; return; // already did shit, so finishes function early } - curTime = Conductor.songPosition; + curTime = Conductor.instance.songPosition; - remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, numSamples)); + remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples)); } wavOptimiz = 8; diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx new file mode 100644 index 000000000..b82d141e7 --- /dev/null +++ b/source/funkin/audio/waveform/WaveformData.hx @@ -0,0 +1,336 @@ +package funkin.audio.waveform; + +import funkin.util.MathUtil; + +@:nullSafety +class WaveformData +{ + static final DEFAULT_VERSION:Int = 2; + + /** + * The version of the waveform data format. + * @default `2` (-1 if not specified/invalid) + */ + public var version(default, null):Int = -1; + + /** + * The number of channels in the waveform. + */ + public var channels(default, null):Int = 1; + + @:alias('sample_rate') + public var sampleRate(default, null):Int = 44100; + + /** + * Number of input audio samples per output waveform data point. + * At base zoom level this is number of samples per pixel. + * Lower values can more accurately represent the waveform when zoomed in, but take more data. + */ + @:alias('samples_per_pixel') + public var samplesPerPoint(default, null):Int = 256; + + /** + * Number of bits to use for each sample value. Valid values are `8` and `16`. + */ + public var bits(default, null):Int = 16; + + /** + * The length of the data array, in points. + */ + public var length(default, null):Int = 0; + + /** + * Array of Int16 values representing the waveform. + * TODO: Use an `openfl.Vector` for performance. + */ + public var data(default, null):Array<Int> = []; + + @:jignored + var channelData:Null<Array<WaveformDataChannel>> = null; + + public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array<Int>) + { + this.version = version ?? DEFAULT_VERSION; + this.channels = channels; + this.sampleRate = sampleRate; + this.samplesPerPoint = samplesPerPoint; + this.bits = bits; + this.length = length; + this.data = data; + } + + function buildChannelData():Array<WaveformDataChannel> + { + channelData = []; + for (i in 0...channels) + { + channelData.push(new WaveformDataChannel(this, i)); + } + return channelData; + } + + public function channel(index:Int) + { + return (channelData == null) ? buildChannelData()[index] : channelData[index]; + } + + public function get(index:Int):Int + { + return data[index] ?? 0; + } + + public function set(index:Int, value:Int) + { + data[index] = value; + } + + /** + * Maximum possible value for a waveform data point. + * The minimum possible value is (-1 * maxSampleValue) + */ + public function maxSampleValue():Int + { + if (_maxSampleValue != 0) return _maxSampleValue; + return _maxSampleValue = Std.int(Math.pow(2, bits)); + } + + /** + * Cache the value because `Math.pow` is expensive and the value gets used a lot. + */ + @:jignored + var _maxSampleValue:Int = 0; + + /** + * @return The length of the waveform in samples. + */ + public function lenSamples():Int + { + return length * samplesPerPoint; + } + + /** + * @return The length of the waveform in seconds. + */ + public function lenSeconds():Float + { + return inline lenSamples() / sampleRate; + } + + /** + * Given the time in seconds, return the waveform data point index. + */ + public function secondsToIndex(seconds:Float):Int + { + return Std.int(seconds * inline pointsPerSecond()); + } + + /** + * Given a waveform data point index, return the time in seconds. + */ + public function indexToSeconds(index:Int):Float + { + return index / inline pointsPerSecond(); + } + + /** + * The number of data points this waveform data provides per second of audio. + */ + public inline function pointsPerSecond():Float + { + return sampleRate / samplesPerPoint; + } + + /** + * Given the percentage progress through the waveform, return the waveform data point index. + */ + public function percentToIndex(percent:Float):Int + { + return Std.int(percent * length); + } + + /** + * Given a waveform data point index, return the percentage progress through the waveform. + */ + public function indexToPercent(index:Int):Float + { + return index / length; + } + + /** + * Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value. + * This is useful for zooming in/out of the waveform in a performant manner. + * + * @param newSamplesPerPoint The new value for `samplesPerPoint`. + */ + public function resample(newSamplesPerPoint:Int):WaveformData + { + var result = this.clone(); + + var ratio = newSamplesPerPoint / samplesPerPoint; + if (ratio == 1) return result; + if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.'); + + var inputSampleCount = this.lenSamples(); + var outputSampleCount = Std.int(inputSampleCount * ratio); + + var inputPointCount = this.length; + var outputPointCount = Std.int(inputPointCount / ratio); + var outputChannelCount = this.channels; + + // TODO: Actually figure out the dumbass logic for this. + + return result; + } + + /** + * Create a new WaveformData whose data represents the two waveforms overlayed. + */ + public function merge(that:WaveformData):WaveformData + { + var result = this.clone([]); + + for (channelIndex in 0...this.channels) + { + var thisChannel = this.channel(channelIndex); + var thatChannel = that.channel(channelIndex); + var resultChannel = result.channel(channelIndex); + + for (index in 0...this.length) + { + var thisMinSample = thisChannel.minSample(index); + var thatMinSample = thatChannel.minSample(index); + + var thisMaxSample = thisChannel.maxSample(index); + var thatMaxSample = thatChannel.maxSample(index); + + resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample))); + resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample))); + } + } + + @:privateAccess + result.length = this.length; + + return result; + } + + /** + * Create a new WaveformData whose parameters match the current object. + */ + public function clone(?newData:Array<Int> = null):WaveformData + { + if (newData == null) + { + newData = this.data.clone(); + } + + var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData); + + return clone; + } +} + +@:nullSafety +class WaveformDataChannel +{ + var parent:WaveformData; + var channelId:Int; + + public function new(parent:WaveformData, channelId:Int) + { + this.parent = parent; + this.channelId = channelId; + } + + /** + * Retrieve a given minimum point at an index. + */ + public function minSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function minSampleMapped(i:Int) + { + return inline minSample(i) / inline parent.maxSampleValue(); + } + + /** + * Minimum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function minSampleRange(start:Int, end:Int) + { + var min = inline parent.maxSampleValue(); + for (i in start...end) + { + var sample = inline minSample(i); + if (sample < min) min = sample; + } + return min; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function minSampleRangeMapped(start:Int, end:Int) + { + return inline minSampleRange(start, end) / inline parent.maxSampleValue(); + } + + /** + * Retrieve a given maximum point at an index. + */ + public function maxSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function maxSampleMapped(i:Int) + { + return inline maxSample(i) / inline parent.maxSampleValue(); + } + + /** + * Maximum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function maxSampleRange(start:Int, end:Int) + { + var max = -(inline parent.maxSampleValue()); + for (i in start...end) + { + var sample = inline maxSample(i); + if (sample > max) max = sample; + } + return max; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function maxSampleRangeMapped(start:Int, end:Int) + { + return inline maxSampleRange(start, end) / inline parent.maxSampleValue(); + } + + public function setMinSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + inline parent.set(offset, value); + } + + public function setMaxSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + inline parent.set(offset, value); + } +} diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx new file mode 100644 index 000000000..54a142f6a --- /dev/null +++ b/source/funkin/audio/waveform/WaveformDataParser.hx @@ -0,0 +1,145 @@ +package funkin.audio.waveform; + +class WaveformDataParser +{ + static final INT16_MAX:Int = 32767; + static final INT16_MIN:Int = -32768; + + static final INT8_MAX:Int = 127; + static final INT8_MIN:Int = -128; + + public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null<WaveformData> + { + if (sound == null) return null; + + // Method 1. This only works if the sound has been played before. + @:privateAccess + var soundBuffer:Null<lime.media.AudioBuffer> = sound?._channel?.__source?.buffer; + + if (soundBuffer == null) + { + // Method 2. This works if the sound has not been played before. + @:privateAccess + soundBuffer = sound?._sound?.__buffer; + + if (soundBuffer == null) + { + trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}'); + return null; + } + else + { + // trace('[WAVEFORM] Method 2 worked.'); + } + } + else + { + // trace('[WAVEFORM] Method 1 worked.'); + } + + return interpretAudioBuffer(soundBuffer); + } + + public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null<WaveformData> + { + var sampleRate = soundBuffer.sampleRate; + var channels = soundBuffer.channels; + var bitsPerSample = soundBuffer.bitsPerSample; + var samplesPerPoint:Int = 256; // I don't think we need to configure this. + var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant.. + + // TODO: Make this work better on HTML5. + var soundData:lime.utils.Int16Array = cast soundBuffer.data; + + var soundDataRawLength:Int = soundData.length; + var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1))); + var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint)); + + // trace('Interpreting audio buffer:'); + // trace(' sampleRate: ${sampleRate}'); + // trace(' channels: ${channels}'); + // trace(' bitsPerSample: ${bitsPerSample}'); + // trace(' samplesPerPoint: ${samplesPerPoint}'); + // trace(' pointsPerSecond: ${pointsPerSecond}'); + // trace(' soundDataRawLength: ${soundDataRawLength}'); + // trace(' soundDataSampleCount: ${soundDataSampleCount}'); + // trace(' soundDataRawLength/4: ${soundDataRawLength / 4}'); + // trace(' outputPointCount: ${outputPointCount}'); + + var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN; + var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX; + + var outputData:Array<Int> = []; + + var perfStart = haxe.Timer.stamp(); + + for (pointIndex in 0...outputPointCount) + { + // minChannel1, maxChannel1, minChannel2, maxChannel2, ... + var values:Array<Int> = []; + + for (i in 0...channels) + { + values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX); + values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN); + } + + var rangeStart = pointIndex * samplesPerPoint; + var rangeEnd = rangeStart + samplesPerPoint; + if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount; + + for (sampleIndex in rangeStart...rangeEnd) + { + for (channelIndex in 0...channels) + { + var sampleIndex:Int = sampleIndex * channels + channelIndex; + var sampleValue = soundData[sampleIndex]; + + if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue; + if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue; + } + } + + // We now have the min and max values for the range. + for (value in values) + outputData.push(value); + } + + 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.'); + + return result; + } + + public static function parseWaveformData(path:String):Null<WaveformData> + { + var rawJson:String = openfl.Assets.getText(path).trim(); + return parseWaveformDataString(rawJson, path); + } + + public static function parseWaveformDataString(contents:String, ?fileName:String):Null<WaveformData> + { + var parser = new json2object.JsonParser<WaveformData>(); + parser.ignoreUnknownVariables = false; + trace('[WAVEFORM] Parsing waveform data: ${contents}'); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + static function printErrors(errors:Array<json2object.Error>, id:String = ''):Void + { + trace('[WAVEFORM] Failed to parse waveform data: ${id}'); + + for (error in errors) + funkin.data.DataError.printError(error); + } +} diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx new file mode 100644 index 000000000..32ced2fbd --- /dev/null +++ b/source/funkin/audio/waveform/WaveformSprite.hx @@ -0,0 +1,449 @@ +package funkin.audio.waveform; + +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.graphics.rendering.MeshRender; +import flixel.util.FlxColor; + +class WaveformSprite extends MeshRender +{ + static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE; + static final DEFAULT_DURATION:Float = 5.0; + static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL; + static final DEFAULT_X:Float = 0.0; + static final DEFAULT_Y:Float = 0.0; + static final DEFAULT_WIDTH:Float = 100.0; + static final DEFAULT_HEIGHT:Float = 100.0; + + /** + * Set this to true to tell the waveform to rebuild itself. + * Do this any time the data or drawable area of the waveform changes. + * This often (but not always) needs to be done every frame. + */ + var isWaveformDirty:Bool = true; + + /** + * If true, force the waveform to redraw every frame. + * Useful if the waveform's clipRect is constantly changing. + */ + public var forceUpdate:Bool = false; + + public var waveformData(default, set):Null<WaveformData>; + + function set_waveformData(value:Null<WaveformData>):Null<WaveformData> + { + if (waveformData == value) return value; + + waveformData = value; + isWaveformDirty = true; + return waveformData; + } + + /** + * The color to render the waveform with. + */ + public var waveformColor(default, set):FlxColor; + + function set_waveformColor(value:FlxColor):FlxColor + { + if (waveformColor == value) return value; + + waveformColor = value; + // We don't need to dirty the waveform geometry, just rebuild the texture. + rebuildGraphic(); + return waveformColor; + } + + public var orientation(default, set):WaveformOrientation; + + function set_orientation(value:WaveformOrientation):WaveformOrientation + { + if (orientation == value) return value; + + orientation = value; + isWaveformDirty = true; + return orientation; + } + + /** + * Time, in seconds, at which the waveform starts. + */ + public var time(default, set):Float; + + function set_time(value:Float) + { + if (time == value) return value; + + time = value; + isWaveformDirty = true; + return time; + } + + /** + * The duration, in seconds, that the waveform represents. + * The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents. + */ + public var duration(default, set):Float; + + function set_duration(value:Float) + { + if (duration == value) return value; + + duration = value; + isWaveformDirty = true; + return duration; + } + + /** + * Set the physical size of the waveform with `this.height = value`. + */ + override function set_height(value:Float):Float + { + if (height == value) return super.set_height(value); + + isWaveformDirty = true; + return super.set_height(value); + } + + /** + * Set the physical size of the waveform with `this.width = value`. + */ + override function set_width(value:Float):Float + { + if (width == value) return super.set_width(value); + + isWaveformDirty = true; + return super.set_width(value); + } + + /** + * The minimum size, in pixels, that a waveform will display with. + * Useful for preventing the waveform from becoming too small to see. + * + * NOTE: This is technically doubled since it's applied above and below the center of the waveform. + */ + public var minWaveformSize:Int = 1; + + /** + * A multiplier on the size of the waveform. + * Still capped at the width and height set for the sprite. + */ + public var amplitude:Float = 1.0; + + public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR); + this.waveformColor = color ?? DEFAULT_COLOR; + this.width = DEFAULT_WIDTH; + this.height = DEFAULT_HEIGHT; + + this.waveformData = waveformData; + this.orientation = orientation ?? DEFAULT_ORIENTATION; + this.time = 0.0; + this.duration = duration ?? DEFAULT_DURATION; + + this.forceUpdate = false; + } + + /** + * Manually tell the waveform to rebuild itself, even if none of its properties have changed. + */ + public function markDirty():Void + { + isWaveformDirty = true; + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + if (forceUpdate || isWaveformDirty) + { + // Recalculate the waveform vertices. + drawWaveform(); + isWaveformDirty = false; + } + } + + function rebuildGraphic():Void + { + // The waveform is rendered using a single colored pixel as a texture. + // If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want. + makeGraphic(1, 1, this.waveformColor); + } + + /** + * @param offsetX Horizontal offset to draw the waveform at, in samples. + */ + function drawWaveform():Void + { + // For each sample in the waveform... + // Add a MAX vertex and a MIN vertex. + // If previous MAX/MIN is empty, store. + // If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN. + // Continue until end of waveform. + + this.clear(); + + if (waveformData == null) return; + + // Center point of the waveform. When horizontal this is half the height, when vertical this is half the width. + var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2); + + var oneSecondInIndices:Int = waveformData.secondsToIndex(1); + + var startTime:Float = time; + var endTime:Float = time + duration; + + var startIndex:Int = waveformData.secondsToIndex(startTime); + var endIndex:Int = waveformData.secondsToIndex(endTime); + + var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex); + var indexesPerPixel:Float = 1 / pixelsPerIndex; + + var topLeftVertexIndex:Int = -1; + var topRightVertexIndex:Int = -1; + var bottomLeftVertexIndex:Int = -1; + var bottomRightVertexIndex:Int = -1; + + if (clipRect != null) + { + topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y); + topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y); + bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height); + bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height); + } + + if (pixelsPerIndex >= 1.0) + { + // Each index is at least one pixel wide, so we render each index. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + for (i in startIndex...endIndex) + { + var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex); + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize; + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + // Don't render if we don't have a previous different set of vertices to create a quad from. + if (prevVertexTopIndex != -1 + && prevVertexBottomIndex != -1 + && prevVertexTopIndex != vertexTopIndex + && prevVertexBottomIndex != vertexBottomIndex) + { + switch (orientation) // the line of code that makes you gay + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + else + { + // Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height); + for (i in 0...waveformLengthPixels) + { + var pixelPos:Int = i; + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + // Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming. + var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex); + var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex); + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1) + { + switch (orientation) + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + } + + function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int + { + var shouldClipXLeft = x < clipRect.x; + var shouldClipXRight = x > (clipRect.x + clipRect.width); + var shouldClipYTop = y < clipRect.y; + var shouldClipYBottom = y > (clipRect.y + clipRect.height); + + // If the vertex is fully outside the clipRect, use a pre-existing vertex. + // Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis. + // Else, create a whole new vertex. + if (shouldClipXLeft && shouldClipYTop) + { + return topLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYTop) + { + return topRightVertexIndex; + } + else if (shouldClipXLeft && shouldClipYBottom) + { + return bottomLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYBottom) + { + return bottomRightVertexIndex; + } + else if (shouldClipXLeft) + { + return this.build_vertex(clipRect.x, y); + } + else if (shouldClipXRight) + { + return this.build_vertex(clipRect.x + clipRect.width, y); + } + else if (shouldClipYTop) + { + return this.build_vertex(x, clipRect.y); + } + else if (shouldClipYBottom) + { + return this.build_vertex(x, clipRect.y + clipRect.height); + } + else + { + return this.build_vertex(x, y); + } + } + + public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + return new WaveformSprite(data, orientation, color, duration); + } + + public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + // TODO: Build waveform data from FunkinSound. + var data = null; + + return buildFromWaveformData(data, orientation, color, duration); + } +} + +enum WaveformOrientation +{ + HORIZONTAL; + VERTICAL; +} diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 70615069b..0ccbe2f18 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -46,6 +46,9 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo this.entries = new Map<String, T>(); } + /** + * TODO: Create a `loadEntriesAsync()` function. + */ public function loadEntries():Void { clearEntries(); @@ -54,7 +57,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo // SCRIPTED ENTRIES // var scriptedEntryClassNames:Array<String> = getScriptedClassNames(); - log('Registering ${scriptedEntryClassNames.length} scripted entries...'); + log('Parsing ${scriptedEntryClassNames.length} scripted entries...'); for (entryCls in scriptedEntryClassNames) { @@ -78,7 +81,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool { return !entries.exists(entryId); }); - log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...'); + log('Parsing ${unscriptedEntryIds.length} unscripted entries...'); for (entryId in unscriptedEntryIds) { try diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx index cbd168a61..244d41132 100644 --- a/source/funkin/data/DataParse.hx +++ b/source/funkin/data/DataParse.hx @@ -120,6 +120,71 @@ class DataParse } } + public static function backdropData(json:Json, name:String):funkin.data.dialogue.ConversationData.BackdropData + { + switch (json.value) + { + case JObject(fields): + var result:Dynamic = {}; + var backdropType:String = ''; + + for (field in fields) + { + switch (field.name) + { + case 'type': + backdropType = Tools.getValue(field.value); + } + Reflect.setField(result, field.name, Tools.getValue(field.value)); + } + + switch (backdropType) + { + case 'solid': + return SOLID(result); + default: + throw 'Expected Backdrop property $name to be specify a valid "type", but it was "${backdropType}".'; + } + + return null; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function outroData(json:Json, name:String):Null<funkin.data.dialogue.ConversationData.OutroData> + { + switch (json.value) + { + case JObject(fields): + var result:Dynamic = {}; + var outroType:String = ''; + + for (field in fields) + { + switch (field.name) + { + case 'type': + outroType = Tools.getValue(field.value); + } + Reflect.setField(result, field.name, Tools.getValue(field.value)); + } + + switch (outroType) + { + case 'none': + return NONE(result); + case 'fade': + return FADE(result); + default: + throw 'Expected Outro property $name to be specify a valid "type", but it was "${outroType}".'; + } + return null; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + /** * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`. * Used by the FNF legacy JSON importer. @@ -178,7 +243,31 @@ class DataParse switch (json.value) { case JObject(fields): - return cast Tools.getValue(json); + var result:LegacyNoteSection = + { + mustHitSection: false, + sectionNotes: [], + }; + for (field in fields) + { + switch (field.name) + { + case 'sectionNotes': + result.sectionNotes = legacyNotes(field.value, field.name); + + case 'mustHitSection': + result.mustHitSection = Tools.getValue(field.value); + case 'typeOfSection': + result.typeOfSection = Tools.getValue(field.value); + case 'lengthInSteps': + result.lengthInSteps = Tools.getValue(field.value); + case 'changeBPM': + result.changeBPM = Tools.getValue(field.value); + case 'bpm': + result.bpm = Tools.getValue(field.value); + } + } + return result; default: throw 'Expected property $name to be an object, but it was ${json.value}.'; } @@ -189,7 +278,12 @@ class DataParse switch (json.value) { case JObject(fields): - return cast Tools.getValue(json); + var result = {}; + for (field in fields) + { + Reflect.setField(result, field.name, legacyNoteSectionArray(field.value, field.name)); + } + return result; default: throw 'Expected property $name to be an object, but it was ${json.value}.'; } @@ -211,13 +305,13 @@ class DataParse switch (json.value) { case JArray(values): - // var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]); - // var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]); - // var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]); - // var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]); + var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]); + var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]); + var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]); + var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]); - // return new LegacyNote(time, data, length, alt); - return null; + return new LegacyNote(time, data, length, alt); + // return null; default: throw 'Expected property $name to be a note, but it was ${json.value}.'; } diff --git a/source/funkin/data/character/TODO.md b/source/funkin/data/character/TODO.md new file mode 100644 index 000000000..e69de29bb diff --git a/source/funkin/data/conversation/TODO.md b/source/funkin/data/conversation/TODO.md new file mode 100644 index 000000000..e69de29bb diff --git a/source/funkin/data/dialogue/ConversationData.hx b/source/funkin/data/dialogue/ConversationData.hx new file mode 100644 index 000000000..795ddae9a --- /dev/null +++ b/source/funkin/data/dialogue/ConversationData.hx @@ -0,0 +1,168 @@ +package funkin.data.dialogue; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data for a specific conversation. + * It includes things like what dialogue boxes to use, what text to display, and what animations to play. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef ConversationData = +{ + /** + * Semantic version for conversation data. + */ + public var version:String; + + /** + * Data on the backdrop for the conversation. + */ + @:jcustomparse(funkin.data.DataParse.backdropData) + public var backdrop:BackdropData; + + /** + * Data on the outro for the conversation. + */ + @:jcustomparse(funkin.data.DataParse.outroData) + @:optional + public var outro:Null<OutroData>; + + /** + * Data on the music for the conversation. + */ + @:optional + public var music:Null<MusicData>; + + /** + * Data for each line of dialogue in the conversation. + */ + public var dialogue:Array<DialogueEntryData>; +} + +/** + * Data on the backdrop for the conversation, behind the dialogue box. + * A custom parser distinguishes between backdrop types based on the `type` field. + */ +enum BackdropData +{ + SOLID(data:BackdropData_Solid); // 'solid' +} + +/** + * Data for a Solid color backdrop. + */ +typedef BackdropData_Solid = +{ + /** + * Used to distinguish between backdrop types. Should always be `solid` for this type. + */ + var type:String; + + /** + * The color of the backdrop. + */ + var color:String; + + /** + * Fade-in time for the backdrop. + * @default No fade-in + */ + @:optional + @:default(0.0) + var fadeTime:Float; +}; + +enum OutroData +{ + NONE(data:OutroData_None); // 'none' + FADE(data:OutroData_Fade); // 'fade' +} + +typedef OutroData_None = +{ + /** + * Used to distinguish between outro types. Should always be `none` for this type. + */ + var type:String; +} + +typedef OutroData_Fade = +{ + /** + * Used to distinguish between outro types. Should always be `fade` for this type. + */ + var type:String; + + /** + * The time to fade out the conversation. + * @default 1 second + */ + @:optional + @:default(1.0) + var fadeTime:Float; +} + +typedef MusicData = +{ + /** + * The asset to play for the music. + */ + var asset:String; + + /** + * The time to fade in the music. + */ + @:optional + @:default(0.0) + var fadeTime:Float; + + @:optional + @:default(false) + var looped:Bool; +}; + +/** + * Data on a single line of dialogue in a conversation. + */ +typedef DialogueEntryData = +{ + /** + * Which speaker is speaking. + * @see `SpeakerData.hx` + */ + public var speaker:String; + + /** + * The animation the speaker should play for this line of dialogue. + */ + public var speakerAnimation:String; + + /** + * Which dialogue box to use for this line of dialogue. + * @see `DialogueBoxData.hx` + */ + public var box:String; + + /** + * Which animation to play for the dialogue box. + */ + public var boxAnimation:String; + + /** + * The text that will display for this line of dialogue. + * Text will automatically wrap. + * When the user advances the dialogue, the next entry in the array will concatenate on. + * Advancing when the last entry is displayed will move to the next `DialogueEntryData`, + * or end the conversation if there are no more. + */ + public var text:Array<String>; + + /** + * The relative speed at which text gets "typed out". + * Setting `speed` to `1.5` would make it look like the character is speaking quickly, + * and setting `speed` to `0.5` would make it look like the character is emphasizing each word. + */ + @:optional + @:default(1.0) + public var speed:Float; +}; diff --git a/source/funkin/data/dialogue/ConversationRegistry.hx b/source/funkin/data/dialogue/ConversationRegistry.hx new file mode 100644 index 000000000..9186ef786 --- /dev/null +++ b/source/funkin/data/dialogue/ConversationRegistry.hx @@ -0,0 +1,81 @@ +package funkin.data.dialogue; + +import funkin.play.cutscene.dialogue.Conversation; +import funkin.data.dialogue.ConversationData; +import funkin.play.cutscene.dialogue.ScriptedConversation; + +class ConversationRegistry extends BaseRegistry<Conversation, ConversationData> +{ + /** + * The current version string for the dialogue box data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateConversationData()` function. + */ + public static final CONVERSATION_DATA_VERSION:thx.semver.Version = "1.0.0"; + + public static final CONVERSATION_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + public static final instance:ConversationRegistry = new ConversationRegistry(); + + public function new() + { + super('CONVERSATION', 'dialogue/conversations', CONVERSATION_DATA_VERSION_RULE); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null<ConversationData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<ConversationData>(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<ConversationData> + { + var parser = new json2object.JsonParser<ConversationData>(); + 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):Conversation + { + return ScriptedConversation.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array<String> + { + return ScriptedConversation.listScriptClasses(); + } +} diff --git a/source/funkin/data/dialogue/DialogueBoxData.hx b/source/funkin/data/dialogue/DialogueBoxData.hx new file mode 100644 index 000000000..a75a5595a --- /dev/null +++ b/source/funkin/data/dialogue/DialogueBoxData.hx @@ -0,0 +1,128 @@ +package funkin.data.dialogue; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data for a conversation text box. + * It includes things like the sprite to use, and the font and color for the text. + * The actual text is included in the ConversationData. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef DialogueBoxData = +{ + /** + * Semantic version for dialogue box data. + */ + public var version:String; + + /** + * A human readable name for the dialogue box type. + */ + public var name:String; + + /** + * The asset path for the sprite to use for the dialogue box. + * Takes a static sprite or a sprite sheet. + */ + public var assetPath:String; + + /** + * Whether to horizontally flip the dialogue box sprite. + */ + @:optional + @:default(false) + public var flipX:Bool; + + /** + * Whether to vertically flip the dialogue box sprite. + */ + @:optional + @:default(false) + public var flipY:Bool; + + /** + * Whether to disable anti-aliasing for the dialogue box sprite. + */ + @:optional + @:default(false) + public var isPixel:Bool; + + /** + * The relative horizontal and vertical offsets for the dialogue box sprite. + */ + @:optional + @:default([0, 0]) + public var offsets:Array<Float>; + + /** + * Info about how to display text in the dialogue box. + */ + public var text:DialogueBoxTextData; + + /** + * Multiply the size of the dialogue box sprite. + */ + @:optional + @:default(1) + public var scale:Float; + + /** + * If using a spritesheet for the dialogue box, the animations to use. + */ + @:optional + @:default([]) + public var animations:Array<AnimationData>; +} + +typedef DialogueBoxTextData = +{ + /** + * The position of the text in teh box. + */ + @:optional + @:default([0, 0]) + var offsets:Array<Float>; + + /** + * The width of the + */ + @:optional + @:default(300) + var width:Int; + + /** + * The font size to use for the text. + */ + @:optional + @:default(32) + var size:Int; + + /** + * The color to use for the text. + * Use a string that can be translated to a color, like `#FF0000` for red. + */ + @:optional + @:default("#000000") + var color:String; + + /** + * The font to use for the text. + * @since v1.1.0 + * @default `Arial`, make sure to switch this! + */ + @:optional + @:default("Arial") + var fontFamily:String; + + /** + * The color to use for the shadow of the text. Use transparent to disable. + */ + var shadowColor:String; + + /** + * The width of the shadow of the text. + */ + @:optional + @:default(0) + var shadowWidth:Int; +}; diff --git a/source/funkin/data/dialogue/DialogueBoxRegistry.hx b/source/funkin/data/dialogue/DialogueBoxRegistry.hx new file mode 100644 index 000000000..87205d96c --- /dev/null +++ b/source/funkin/data/dialogue/DialogueBoxRegistry.hx @@ -0,0 +1,81 @@ +package funkin.data.dialogue; + +import funkin.play.cutscene.dialogue.DialogueBox; +import funkin.data.dialogue.DialogueBoxData; +import funkin.play.cutscene.dialogue.ScriptedDialogueBox; + +class DialogueBoxRegistry extends BaseRegistry<DialogueBox, DialogueBoxData> +{ + /** + * The current version string for the dialogue box data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateDialogueBoxData()` function. + */ + public static final DIALOGUEBOX_DATA_VERSION:thx.semver.Version = "1.1.0"; + + public static final DIALOGUEBOX_DATA_VERSION_RULE:thx.semver.VersionRule = "1.1.x"; + + public static final instance:DialogueBoxRegistry = new DialogueBoxRegistry(); + + public function new() + { + super('DIALOGUEBOX', 'dialogue/boxes', DIALOGUEBOX_DATA_VERSION_RULE); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null<DialogueBoxData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<DialogueBoxData>(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<DialogueBoxData> + { + var parser = new json2object.JsonParser<DialogueBoxData>(); + 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):DialogueBox + { + return ScriptedDialogueBox.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array<String> + { + return ScriptedDialogueBox.listScriptClasses(); + } +} diff --git a/source/funkin/data/dialogue/SpeakerData.hx b/source/funkin/data/dialogue/SpeakerData.hx new file mode 100644 index 000000000..e8a2eacf0 --- /dev/null +++ b/source/funkin/data/dialogue/SpeakerData.hx @@ -0,0 +1,68 @@ +package funkin.data.dialogue; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for a specific speaker in a conversation. + * It includes things like what sprite to use and its available animations. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef SpeakerData = +{ + /** + * Semantic version of the speaker data. + */ + public var version:String; + + /** + * A human-readable name for the speaker. + */ + public var name:String; + + /** + * The path to the asset to use for the speaker's sprite. + */ + public var assetPath:String; + + /** + * Whether the sprite should be flipped horizontally. + */ + @:optional + @:default(false) + public var flipX:Bool; + + /** + * Whether the sprite should be flipped vertically. + */ + @:optional + @:default(false) + public var flipY:Bool; + + /** + * Whether to disable anti-aliasing for the dialogue box sprite. + */ + @:optional + @:default(false) + public var isPixel:Bool; + + /** + * The offsets to apply to the sprite's position. + */ + @:optional + @:default([0, 0]) + public var offsets:Array<Float>; + + /** + * The scale to apply to the sprite. + */ + @:optional + @:default(1.0) + public var scale:Float; + + /** + * The available animations for the speaker. + */ + @:optional + @:default([]) + public var animations:Array<AnimationData>; +} diff --git a/source/funkin/data/dialogue/SpeakerRegistry.hx b/source/funkin/data/dialogue/SpeakerRegistry.hx new file mode 100644 index 000000000..6bd301dd7 --- /dev/null +++ b/source/funkin/data/dialogue/SpeakerRegistry.hx @@ -0,0 +1,81 @@ +package funkin.data.dialogue; + +import funkin.play.cutscene.dialogue.Speaker; +import funkin.data.dialogue.SpeakerData; +import funkin.play.cutscene.dialogue.ScriptedSpeaker; + +class SpeakerRegistry extends BaseRegistry<Speaker, SpeakerData> +{ + /** + * The current version string for the speaker data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateSpeakerData()` function. + */ + public static final SPEAKER_DATA_VERSION:thx.semver.Version = "1.0.0"; + + public static final SPEAKER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + public static final instance:SpeakerRegistry = new SpeakerRegistry(); + + public function new() + { + super('SPEAKER', 'dialogue/speakers', SPEAKER_DATA_VERSION_RULE); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null<SpeakerData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<SpeakerData>(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<SpeakerData> + { + var parser = new json2object.JsonParser<SpeakerData>(); + 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):Speaker + { + return ScriptedSpeaker.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array<String> + { + return ScriptedSpeaker.listScriptClasses(); + } +} diff --git a/source/funkin/data/dialogue/TODO.md b/source/funkin/data/dialogue/TODO.md new file mode 100644 index 000000000..e69de29bb diff --git a/source/funkin/data/event/SongEventData.hx b/source/funkin/data/event/SongEventRegistry.hx similarity index 71% rename from source/funkin/data/event/SongEventData.hx rename to source/funkin/data/event/SongEventRegistry.hx index 7a167b031..dc5589813 100644 --- a/source/funkin/data/event/SongEventData.hx +++ b/source/funkin/data/event/SongEventRegistry.hx @@ -1,7 +1,7 @@ package funkin.data.event; import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventSchema; import funkin.data.song.SongData.SongEventData; import funkin.util.macro.ClassMacro; import funkin.play.event.ScriptedSongEvent; @@ -9,7 +9,7 @@ import funkin.play.event.ScriptedSongEvent; /** * This class statically handles the parsing of internal and scripted song event handlers. */ -class SongEventParser +class SongEventRegistry { /** * Every built-in event class must be added to this list. @@ -160,84 +160,3 @@ class SongEventParser } } } - -enum abstract SongEventFieldType(String) from String to String -{ - /** - * The STRING type will display as a text field. - */ - var STRING = "string"; - - /** - * The INTEGER type will display as a text field that only accepts numbers. - */ - var INTEGER = "integer"; - - /** - * The FLOAT type will display as a text field that only accepts numbers. - */ - var FLOAT = "float"; - - /** - * The BOOL type will display as a checkbox. - */ - var BOOL = "bool"; - - /** - * The ENUM type will display as a dropdown. - * Make sure to specify the `keys` field in the schema. - */ - var ENUM = "enum"; -} - -typedef SongEventSchemaField = -{ - /** - * The name of the property as it should be saved in the event data. - */ - name:String, - - /** - * The title of the field to display in the UI. - */ - title:String, - - /** - * The type of the field. - */ - type:SongEventFieldType, - - /** - * Used only for ENUM values. - * The key is the display name and the value is the actual value. - */ - ?keys:Map<String, Dynamic>, - - /** - * Used for INTEGER and FLOAT values. - * The minimum value that can be entered. - * @default No minimum - */ - ?min:Float, - - /** - * Used for INTEGER and FLOAT values. - * The maximum value that can be entered. - * @default No maximum - */ - ?max:Float, - - /** - * Used for INTEGER and FLOAT values. - * The step value that will be used when incrementing/decrementing the value. - * @default `0.1` - */ - ?step:Float, - - /** - * An optional default value for the field. - */ - ?defaultValue:Dynamic, -} - -typedef SongEventSchema = Array<SongEventSchemaField>; diff --git a/source/funkin/data/event/SongEventSchema.hx b/source/funkin/data/event/SongEventSchema.hx new file mode 100644 index 000000000..9591e601e --- /dev/null +++ b/source/funkin/data/event/SongEventSchema.hx @@ -0,0 +1,177 @@ +package funkin.data.event; + +import funkin.play.event.SongEvent; +import funkin.data.event.SongEventSchema; +import funkin.data.song.SongData.SongEventData; +import funkin.util.macro.ClassMacro; +import funkin.play.event.ScriptedSongEvent; + +@:forward(name, title, type, keys, min, max, step, units, defaultValue, iterator) +abstract SongEventSchema(SongEventSchemaRaw) +{ + /** + * These units look better when placed immediately next to the value, rather than after a space. + */ + static final NO_SPACE_UNITS:Array<String> = ['x', '°', '%']; + + public function new(?fields:Array<SongEventSchemaField>) + { + this = fields; + } + + @:arrayAccess + public function getByName(name:String):SongEventSchemaField + { + for (field in this) + { + if (field.name == name) return field; + } + + return null; + } + + public function getFirstField():SongEventSchemaField + { + return this[0]; + } + + @:arrayAccess + public inline function get(key:Int) + { + return this[key]; + } + + @:arrayAccess + public inline function arrayWrite(k:Int, v:SongEventSchemaField):SongEventSchemaField + { + return this[k] = v; + } + + public function stringifyFieldValue(name:String, value:Dynamic, addUnits:Bool = true):String + { + var field:SongEventSchemaField = getByName(name); + if (field == null) return 'Unknown'; + + switch (field.type) + { + case SongEventFieldType.STRING: + return Std.string(value); + case SongEventFieldType.INTEGER: + var returnValue:String = Std.string(value); + if (addUnits) return addUnitsToString(returnValue, field); + return returnValue; + case SongEventFieldType.FLOAT: + var returnValue:String = Std.string(value); + if (addUnits) return addUnitsToString(returnValue, field); + return returnValue; + case SongEventFieldType.BOOL: + return Std.string(value); + case SongEventFieldType.ENUM: + var valueString:String = Std.string(value); + for (key in field.keys.keys()) + { + // Comparing these values as strings because comparing Dynamic variables is jank. + if (Std.string(field.keys.get(key)) == valueString) return key; + } + return valueString; + default: + return 'Unknown'; + } + } + + function addUnitsToString(value:String, field:SongEventSchemaField) + { + if (field.units == null || field.units == '') return value; + + var unit:String = field.units; + + return value + (NO_SPACE_UNITS.contains(unit) ? '' : ' ') + '${unit}'; + } +} + +typedef SongEventSchemaRaw = Array<SongEventSchemaField>; + +typedef SongEventSchemaField = +{ + /** + * The name of the property as it should be saved in the event data. + */ + name:String, + + /** + * The title of the field to display in the UI. + */ + title:String, + + /** + * The type of the field. + */ + type:SongEventFieldType, + + /** + * Used only for ENUM values. + * The key is the display name and the value is the actual value. + */ + ?keys:Map<String, Dynamic>, + + /** + * Used for INTEGER and FLOAT values. + * The minimum value that can be entered. + * @default No minimum + */ + ?min:Float, + + /** + * Used for INTEGER and FLOAT values. + * The maximum value that can be entered. + * @default No maximum + */ + ?max:Float, + + /** + * Used for INTEGER and FLOAT values. + * The step value that will be used when incrementing/decrementing the value. + * @default `0.1` + */ + ?step:Float, + + /** + * Used for INTEGER and FLOAT values. + * The units that the value is expressed in (pixels, percent, etc). + */ + ?units:String, + + /** + * An optional default value for the field. + */ + ?defaultValue:Dynamic, +} + +enum abstract SongEventFieldType(String) from String to String +{ + /** + * The STRING type will display as a text field. + */ + var STRING = "string"; + + /** + * The INTEGER type will display as a text field that only accepts numbers. + */ + var INTEGER = "integer"; + + /** + * The FLOAT type will display as a text field that only accepts numbers. + */ + var FLOAT = "float"; + + /** + * The BOOL type will display as a checkbox. + */ + var BOOL = "bool"; + + /** + * The ENUM type will display as a dropdown. + * Make sure to specify the `keys` field in the schema. + */ + var ENUM = "enum"; +} diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index 75b0b11f6..96712cba5 100644 --- a/source/funkin/data/level/LevelRegistry.hx +++ b/source/funkin/data/level/LevelRegistry.hx @@ -7,9 +7,9 @@ import funkin.ui.story.ScriptedLevel; class LevelRegistry extends BaseRegistry<Level, LevelData> { /** - * The current version string for the stage data format. + * The current version string for the level data format. * Handle breaking changes by incrementing this value - * and adding migration to the `migrateStageData()` function. + * and adding migration to the `migrateLevelData()` function. */ public static final LEVEL_DATA_VERSION:thx.semver.Version = "1.0.0"; @@ -30,6 +30,7 @@ class LevelRegistry extends BaseRegistry<Level, LevelData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<LevelData>(); + parser.ignoreUnknownVariables = false; switch (loadEntryFile(id)) { @@ -57,6 +58,7 @@ class LevelRegistry extends BaseRegistry<Level, LevelData> public function parseEntryDataRaw(contents:String, ?fileName:String):Null<LevelData> { var parser = new json2object.JsonParser<LevelData>(); + parser.ignoreUnknownVariables = false; parser.fromJson(contents, fileName); if (parser.errors.length > 0) diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx index 4255a644b..ffb9bf490 100644 --- a/source/funkin/data/notestyle/NoteStyleRegistry.hx +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -35,6 +35,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<NoteStyleData>(); + parser.ignoreUnknownVariables = false; switch (loadEntryFile(id)) { @@ -62,6 +63,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData> public function parseEntryDataRaw(contents:String, ?fileName:String):Null<NoteStyleData> { var parser = new json2object.JsonParser<NoteStyleData>(); + parser.ignoreUnknownVariables = false; parser.fromJson(contents, fileName); if (parser.errors.length > 0) diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index 7886ada4f..7d5bc4e19 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -1,7 +1,11 @@ package funkin.data.song; +import funkin.data.event.SongEventRegistry; +import funkin.play.event.SongEvent; +import funkin.data.event.SongEventSchema; import funkin.data.song.SongRegistry; import thx.semver.Version; +import funkin.util.tools.ICloneable; /** * Data containing information about a song. @@ -9,7 +13,7 @@ import thx.semver.Version; * Data which is only necessary in-game should be stored in the SongChartData. */ @:nullSafety -class SongMetadata +class SongMetadata implements ICloneable<SongMetadata> { /** * A semantic versioning string for the song data format. @@ -35,10 +39,11 @@ class SongMetadata public var looped:Bool; /** - * Instrumental and vocal offsets. Optional, defaults to 0. + * Instrumental and vocal offsets. + * Defaults to an empty SongOffsets object. */ @:optional - public var offsets:SongOffsets; + public var offsets:Null<SongOffsets>; /** * Data relating to the song's gameplay. @@ -84,16 +89,16 @@ class SongMetadata * @param newVariation Set to a new variation ID to change the new metadata. * @return The cloned SongMetadata */ - public function clone(?newVariation:String = null):SongMetadata + public function clone():SongMetadata { - var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + var result:SongMetadata = new SongMetadata(this.songName, this.artist, this.variation); result.version = this.version; result.timeFormat = this.timeFormat; result.divisions = this.divisions; - result.offsets = this.offsets; - result.timeChanges = this.timeChanges; + result.offsets = this.offsets != null ? this.offsets.clone() : new SongOffsets(); // if no song offsets found (aka null), so just create new ones + result.timeChanges = this.timeChanges.deepClone(); result.looped = this.looped; - result.playData = this.playData; + result.playData = this.playData.clone(); result.generatedBy = this.generatedBy; return result; @@ -128,7 +133,7 @@ enum abstract SongTimeFormat(String) from String to String var MILLISECONDS = 'ms'; } -class SongTimeChange +class SongTimeChange implements ICloneable<SongTimeChange> { public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); @@ -149,7 +154,7 @@ class SongTimeChange */ @:optional @:alias("b") - public var beatTime:Null<Float>; + public var beatTime:Float; /** * Quarter notes per minute (float). Cannot be empty in the first element of the list, @@ -195,6 +200,11 @@ class SongTimeChange this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; } + public function clone():SongTimeChange + { + return new SongTimeChange(this.timeStamp, this.bpm, this.timeSignatureNum, this.timeSignatureDen, this.beatTime, this.beatTuplets); + } + /** * Produces a string representation suitable for debugging. */ @@ -209,7 +219,7 @@ class SongTimeChange * These are intended to correct for issues with the chart, or with the song's audio (for example a 10ms delay before the song starts). * This is independent of the offsets applied in the user's settings, which are applied after these offsets and intended to correct for the user's hardware. */ -class SongOffsets +class SongOffsets implements ICloneable<SongOffsets> { /** * The offset, in milliseconds, to apply to the song's instrumental relative to the chart. @@ -279,6 +289,15 @@ class SongOffsets return value; } + public function clone():SongOffsets + { + var result:SongOffsets = new SongOffsets(this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + result.vocals = this.vocals.clone(); + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -292,7 +311,7 @@ class SongOffsets * Metadata for a song only used for the music. * For example, the menu music. */ -class SongMusicData +class SongMusicData implements ICloneable<SongMusicData> { /** * A semantic versioning string for the song data format. @@ -346,13 +365,13 @@ class SongMusicData this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation; } - public function clone(?newVariation:String = null):SongMusicData + public function clone():SongMusicData { - var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + var result:SongMusicData = new SongMusicData(this.songName, this.artist, this.variation); result.version = this.version; result.timeFormat = this.timeFormat; result.divisions = this.divisions; - result.timeChanges = this.timeChanges; + result.timeChanges = this.timeChanges.clone(); result.looped = this.looped; result.generatedBy = this.generatedBy; @@ -368,7 +387,7 @@ class SongMusicData } } -class SongPlayData +class SongPlayData implements ICloneable<SongPlayData> { /** * The variations this song has. The associated metadata files should exist. @@ -412,11 +431,46 @@ class SongPlayData @:optional public var album:Null<String>; + /** + * The start time for the audio preview in Freeplay. + * Defaults to 0 seconds in. + * @since `2.2.2` + */ + @:optional + @:default(0) + public var previewStart:Int; + + /** + * The end time for the audio preview in Freeplay. + * Defaults to 15 seconds in. + * @since `2.2.2` + */ + @:optional + @:default(15000) + public var previewEnd:Int; + public function new() { ratings = new Map<String, Int>(); } + public function clone():SongPlayData + { + // TODO: This sucks! If you forget to update this you get weird behavior. + var result:SongPlayData = new SongPlayData(); + result.songVariations = this.songVariations.clone(); + result.difficulties = this.difficulties.clone(); + result.characters = this.characters.clone(); + result.stage = this.stage; + result.noteStyle = this.noteStyle; + result.ratings = this.ratings.clone(); + result.album = this.album; + result.previewStart = this.previewStart; + result.previewEnd = this.previewEnd; + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -430,7 +484,7 @@ class SongPlayData * Information about the characters used in this variation of the song. * Create a new variation if you want to change the characters. */ -class SongCharacterData +class SongCharacterData implements ICloneable<SongCharacterData> { @:optional @:default('') @@ -460,6 +514,14 @@ class SongCharacterData this.instrumental = instrumental; } + public function clone():SongCharacterData + { + var result:SongCharacterData = new SongCharacterData(this.player, this.girlfriend, this.opponent, this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -469,7 +531,7 @@ class SongCharacterData } } -class SongChartData +class SongChartData implements ICloneable<SongChartData> { @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) @:jcustomparse(funkin.data.DataParse.semverVersion) @@ -539,6 +601,24 @@ class SongChartData return writer.write(this, pretty ? ' ' : null); } + public function clone():SongChartData + { + // We have to manually perform the deep clone here because Map.deepClone() doesn't work. + var noteDataClone:Map<String, Array<SongNoteData>> = new Map<String, Array<SongNoteData>>(); + for (key in this.notes.keys()) + { + noteDataClone.set(key, this.notes.get(key).deepClone()); + } + var eventDataClone:Array<SongEventData> = this.events.deepClone(); + + var result:SongChartData = new SongChartData(this.scrollSpeed.clone(), eventDataClone, noteDataClone); + result.version = this.version; + result.generatedBy = this.generatedBy; + result.variation = this.variation; + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -548,7 +628,7 @@ class SongChartData } } -class SongEventDataRaw +class SongEventDataRaw implements ICloneable<SongEventDataRaw> { /** * The timestamp of the event. The timestamp is in the format of the song's time format. @@ -602,14 +682,19 @@ class SongEventDataRaw { if (_stepTime != null && !force) return _stepTime; - return _stepTime = Conductor.getTimeInSteps(this.time); + return _stepTime = Conductor.instance.getTimeInSteps(this.time); + } + + public function clone():SongEventDataRaw + { + return new SongEventDataRaw(this.time, this.event, this.value); } } /** * Wrap SongEventData in an abstract so we can overload operators. */ -@:forward(time, event, value, activated, getStepTime) +@:forward(time, event, value, activated, getStepTime, clone) abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw { public function new(time:Float, event:String, value:Dynamic = null) @@ -617,6 +702,38 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR this = new SongEventDataRaw(time, event, value); } + public inline function valueAsStruct(?defaultKey:String = "key"):Dynamic + { + if (this.value == null) return {}; + if (Std.isOfType(this.value, Array)) + { + var result:haxe.DynamicAccess<Dynamic> = {}; + result.set(defaultKey, this.value); + return cast result; + } + else if (Reflect.isObject(this.value)) + { + // We enter this case if the value is a struct. + return cast this.value; + } + else + { + var result:haxe.DynamicAccess<Dynamic> = {}; + result.set(defaultKey, this.value); + return cast result; + } + } + + public inline function getHandler():Null<SongEvent> + { + return SongEventRegistry.getEvent(this.event); + } + + public inline function getSchema():Null<SongEventSchema> + { + return SongEventRegistry.getEventSchema(this.event); + } + public inline function getDynamic(key:String):Null<Dynamic> { return this.value == null ? null : Reflect.field(this.value, key); @@ -662,6 +779,34 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR return this.value == null ? null : cast Reflect.field(this.value, key); } + public function buildTooltip():String + { + var eventHandler = getHandler(); + var eventSchema = getSchema(); + + if (eventSchema == null) return 'Unknown Event: ${this.event}'; + + var result = '${eventHandler.getTitle()}'; + + var defaultKey = eventSchema.getFirstField()?.name; + var valueStruct:haxe.DynamicAccess<Dynamic> = valueAsStruct(defaultKey); + + for (pair in valueStruct.keyValueIterator()) + { + var key = pair.key; + var value = pair.value; + + var title = eventSchema.getByName(key)?.title ?? 'UnknownField'; + + // if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value)); + var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue'; + + result += '\n- ${title}: ${valueStr}'; + } + + return result; + } + public function clone():SongEventData { return new SongEventData(this.time, this.event, this.value); @@ -712,7 +857,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR } } -class SongNoteDataRaw +class SongNoteDataRaw implements ICloneable<SongNoteDataRaw> { /** * The timestamp of the note. The timestamp is in the format of the song's time format. @@ -742,12 +887,18 @@ class SongNoteDataRaw @:alias("l") @:default(0) @:optional - public var length:Float; + public var length(default, set):Float; + + function set_length(value:Float):Float + { + _stepLength = null; + return length = value; + } /** * The kind of the note. * This can allow the note to include information used for custom behavior. - * Defaults to blank or `"normal"`. + * Defaults to blank or `Constants.DEFAULT_DIFFICULTY`. */ @:alias("k") @:default("normal") @@ -796,9 +947,14 @@ class SongNoteDataRaw { if (_stepTime != null && !force) return _stepTime; - return _stepTime = Conductor.getTimeInSteps(this.time); + return _stepTime = Conductor.instance.getTimeInSteps(this.time); } + /** + * The length of the note, if applicable, in steps. + * Calculated from the length and the BPM. + * Cached for performance. Set to `null` to recalculate. + */ @:jignored var _stepLength:Null<Float> = null; @@ -812,7 +968,7 @@ class SongNoteDataRaw if (_stepLength != null && !force) return _stepLength; - return _stepLength = Conductor.getTimeInSteps(this.time + this.length) - getStepTime(); + return _stepLength = Conductor.instance.getTimeInSteps(this.time + this.length) - getStepTime(); } public function setStepLength(value:Float):Void @@ -823,11 +979,21 @@ class SongNoteDataRaw } else { - var lengthMs:Float = Conductor.getStepTimeInMs(value) - this.time; + var endStep:Float = getStepTime() + value; + var endMs:Float = Conductor.instance.getStepTimeInMs(endStep); + var lengthMs:Float = endMs - this.time; + this.length = lengthMs; } + + // Recalculate the step length next time it's requested. _stepLength = null; } + + public function clone():SongNoteDataRaw + { + return new SongNoteDataRaw(this.time, this.data, this.length, this.kind); + } } /** @@ -891,6 +1057,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw @:op(A == B) public function op_equals(other:SongNoteData):Bool { + // Handle the case where one value is null. + if (this == null) return other == null; + if (other == null) return false; + if (this.kind == '') { if (other.kind != '' && other.kind != 'normal') return false; @@ -906,6 +1076,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw @:op(A != B) public function op_notEquals(other:SongNoteData):Bool { + // Handle the case where one value is null. + if (this == null) return other == null; + if (other == null) return false; + if (this.kind == '') { if (other.kind != '' && other.kind != 'normal') return true; @@ -921,24 +1095,32 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw @:op(A > B) public function op_greaterThan(other:SongNoteData):Bool { + if (other == null) return false; + return this.time > other.time; } @:op(A < B) public function op_lessThan(other:SongNoteData):Bool { + if (other == null) return false; + return this.time < other.time; } @:op(A >= B) public function op_greaterThanOrEquals(other:SongNoteData):Bool { + if (other == null) return false; + return this.time >= other.time; } @:op(A <= B) public function op_lessThanOrEquals(other:SongNoteData):Bool { + if (other == null) return false; + return this.time <= other.time; } diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx index 4ae4b1426..01ea2da32 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -153,8 +153,8 @@ class SongDataUtils public static function buildNoteClipboard(notes:Array<SongNoteData>, ?timeOffset:Int = null):Array<SongNoteData> { if (notes.length == 0) return notes; - if (timeOffset == null) timeOffset = -Std.int(notes[0].time); - return offsetSongNoteData(sortNotes(notes), timeOffset); + if (timeOffset == null) timeOffset = Std.int(notes[0].time); + return offsetSongNoteData(sortNotes(notes), -timeOffset); } /** @@ -165,8 +165,8 @@ class SongDataUtils public static function buildEventClipboard(events:Array<SongEventData>, ?timeOffset:Int = null):Array<SongEventData> { if (events.length == 0) return events; - if (timeOffset == null) timeOffset = -Std.int(events[0].time); - return offsetSongEventData(sortEvents(events), timeOffset); + if (timeOffset == null) timeOffset = Std.int(events[0].time); + return offsetSongEventData(sortEvents(events), -timeOffset); } /** @@ -230,6 +230,7 @@ class SongDataUtils trace('Read ${notesString.length} characters from clipboard.'); var parser = new json2object.JsonParser<SongClipboardItems>(); + parser.ignoreUnknownVariables = false; parser.fromJson(notesString, 'clipboard'); if (parser.errors.length > 0) { @@ -272,7 +273,7 @@ class SongDataUtils } /** - * Filter a list of notes to only include notes whose data is within the given range. + * Filter a list of notes to only include notes whose data is within the given range, inclusive. */ public static function getNotesInDataRange(notes:Array<SongNoteData>, start:Int, end:Int):Array<SongNoteData> { diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index 850654eb7..dad287e82 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -12,6 +12,7 @@ import funkin.util.VersionUtil; using funkin.data.song.migrator.SongDataMigrator; +@:nullSafety class SongRegistry extends BaseRegistry<Song, SongMetadata> { /** @@ -19,7 +20,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.1"; + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2"; public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x"; @@ -31,7 +32,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; - public static var DEFAULT_GENERATEDBY(get, null):String; + public static var DEFAULT_GENERATEDBY(get, never):String; static function get_DEFAULT_GENERATEDBY():String { @@ -57,7 +58,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> // SCRIPTED ENTRIES // var scriptedEntryClassNames:Array<String> = getScriptedClassNames(); - log('Registering ${scriptedEntryClassNames.length} scripted entries...'); + log('Parsing ${scriptedEntryClassNames.length} scripted entries...'); for (entryCls in scriptedEntryClassNames) { @@ -83,12 +84,12 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool { return !entries.exists(entryId); }); - log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...'); + log('Parsing ${unscriptedEntryIds.length} unscripted entries...'); for (entryId in unscriptedEntryIds) { try { - var entry:Song = createEntry(entryId); + var entry:Null<Song> = createEntry(entryId); if (entry != null) { trace(' Loaded entry data: ${entry}'); @@ -126,6 +127,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMetadata>(); + parser.ignoreUnknownVariables = true; + switch (loadEntryMetadataFile(id, variation)) { case {fileName: fileName, contents: contents}: @@ -147,6 +150,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMetadata>(); + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -206,6 +210,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMetadata_v2_1_0>(); + parser.ignoreUnknownVariables = true; + switch (loadEntryMetadataFile(id, variation)) { case {fileName: fileName, contents: contents}: @@ -226,6 +232,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMetadata_v2_0_0>(); + parser.ignoreUnknownVariables = true; + switch (loadEntryMetadataFile(id, variation)) { case {fileName: fileName, contents: contents}: @@ -244,6 +252,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata> { var parser = new json2object.JsonParser<SongMetadata_v2_1_0>(); + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -257,6 +266,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata> { var parser = new json2object.JsonParser<SongMetadata_v2_0_0>(); + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -272,6 +282,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongMusicData>(); + parser.ignoreUnknownVariables = false; + switch (loadMusicDataFile(id, variation)) { case {fileName: fileName, contents: contents}: @@ -291,6 +303,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMusicData> { var parser = new json2object.JsonParser<SongMusicData>(); + parser.ignoreUnknownVariables = false; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -334,6 +347,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongChartData>(); + parser.ignoreUnknownVariables = true; switch (loadEntryChartFile(id, variation)) { @@ -356,6 +370,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser<SongChartData>(); + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -441,7 +456,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents; - var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + var entryVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } @@ -449,7 +464,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata> { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents; - var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + var entryVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } diff --git a/source/funkin/data/song/importer/ChartManifestData.hx b/source/funkin/data/song/importer/ChartManifestData.hx index 0c7d2f0b0..dd0d28479 100644 --- a/source/funkin/data/song/importer/ChartManifestData.hx +++ b/source/funkin/data/song/importer/ChartManifestData.hx @@ -68,6 +68,7 @@ class ChartManifestData public static function deserialize(contents:String):Null<ChartManifestData> { var parser = new json2object.JsonParser<ChartManifestData>(); + parser.ignoreUnknownVariables = false; parser.fromJson(contents, 'manifest.json'); if (parser.errors.length > 0) diff --git a/source/funkin/data/song/importer/FNFLegacyData.hx b/source/funkin/data/song/importer/FNFLegacyData.hx index 5b75368c9..52380d344 100644 --- a/source/funkin/data/song/importer/FNFLegacyData.hx +++ b/source/funkin/data/song/importer/FNFLegacyData.hx @@ -19,7 +19,8 @@ class LegacySongData @:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds) public var speed:Either<Float, LegacyScrollSpeeds>; - public var stageDefault:String; + @:optional + public var stageDefault:Null<String>; public var bpm:Float; @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData) diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx index ee68513dc..ab2abda8e 100644 --- a/source/funkin/data/song/importer/FNFLegacyImporter.hx +++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx @@ -14,6 +14,7 @@ class FNFLegacyImporter public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData { var parser = new json2object.JsonParser<FNFLegacyData>(); + parser.ignoreUnknownVariables = true; // Set to true to ignore extra variables that might be included in the JSON. parser.fromJson(input, fileName); if (parser.errors.length > 0) @@ -185,15 +186,34 @@ class FNFLegacyImporter return result; } + static final STRUMLINE_SIZE = 4; + static function migrateNoteSections(input:Array<LegacyNoteSection>):Array<SongNoteData> { var result:Array<SongNoteData> = []; for (section in input) { + var mustHitSection = section.mustHitSection ?? false; for (note in section.sectionNotes) { - result.push(new SongNoteData(note.time, note.data, note.length, note.getKind())); + // Handle the dumb logic for mustHitSection. + var noteData = note.data; + + // Flip notes if mustHitSection is FALSE (not true lol). + if (!mustHitSection) + { + if (noteData >= STRUMLINE_SIZE) + { + noteData -= STRUMLINE_SIZE; + } + else + { + noteData += STRUMLINE_SIZE; + } + } + + result.push(new SongNoteData(note.time, noteData, note.length, note.getKind())); } } diff --git a/source/funkin/data/speaker/TODO.md b/source/funkin/data/speaker/TODO.md new file mode 100644 index 000000000..e69de29bb diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx new file mode 100644 index 000000000..cb914007f --- /dev/null +++ b/source/funkin/data/stage/StageData.hx @@ -0,0 +1,199 @@ +package funkin.data.stage; + +import funkin.data.animation.AnimationData; + +@:nullSafety +class StageData +{ + /** + * The sematic version number of the stage data JSON format. + * Supports fancy comparisons like NPM does it's neat. + */ + @:default(funkin.data.stage.StageRegistry.STAGE_DATA_VERSION) + public var version:String; + + public var name:String = 'Unknown'; + public var props:Array<StageDataProp> = []; + public var characters:StageDataCharacters; + + @:default(1.0) + @:optional + public var cameraZoom:Null<Float>; + + public function new() + { + this.version = StageRegistry.STAGE_DATA_VERSION; + this.characters = makeDefaultCharacters(); + } + + function makeDefaultCharacters():StageDataCharacters + { + return { + bf: + { + zIndex: 0, + position: [0, 0], + cameraOffsets: [-100, -100] + }, + dad: + { + zIndex: 0, + position: [0, 0], + cameraOffsets: [100, -100] + }, + gf: + { + zIndex: 0, + position: [0, 0], + cameraOffsets: [0, 0] + } + }; + } + + /** + * Convert this StageData into a JSON string. + */ + public function serialize(pretty:Bool = true):String + { + var writer = new json2object.JsonWriter<StageData>(); + return writer.write(this, pretty ? ' ' : null); + } +} + +typedef StageDataCharacters = +{ + var bf:StageDataCharacter; + var dad:StageDataCharacter; + var gf:StageDataCharacter; +}; + +typedef StageDataProp = +{ + /** + * The name of the prop for later lookup by scripts. + * Optional; if unspecified, the prop can't be referenced by scripts. + */ + @:optional + var name:String; + + /** + * The asset used to display the prop. + * NOTE: As of Stage data v1.0.1, you can also use a color here to create a rectangle, like "#ff0000". + * In this case, the `scale` property will be used to determine the size of the prop. + */ + var assetPath:String; + + /** + * The position of the prop as an [x, y] array of two floats. + */ + var position:Array<Float>; + + /** + * A number determining the stack order of the prop, relative to other props and the characters in the stage. + * Props with lower numbers render below those with higher numbers. + * This is just like CSS, it isn't hard. + * @default 0 + */ + @:optional + @:default(0) + var zIndex:Int; + + /** + * If set to true, anti-aliasing will be forcibly disabled on the sprite. + * This prevents blurry images on pixel-art levels. + * @default false + */ + @:optional + @:default(false) + var isPixel:Bool; + + /** + * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats. + * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory. + */ + @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats) + @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats) + @:optional + var scale:haxe.ds.Either<Float, Array<Float>>; + + /** + * The alpha of the prop, as a float. + * @default 1.0 + */ + @:optional + @:default(1.0) + var alpha:Float; + + /** + * If not zero, this prop will play an animation every X beats of the song. + * This requires animations to be defined. If `danceLeft` and `danceRight` are defined, + * they will alternated between, otherwise the `idle` animation will be used. + * + * @default 0 + */ + @:default(0) + @:optional + var danceEvery:Int; + + /** + * How much the prop scrolls relative to the camera. Used to create a parallax effect. + * Represented as an [x, y] array of two floats. + * [1, 1] means the prop moves 1:1 with the camera. + * [0.5, 0.5] means the prop half as much as the camera. + * [0, 0] means the prop is not moved. + * @default [0, 0] + */ + @:optional + @:default([0, 0]) + var scroll:Array<Float>; + + /** + * An optional array of animations which the prop can play. + * @default Prop has no animations. + */ + @:optional + @:default([]) + var animations:Array<AnimationData>; + + /** + * If animations are used, this is the name of the animation to play first. + * @default Don't play an animation. + */ + @:optional + var startingAnimation:Null<String>; + + /** + * The animation type to use. + * Options: "sparrow", "packer" + * @default "sparrow" + */ + @:default("sparrow") + @:optional + var animType:String; +}; + +typedef StageDataCharacter = +{ + /** + * A number determining the stack order of the character, relative to props and other characters in the stage. + * Again, just like CSS. + * @default 0 + */ + @:optional + @:default(0) + var zIndex:Int; + + /** + * The position to render the character at. + */ + @:optional + @:default([0, 0]) + var position:Array<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 + */ + @:optional + var cameraOffsets:Array<Float>; +}; diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx new file mode 100644 index 000000000..b78292e5b --- /dev/null +++ b/source/funkin/data/stage/StageRegistry.hx @@ -0,0 +1,103 @@ +package funkin.data.stage; + +import funkin.data.stage.StageData; +import funkin.play.stage.Stage; +import funkin.play.stage.ScriptedStage; + +class StageRegistry extends BaseRegistry<Stage, StageData> +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final STAGE_DATA_VERSION:thx.semver.Version = "1.0.1"; + + public static final STAGE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + public static final instance:StageRegistry = new StageRegistry(); + + public function new() + { + super('STAGE', 'stages', STAGE_DATA_VERSION_RULE); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null<StageData> + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser<StageData>(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null<StageData> + { + var parser = new json2object.JsonParser<StageData>(); + 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):Stage + { + return ScriptedStage.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array<String> + { + return ScriptedStage.listScriptClasses(); + } + + /** + * A list of all the stages from the base game, in order. + * TODO: Should this be hardcoded? + */ + public function listBaseGameStageIds():Array<String> + { + return [ + "mainStage", "spookyMansion", "phillyTrain", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield", "phillyStreets", + "phillyBlazin", + ]; + } + + /** + * A list of all installed story weeks that are not from the base game. + */ + public function listModdedStageIds():Array<String> + { + return listEntryIds().filter(function(id:String):Bool { + return listBaseGameStageIds().indexOf(id) == -1; + }); + } +} diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx new file mode 100644 index 000000000..487aaac34 --- /dev/null +++ b/source/funkin/graphics/FunkinSprite.hx @@ -0,0 +1,53 @@ +package funkin.graphics; + +import flixel.FlxSprite; +import flixel.util.FlxColor; +import flixel.graphics.FlxGraphic; + +/** + * An FlxSprite with additional functionality. + */ +class FunkinSprite extends FlxSprite +{ + /** + * @param x Starting X position + * @param y Starting Y position + */ + public function new(?x:Float = 0, ?y:Float = 0) + { + super(x, y); + } + + /** + * Acts similarly to `makeGraphic`, but with improved memory usage, + * at the expense of not being able to paint onto the 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. + */ + public function makeSolidColor(width:Int, height:Int, color:FlxColor = FlxColor.WHITE):FunkinSprite + { + var graphic:FlxGraphic = FlxG.bitmap.create(2, 2, color, false, 'solid#${color.toHexString(true, false)}'); + frames = graphic.imageFrame; + scale.set(width / 2, height / 2); + updateHitbox(); + + return this; + } + + /** + * Ensure scale is applied when cloning a sprite. + * The default `clone()` method acts kinda weird TBH. + * @return A clone of this sprite. + */ + public override function clone():FunkinSprite + { + var result = new FunkinSprite(this.x, this.y); + result.frames = this.frames; + result.scale.set(this.scale.x, this.scale.y); + result.updateHitbox(); + + return result; + } +} diff --git a/source/funkin/graphics/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx index 39402808a..a06d53337 100644 --- a/source/funkin/graphics/rendering/MeshRender.hx +++ b/source/funkin/graphics/rendering/MeshRender.hx @@ -12,22 +12,19 @@ class MeshRender extends FlxStrip public var vertex_count(default, null):Int = 0; public var index_count(default, null):Int = 0; - var tri_offset:Int = 0; - public function new(x, y, ?col:FlxColor = FlxColor.WHITE) { super(x, y); makeGraphic(1, 1, col); } - public inline function start() + /** + * Add a vertex. + */ + public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int { - tri_offset = vertex_count; - } - - public inline function add_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0) - { - final pos = vertex_count << 1; + final index = vertex_count; + final pos = index << 1; vertices[pos] = x; vertices[pos + 1] = y; @@ -36,48 +33,72 @@ class MeshRender extends FlxStrip uvtData[pos + 1] = v; vertex_count++; + return index; } - public function add_tri(a:Int, b:Int, c:Int) + /** + * Build a triangle from three vertex indexes. + * @param a + * @param b + * @param c + */ + public function add_tri(a:Int, b:Int, c:Int):Void { - indices[index_count] = a + tri_offset; - indices[index_count + 1] = b + tri_offset; - indices[index_count + 2] = c + tri_offset; + indices[index_count] = a; + indices[index_count + 1] = b; + indices[index_count + 2] = c; index_count += 3; } - /** - * - * top left - a - * - * top right - b - * - * bottom left - c - * - * bottom right - d - */ - public function add_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, - bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0) + public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0, + cv:Float = 0):Void { - start(); - // top left - add_vertex(bx, by, bu, bv); - // top right - add_vertex(ax, ay, au, av); - // bottom left - add_vertex(cx, cy, cu, cv); - // bottom right - add_vertex(dx, dy, du, dv); + add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv)); + } - add_tri(0, 1, 2); - add_tri(0, 2, 3); + /** + * @param a top left vertex + * @param b top right vertex + * @param c bottom right vertex + * @param d bottom left vertex + */ + public function add_quad(a:Int, b:Int, c:Int, d:Int):Void + { + add_tri(a, b, c); + add_tri(a, c, d); + } + + /** + * Build a quad from four points. + * + * top right - a + * top left - b + * bottom right - c + * bottom left - d + */ + public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, + bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void + { + // top left + var b = build_vertex(bx, by, bu, bv); + // top right + var a = build_vertex(ax, ay, au, av); + // bottom left + var c = build_vertex(cx, cy, cu, cv); + // bottom right + var d = build_vertex(dx, dy, du, dv); + + add_tri(a, b, c); + add_tri(a, c, d); } public function clear() { vertices.length = 0; indices.length = 0; + uvtData.length = 0; + colors.length = 0; vertex_count = 0; index_count = 0; } diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx index b4bf43808..39f399465 100644 --- a/source/funkin/input/Cursor.hx +++ b/source/funkin/input/Cursor.hx @@ -34,6 +34,18 @@ class Cursor Cursor.cursorMode = null; } + public static inline function toggle():Void + { + if (FlxG.mouse.visible) + { + hide(); + } + else + { + show(); + } + } + public static final CURSOR_DEFAULT_PARAMS:CursorParams = { graphic: "assets/images/cursor/cursor-default.png", diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 7716f0f02..f1e82aee9 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -2,20 +2,21 @@ package funkin.modding; import funkin.util.macro.ClassMacro; import funkin.modding.module.ModuleHandler; -import funkin.play.character.CharacterData.CharacterDataParser; import funkin.data.song.SongData; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; import polymod.Polymod; import polymod.backends.PolymodAssets.PolymodAssetType; import polymod.format.ParseRules.TextFileFormat; -import funkin.data.event.SongEventData.SongEventParser; +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.play.cutscene.dialogue.ConversationDataParser; -import funkin.play.cutscene.dialogue.DialogueBoxDataParser; +import funkin.data.dialogue.ConversationRegistry; +import funkin.data.dialogue.DialogueBoxRegistry; +import funkin.data.dialogue.SpeakerRegistry; +import funkin.play.character.CharacterData.CharacterDataParser; import funkin.save.Save; -import funkin.play.cutscene.dialogue.SpeakerDataParser; import funkin.data.song.SongRegistry; class PolymodHandler @@ -207,8 +208,8 @@ class PolymodHandler { return { assetLibraryPaths: [ - "default" => "preload", "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, } @@ -271,12 +272,12 @@ class PolymodHandler SongRegistry.instance.loadEntries(); LevelRegistry.instance.loadEntries(); NoteStyleRegistry.instance.loadEntries(); - SongEventParser.loadEventCache(); - ConversationDataParser.loadConversationCache(); - DialogueBoxDataParser.loadDialogueBoxCache(); - SpeakerDataParser.loadSpeakerCache(); - StageDataParser.loadStageCache(); - CharacterDataParser.loadCharacterCache(); + SongEventRegistry.loadEventCache(); + ConversationRegistry.instance.loadEntries(); + DialogueBoxRegistry.instance.loadEntries(); + SpeakerRegistry.instance.loadEntries(); + StageRegistry.instance.loadEntries(); + CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry. ModuleHandler.loadModuleCache(); } } diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index d23574ce2..5b7ce9fc2 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -40,7 +40,7 @@ class Countdown stopCountdown(); PlayState.instance.isInCountdown = true; - Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5); + Conductor.instance.update(PlayState.instance.startTimestamp + Conductor.instance.beatLengthMs * -5); // Handle onBeatHit events manually // @:privateAccess // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0)); @@ -48,7 +48,7 @@ class Countdown // The timer function gets called based on the beat of the song. countdownTimer = new FlxTimer(); - countdownTimer.start(Conductor.beatLengthMs / 1000, function(tmr:FlxTimer) { + countdownTimer.start(Conductor.instance.beatLengthMs / 1000, function(tmr:FlxTimer) { if (PlayState.instance == null) { tmr.cancel(); @@ -158,7 +158,7 @@ class Countdown { stopCountdown(); // This will trigger PlayState.startSong() - Conductor.update(0); + Conductor.instance.update(0); // PlayState.isInCountdown = false; } @@ -225,7 +225,7 @@ class Countdown countdownSprite.screenCenter(); // Fade sprite in, then out, then destroy it. - FlxTween.tween(countdownSprite, {y: countdownSprite.y += 100, alpha: 0}, Conductor.beatLengthMs / 1000, + FlxTween.tween(countdownSprite, {y: countdownSprite.y += 100, alpha: 0}, Conductor.instance.beatLengthMs / 1000, { ease: FlxEase.cubeInOut, onComplete: function(twn:FlxTween) { diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx index 6eb53e2d5..36f72237e 100644 --- a/source/funkin/play/GameOverSubState.hx +++ b/source/funkin/play/GameOverSubState.hx @@ -7,6 +7,7 @@ import flixel.sound.FlxSound; import funkin.ui.story.StoryMenuState; 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; @@ -22,6 +23,12 @@ import funkin.play.character.BaseCharacter; */ class GameOverSubState extends MusicBeatSubState { + /** + * The currently active GameOverSubState. + * There should be only one GameOverSubState in existance at a time, we can use a singleton. + */ + public static var instance:GameOverSubState = null; + /** * Which alternate animation on the character to use. * You can set this via script. @@ -64,9 +71,16 @@ class GameOverSubState extends MusicBeatSubState */ var isEnding:Bool = false; - public function new() + var isChartingMode:Bool = false; + + var transparent:Bool; + + public function new(params:GameOverParams) { super(); + + this.isChartingMode = params?.isChartingMode ?? false; + transparent = params.transparent; } /** @@ -80,6 +94,13 @@ class GameOverSubState extends MusicBeatSubState override public function create() { + if (instance != null) + { + // TODO: Do something in this case? IDK. + trace('WARNING: GameOverSubState instance already exists. This should not happen.'); + } + instance = this; + super.create(); // @@ -87,10 +108,12 @@ class GameOverSubState extends MusicBeatSubState // // Add a black background to the screen. - // We make this transparent so that we can see the stage underneath during debugging. - var bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); - bg.alpha = 0.25; + 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, + // but it's normally opaque. + bg.alpha = transparent ? 0.25 : 1.0; bg.scrollFactor.set(); + bg.screenCenter(); add(bg); // Pluck Boyfriend from the PlayState and place him (in the same position) in the GameOverSubState. @@ -121,7 +144,7 @@ class GameOverSubState extends MusicBeatSubState gameOverMusic.stop(); // The conductor now represents the BPM of the game over music. - Conductor.update(0); + Conductor.instance.update(0); } var hasStartedAnimation:Bool = false; @@ -176,16 +199,27 @@ class GameOverSubState extends MusicBeatSubState // PlayState.seenCutscene = false; // old thing... gameOverMusic.stop(); - if (PlayStatePlaylist.isStoryMode) FlxG.switchState(new StoryMenuState()); + if (isChartingMode) + { + 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! + } + else if (PlayStatePlaylist.isStoryMode) + { + FlxG.switchState(new StoryMenuState()); + } else + { FlxG.switchState(new FreeplayState()); + } } if (gameOverMusic.playing) { // Match the conductor to the music. // This enables the stepHit and beatHit events. - Conductor.update(gameOverMusic.time); + Conductor.instance.update(gameOverMusic.time); } else { @@ -201,6 +235,7 @@ class GameOverSubState extends MusicBeatSubState 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. @@ -261,15 +296,16 @@ class GameOverSubState extends MusicBeatSubState */ function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void { - var musicPath = Paths.music('gameOver' + musicSuffix); + var musicPath = Paths.music('gameplay/gameover/gameOver' + musicSuffix); if (isEnding) { - musicPath = Paths.music('gameOverEnd' + musicSuffix); + musicPath = Paths.music('gameplay/gameover/gameOverEnd' + musicSuffix); } if (!gameOverMusic.playing || force) { gameOverMusic.loadEmbedded(musicPath); gameOverMusic.volume = startingVolume; + gameOverMusic.looped = !isEnding; gameOverMusic.play(); } } @@ -283,7 +319,7 @@ class GameOverSubState extends MusicBeatSubState public static function playBlueBalledSFX() { blueballed = true; - FlxG.sound.play(Paths.sound('fnf_loss_sfx' + blueBallSuffix)); + FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); } var playingJeffQuote:Bool = false; @@ -306,4 +342,15 @@ class GameOverSubState extends MusicBeatSubState } }); } + + public override function toString():String + { + return "GameOverSubState"; + } +} + +typedef GameOverParams = +{ + var isChartingMode:Bool; + var transparent:Bool; } diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 5f53e9388..ad5272f88 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -47,9 +47,11 @@ import funkin.ui.mainmenu.MainMenuState; import funkin.modding.events.ScriptEventDispatcher; import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.cutscene.dialogue.Conversation; +import funkin.data.dialogue.ConversationRegistry; import funkin.play.cutscene.VanillaCutscenes; import funkin.play.cutscene.VideoCutscene; -import funkin.data.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventRegistry; import funkin.play.notes.NoteSprite; import funkin.play.notes.NoteDirection; import funkin.play.notes.NoteSprite; @@ -59,11 +61,11 @@ import funkin.play.notes.notestyle.NoteStyle; import funkin.play.scoring.Scoring; import funkin.play.song.Song; import funkin.data.song.SongRegistry; +import funkin.data.stage.StageRegistry; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongCharacterData; import funkin.play.stage.Stage; -import funkin.play.stage.StageData.StageDataParser; import funkin.ui.transition.LoadingState; import funkin.play.components.PopUpStuff; import funkin.ui.options.PreferencesMenu; @@ -571,15 +573,15 @@ class PlayState extends MusicBeatSubState } // Prepare the Conductor. - Conductor.forceBPM(null); + Conductor.instance.forceBPM(null); if (currentChart.offsets != null) { - Conductor.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(); + Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(); } - Conductor.mapTimeChanges(currentChart.timeChanges); - Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp); + Conductor.instance.mapTimeChanges(currentChart.timeChanges); + Conductor.instance.update((Conductor.instance.beatLengthMs * -5) + startTimestamp); // The song is now loaded. We can continue to initialize the play state. initCameras(); @@ -744,7 +746,7 @@ class PlayState extends MusicBeatSubState // Reset music properly. - FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instrumentalOffset); + FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instance.instrumentalOffset); FlxG.sound.music.pause(); if (!overrideMusic) @@ -795,22 +797,22 @@ class PlayState extends MusicBeatSubState { if (isInCountdown) { - Conductor.update(Conductor.songPosition + elapsed * 1000); - if (Conductor.songPosition >= (startTimestamp)) startSong(); + Conductor.instance.update(Conductor.instance.songPosition + elapsed * 1000); + if (Conductor.instance.songPosition >= (startTimestamp)) startSong(); } } else { if (Constants.EXT_SOUND == 'mp3') { - Conductor.formatOffset = Constants.MP3_DELAY_MS; + Conductor.instance.formatOffset = Constants.MP3_DELAY_MS; } else { - Conductor.formatOffset = 0.0; + Conductor.instance.formatOffset = 0.0; } - Conductor.update(); // Normal conductor update. + Conductor.instance.update(); // Normal conductor update. } var androidPause:Bool = false; @@ -932,7 +934,11 @@ class PlayState extends MusicBeatSubState } #end - var gameOverSubState = new GameOverSubState(); + var gameOverSubState = new GameOverSubState( + { + isChartingMode: isChartingMode, + transparent: persistentDraw + }); FlxTransitionableSubState.skipNextTransIn = true; FlxTransitionableSubState.skipNextTransOut = true; openSubState(gameOverSubState); @@ -948,7 +954,7 @@ class PlayState extends MusicBeatSubState // TODO: Check that these work even when songPosition is less than 0. if (songEvents != null && songEvents.length > 0) { - var songEventsToActivate:Array<SongEventData> = SongEventParser.queryEvents(songEvents, Conductor.songPosition); + var songEventsToActivate:Array<SongEventData> = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition); if (songEventsToActivate.length > 0) { @@ -956,7 +962,7 @@ class PlayState extends MusicBeatSubState for (event in songEventsToActivate) { // If an event is trying to play, but it's over 5 seconds old, skip it. - if (event.time - Conductor.songPosition < -5000) + if (event.time - Conductor.instance.songPosition < -5000) { event.activated = true; continue; @@ -967,7 +973,7 @@ class PlayState extends MusicBeatSubState // Calling event.cancelEvent() skips the event. Neat! if (!eventEvent.eventCanceled) { - SongEventParser.handleEvent(event); + SongEventRegistry.handleEvent(event); } } } @@ -1058,7 +1064,7 @@ class PlayState extends MusicBeatSubState if (startTimer.finished) { DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, - currentSongLengthMs - Conductor.songPosition); + currentSongLengthMs - Conductor.instance.songPosition); } else { @@ -1082,12 +1088,12 @@ class PlayState extends MusicBeatSubState { if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause) { - if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song + if (Conductor.instance.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC, true, currentSongLengthMs - - Conductor.songPosition); + - Conductor.instance.songPosition); else DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC); } @@ -1160,17 +1166,17 @@ class PlayState extends MusicBeatSubState if (!startingSong && FlxG.sound.music != null - && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset)) > 200 - || Math.abs(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset)) > 200)) + && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200 + || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200)) { trace("VOCALS NEED RESYNC"); - if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset)); - trace(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset)); + if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)); + trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)); resyncVocals(); } - if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep)); - if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep)); + if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.instance.currentStep)); + if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.instance.currentStep)); return true; } @@ -1191,14 +1197,14 @@ class PlayState extends MusicBeatSubState } // Only zoom camera if we are zoomed by less than 35%. - if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0) + if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.instance.currentBeat % cameraZoomRate == 0) { // Zoom camera in (1.5%) FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom; // Hud zooms double (3%) camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom; } - // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}'); + // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.instance.currentBeat} % ${cameraZoomRate} == ${Conductor.instance.currentBeat % cameraZoomRate}}'); // That combo milestones that got spoiled that one time. // Comes with NEAT visual and audio effects. @@ -1211,13 +1217,13 @@ class PlayState extends MusicBeatSubState // TODO: Re-enable combo text (how to do this without sections?). // if (currentSong != null) // { - // shouldShowComboText = (Conductor.currentBeat % 8 == 7); - // var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)]; + // shouldShowComboText = (Conductor.instance.currentBeat % 8 == 7); + // var daSection = .getSong()[Std.int(Conductor.instance.currentBeat / 16)]; // shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection); // shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5); // - // var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1]; - // var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16); + // var daNextSection = .getSong()[Std.int(Conductor.instance.currentBeat / 16) + 1]; + // var isEndOfSong = .getSong().length < Std.int(Conductor.instance.currentBeat / 16); // shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection)); // } @@ -1230,7 +1236,7 @@ class PlayState extends MusicBeatSubState var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation - new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) { + new FlxTimer().start(((Conductor.instance.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) { animShit.forceFinish(); }); } @@ -1267,10 +1273,10 @@ class PlayState extends MusicBeatSubState if (currentStage == null) return; // TODO: Add HEY! song events to Tutorial. - if (Conductor.currentBeat % 16 == 15 + if (Conductor.instance.currentBeat % 16 == 15 && currentStage.getDad().characterId == 'gf' - && Conductor.currentBeat > 16 - && Conductor.currentBeat < 48) + && Conductor.instance.currentBeat > 16 + && Conductor.instance.currentBeat < 48) { currentStage.getBoyfriend().playAnimation('hey', true); currentStage.getDad().playAnimation('cheer', true); @@ -1359,7 +1365,8 @@ class PlayState extends MusicBeatSubState */ function loadStage(id:String):Void { - currentStage = StageDataParser.fetchStage(id); + currentStage = StageRegistry.instance.fetchEntry(id); + currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory. if (currentStage != null) { @@ -1581,7 +1588,7 @@ class PlayState extends MusicBeatSubState trace('Song difficulty could not be loaded.'); } - // Conductor.forceBPM(currentChart.getStartingBPM()); + // Conductor.instance.forceBPM(currentChart.getStartingBPM()); if (!overrideMusic) { @@ -1613,7 +1620,7 @@ class PlayState extends MusicBeatSubState // Reset song events. songEvents = currentChart.getEvents(); - SongEventParser.resetEvents(songEvents); + SongEventRegistry.resetEvents(songEvents); // Reset the notes on each strumline. var playerNoteData:Array<SongNoteData> = []; @@ -1667,7 +1674,7 @@ class PlayState extends MusicBeatSubState { isInCutscene = true; - currentConversation = ConversationDataParser.fetchConversation(conversationId); + currentConversation = ConversationRegistry.instance.fetchEntry(conversationId); if (currentConversation == null) return; currentConversation.completeCallback = onConversationComplete; @@ -1712,7 +1719,7 @@ class PlayState extends MusicBeatSubState FlxG.sound.music.onComplete = endSong; // 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.instrumentalOffset; + FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset; trace('Playing vocals...'); add(vocals); @@ -1728,7 +1735,7 @@ class PlayState extends MusicBeatSubState if (startTimestamp > 0) { - // FlxG.sound.music.time = startTimestamp - Conductor.instrumentalOffset; + // FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset; handleSkippedNotes(); } } @@ -1802,11 +1809,12 @@ class PlayState extends MusicBeatSubState { if (note == null) continue; + // TODO: Does this properly account for offsets? var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS; var hitWindowCenter = note.strumTime; var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; - if (Conductor.songPosition > hitWindowEnd) + if (Conductor.instance.songPosition > hitWindowEnd) { if (note.hasMissed) continue; @@ -1816,7 +1824,7 @@ class PlayState extends MusicBeatSubState if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; } - else if (Conductor.songPosition > hitWindowCenter) + else if (Conductor.instance.songPosition > hitWindowCenter) { if (note.hasBeenHit) continue; @@ -1837,7 +1845,7 @@ class PlayState extends MusicBeatSubState opponentStrumline.playNoteHoldCover(note.holdNoteSprite); } } - else if (Conductor.songPosition > hitWindowStart) + else if (Conductor.instance.songPosition > hitWindowStart) { if (note.hasBeenHit || note.hasMissed) continue; @@ -1870,27 +1878,43 @@ class PlayState extends MusicBeatSubState } } - // TODO: Potential penalty for dropping a hold note? - // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; } + if (holdNote.missedNote && !holdNote.handledMiss) + { + // When the opponent drops a hold note. + holdNote.handledMiss = true; + + // We dropped a hold note. + // Mute vocals and play miss animation, but don't penalize. + vocals.opponentVolume = 0; + currentStage.getOpponent().playSingAnimation(holdNote.noteData.getDirection(), true); + } } // Process notes on the player's side. for (note in playerStrumline.notes.members) { - if (note == null || note.hasBeenHit) continue; + if (note == null) continue; + + if (note.hasBeenHit) + { + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = false; + continue; + } var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS; var hitWindowCenter = note.strumTime; var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; - if (Conductor.songPosition > hitWindowEnd) + if (Conductor.instance.songPosition > hitWindowEnd) { note.tooEarly = false; note.mayHit = false; note.hasMissed = true; if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; } - else if (Conductor.songPosition > hitWindowStart) + else if (Conductor.instance.songPosition > hitWindowStart) { note.tooEarly = false; note.mayHit = true; @@ -1940,8 +1964,15 @@ class PlayState extends MusicBeatSubState songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed); } - // TODO: Potential penalty for dropping a hold note? - // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; } + if (holdNote.missedNote && !holdNote.handledMiss) + { + // The player dropped a hold note. + holdNote.handledMiss = true; + + // Mute vocals and play miss animation, but don't penalize. + vocals.playerVolume = 0; + currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true); + } } } @@ -1957,7 +1988,7 @@ class PlayState extends MusicBeatSubState if (note == null || note.hasBeenHit) continue; var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; - if (Conductor.songPosition > hitWindowEnd) + if (Conductor.instance.songPosition > hitWindowEnd) { // We have passed this note. // Flag the note for deletion without actually penalizing the player. @@ -2033,8 +2064,6 @@ class PlayState extends MusicBeatSubState trace('Hit note! ${targetNote.noteData}'); goodNoteHit(targetNote, input); - targetNote.visible = false; - targetNote.kill(); notesInDirection.remove(targetNote); // Play the strumline animation. @@ -2066,15 +2095,8 @@ class PlayState extends MusicBeatSubState // Calling event.cancelEvent() skips all the other logic! Neat! if (event.eventCanceled) return; - Highscore.tallies.combo++; - Highscore.tallies.totalNotesHit++; - - if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; - popUpScore(note, input); - playerStrumline.hitNote(note); - if (note.isHoldNote && note.holdNoteSprite != null) { playerStrumline.playNoteHoldCover(note.holdNoteSprite); @@ -2090,8 +2112,6 @@ class PlayState extends MusicBeatSubState function onNoteMiss(note:NoteSprite):Void { // a MISS is when you let a note scroll past you!! - Highscore.tallies.missed++; - var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Highscore.tallies.combo, true); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! @@ -2121,7 +2141,7 @@ class PlayState extends MusicBeatSubState { inputSpitter.push( { - t: Std.int(Conductor.songPosition), + t: Std.int(Conductor.instance.songPosition), d: indices[i], l: 20 }); @@ -2131,7 +2151,7 @@ class PlayState extends MusicBeatSubState { inputSpitter.push( { - t: Std.int(Conductor.songPosition), + t: Std.int(Conductor.instance.songPosition), d: -1, l: 20 }); @@ -2139,8 +2159,11 @@ class PlayState extends MusicBeatSubState } vocals.playerVolume = 0; + Highscore.tallies.missed++; + if (Highscore.tallies.combo != 0) { + // Break the combo. Highscore.tallies.combo = comboPopUps.displayCombo(0); } @@ -2192,7 +2215,7 @@ class PlayState extends MusicBeatSubState { inputSpitter.push( { - t: Std.int(Conductor.songPosition), + t: Std.int(Conductor.instance.songPosition), d: indices[i], l: 20 }); @@ -2276,39 +2299,63 @@ class PlayState extends MusicBeatSubState vocals.playerVolume = 1; // Calculate the input latency (do this as late as possible). - var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0; - trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!'); + // 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.songPosition - daNote.noteData.time - inputLatencyMs); + 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. + trace('[WARNING] popUpScore judged a note as a miss!'); + // TODO: Remove this. + comboPopUps.displayRating('miss'); + return; + } + + var isComboBreak = false; switch (daRating) { - case 'killer': - Highscore.tallies.killer += 1; - health += Constants.HEALTH_KILLER_BONUS; 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; - case 'miss': - Highscore.tallies.missed += 1; - health -= Constants.HEALTH_MISS_PENALTY; + isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK; } - if (daRating == "sick" || daRating == "killer") + if (isComboBreak) + { + // Break the combo, but don't increment tallies.misses. + Highscore.tallies.combo = comboPopUps.displayCombo(0); + } + else + { + Highscore.tallies.combo++; + Highscore.tallies.totalNotesHit++; + if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; + } + + playerStrumline.hitNote(daNote, !isComboBreak); + + if (daRating == "sick") { playerStrumline.playNoteSplash(daNote.noteData.getDirection()); } @@ -2336,7 +2383,7 @@ class PlayState extends MusicBeatSubState { inputSpitter.push( { - t: Std.int(Conductor.songPosition), + t: Std.int(Conductor.instance.songPosition), d: indices[i], l: 20 }); @@ -2346,7 +2393,7 @@ class PlayState extends MusicBeatSubState { inputSpitter.push( { - t: Std.int(Conductor.songPosition), + t: Std.int(Conductor.instance.songPosition), d: -1, l: 20 }); @@ -2444,7 +2491,6 @@ class PlayState extends MusicBeatSubState score: songScore, tallies: { - killer: Highscore.tallies.killer, sick: Highscore.tallies.sick, good: Highscore.tallies.good, bad: Highscore.tallies.bad, @@ -2495,7 +2541,6 @@ class PlayState extends MusicBeatSubState tallies: { // TODO: Sum up the values for the whole level! - killer: 0, sick: 0, good: 0, bad: 0, @@ -2745,15 +2790,15 @@ class PlayState extends MusicBeatSubState { FlxG.sound.music.pause(); - var targetTimeSteps:Float = Conductor.currentStepTime + (Conductor.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections); - var targetTimeMs:Float = Conductor.getStepTimeInMs(targetTimeSteps); + var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections); + var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); FlxG.sound.music.time = targetTimeMs; handleSkippedNotes(); // regenNoteData(FlxG.sound.music.time); - Conductor.update(FlxG.sound.music.time); + Conductor.instance.update(FlxG.sound.music.time); resyncVocals(); } diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx index 3523ec994..f9dc18119 100644 --- a/source/funkin/play/character/AnimateAtlasCharacter.hx +++ b/source/funkin/play/character/AnimateAtlasCharacter.hx @@ -9,6 +9,7 @@ import flixel.math.FlxMath; import flixel.math.FlxPoint.FlxCallbackPoint; import flixel.math.FlxPoint; import flixel.math.FlxRect; +import funkin.graphics.FunkinSprite; import flixel.system.FlxAssets.FlxGraphicAsset; import flixel.util.FlxColor; import flixel.util.FlxDestroyUtil; @@ -621,7 +622,7 @@ class AnimateAtlasCharacter extends BaseCharacter * This functionality isn't supported in SpriteGroup * @return this sprite group */ - public override function loadGraphicFromSprite(Sprite:FlxSprite):FlxSprite + public override function loadGraphicFromSprite(Sprite:FlxSprite):FunkinSprite { #if FLX_DEBUG throw "This function is not supported in FlxSpriteGroup"; diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index 7ad0892f6..390864148 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -367,7 +367,7 @@ 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.beatLengthMs * 0.001); // x beats, to ms. + var singTimeSec:Float = singTimeSec * (Conductor.instance.beatLengthMs * 0.001); // x beats, to ms. if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index 16cc8b299..69e3ca48e 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -43,7 +43,7 @@ class CharacterDataParser { // Clear any stages that are cached if there were any. clearCharacterCache(); - trace('Loading character cache...'); + trace('[CHARACTER] Parsing all entries...'); // // UNSCRIPTED CHARACTERS diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx index 0fc07399c..48c5afb58 100644 --- a/source/funkin/play/character/MultiSparrowCharacter.hx +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -1,5 +1,6 @@ package funkin.play.character; +import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFramesCollection; import funkin.modding.events.ScriptEvent; import funkin.util.assets.FlxAnimationUtil; @@ -7,35 +8,17 @@ import funkin.play.character.CharacterData.CharacterRenderType; /** * For some characters which use Sparrow atlases, the spritesheets need to be split - * into multiple files. This character renderer handles by showing the appropriate sprite. + * into multiple files. This character renderer concatenates these together into a single sprite. * * Examples in base game include BF Holding GF (most of the sprites are in one file * but the death animation is in a separate file). * Only example I can think of in mods is Tricky (which has a separate file for each animation). * - * BaseCharacter has game logic, SparrowCharacter has only rendering logic. + * BaseCharacter has game logic, MultiSparrowCharacter has only rendering logic. * KEEP THEM SEPARATE! - * - * TODO: Rewrite this to use a single frame collection. - * @see https://github.com/HaxeFlixel/flixel/issues/2587#issuecomment-1179620637 */ class MultiSparrowCharacter extends BaseCharacter { - /** - * The actual group which holds all spritesheets this character uses. - */ - var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>(); - - /** - * A map between animation names and what frame collection the animation should use. - */ - var animAssetPath:Map<String, String> = new Map<String, String>(); - - /** - * The current frame collection being used. - */ - var activeMember:String; - public function new(id:String) { super(id, CharacterRenderType.MultiSparrow); @@ -51,7 +34,7 @@ class MultiSparrowCharacter extends BaseCharacter function buildSprites():Void { - buildSpritesheets(); + buildSpritesheet(); buildAnimations(); if (_data.isPixel) @@ -66,95 +49,49 @@ class MultiSparrowCharacter extends BaseCharacter } } - function buildSpritesheets():Void + function buildSpritesheet():Void { - // TODO: This currently works by creating like 5 frame collections and switching between them. - // It would be better to refactor this to simply concatenate the frame collections together. - - // Build the list of asset paths to use. - // Ignore nulls and duplicates. - var assetList = [_data.assetPath]; + var assetList = []; for (anim in _data.animations) { if (anim.assetPath != null && !assetList.contains(anim.assetPath)) { assetList.push(anim.assetPath); } - animAssetPath.set(anim.name, anim.assetPath); } - // Load the Sparrow atlas for each path and store them in the members map. + var texture:FlxAtlasFrames = Paths.getSparrowAtlas(_data.assetPath, 'shared'); + + if (texture == null) + { + trace('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}'); + } + else + { + trace('Creating multi-sparrow atlas: ${_data.assetPath}'); + texture.parent.destroyOnNoUse = false; + } + for (asset in assetList) { - var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared'); + var subTexture:FlxAtlasFrames = Paths.getSparrowAtlas(asset, 'shared'); // If we don't do this, the unused textures will be removed as soon as they're loaded. - if (texture == null) + if (subTexture == null) { - trace('Multi-Sparrow atlas could not load texture: ${asset}'); + trace('Multi-Sparrow atlas could not load subtexture: ${asset}'); } else { - trace('Adding multi-sparrow atlas: ${asset}'); - texture.parent.destroyOnNoUse = false; - members.set(asset, texture); + trace('Concatenating multi-sparrow atlas: ${asset}'); + subTexture.parent.destroyOnNoUse = false; } + + texture.addAtlas(subTexture); } - // Use the default frame collection to start. - loadFramesByAssetPath(_data.assetPath); - } - - /** - * Replace this sprite's animation frames with the ones at this asset path. - */ - function loadFramesByAssetPath(assetPath:String):Void - { - if (_data.assetPath == null) - { - trace('[ERROR] Multi-Sparrow character has no default asset path!'); - return; - } - if (assetPath == null) - { - // trace('Asset path is null, falling back to default. This is normal!'); - loadFramesByAssetPath(_data.assetPath); - return; - } - - if (this.activeMember == assetPath) - { - // trace('Already using this asset path: ${assetPath}'); - return; - } - - if (members.exists(assetPath)) - { - // Switch to a new set of sprites. - // trace('Loading frames from asset path: ${assetPath}'); - this.frames = members.get(assetPath); - this.activeMember = assetPath; - this.setScale(_data.scale); - } - else - { - trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}'); - } - } - - /** - * Replace this sprite's animation frames with the ones needed to play this animation. - */ - function loadFramesByAnimName(animName) - { - if (animAssetPath.exists(animName)) - { - loadFramesByAssetPath(animAssetPath.get(animName)); - } - else - { - trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}'); - } + this.frames = texture; + this.setScale(_data.scale); } function buildAnimations() @@ -164,7 +101,6 @@ class MultiSparrowCharacter extends BaseCharacter // We need to swap to the proper frame collection before adding the animations, I think? for (anim in _data.animations) { - loadFramesByAnimName(anim.name); FlxAnimationUtil.addAtlasAnimation(this, anim); if (anim.offsets == null) @@ -187,37 +123,6 @@ class MultiSparrowCharacter extends BaseCharacter // unless we're forcing a new animation. if (!this.canPlayOtherAnims && !ignoreOther) return; - loadFramesByAnimName(name); super.playAnimation(name, restart, ignoreOther, reverse); } - - override function set_frames(value:FlxFramesCollection):FlxFramesCollection - { - // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK - // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM - // if (animation != null) - // { - // animation.destroyAnimations(); - // } - - if (value != null) - { - graphic = value.parent; - this.frames = value; - this.frame = value.getByIndex(0); - // this.numFrames = value.numFrames; - resetHelpers(); - this.bakedRotationAngle = 0; - this.animation.frameIndex = 0; - graphicLoaded(); - } - else - { - this.frames = null; - this.frame = null; - this.graphic = null; - } - - return this.frames; - } } diff --git a/source/funkin/play/components/ComboMilestone.hx b/source/funkin/play/components/ComboMilestone.hx index 54d1438f1..4119e45c2 100644 --- a/source/funkin/play/components/ComboMilestone.hx +++ b/source/funkin/play/components/ComboMilestone.hx @@ -40,7 +40,7 @@ class ComboMilestone extends FlxTypedSpriteGroup<FlxSprite> { if (onScreenTime < 0.9) { - new FlxTimer().start((Conductor.beatLengthMs / 1000) * 0.25, function(tmr) { + new FlxTimer().start((Conductor.instance.beatLengthMs / 1000) * 0.25, function(tmr) { forceFinish(); }); } diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx index 38a6ec15a..9553856a9 100644 --- a/source/funkin/play/components/PopUpStuff.hx +++ b/source/funkin/play/components/PopUpStuff.hx @@ -59,7 +59,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite> remove(rating, true); rating.destroy(); }, - startDelay: Conductor.beatLengthMs * 0.001 + startDelay: Conductor.instance.beatLengthMs * 0.001 }); } @@ -110,7 +110,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite> remove(comboSpr, true); comboSpr.destroy(); }, - startDelay: Conductor.beatLengthMs * 0.001 + startDelay: Conductor.instance.beatLengthMs * 0.001 }); var seperatedScore:Array<Int> = []; @@ -157,7 +157,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite> remove(numScore, true); numScore.destroy(); }, - startDelay: Conductor.beatLengthMs * 0.002 + startDelay: Conductor.instance.beatLengthMs * 0.002 }); daLoop++; diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx index b2361c795..dc3fd8c8a 100644 --- a/source/funkin/play/cutscene/dialogue/Conversation.hx +++ b/source/funkin/play/cutscene/dialogue/Conversation.hx @@ -1,8 +1,10 @@ package funkin.play.cutscene.dialogue; +import funkin.data.IRegistryEntry; import flixel.FlxSprite; import flixel.group.FlxSpriteGroup; import flixel.util.FlxColor; +import funkin.graphics.FunkinSprite; import flixel.tweens.FlxTween; import flixel.tweens.FlxEase; import flixel.sound.FlxSound; @@ -13,27 +15,30 @@ import funkin.modding.IScriptedClass.IEventHandler; import funkin.play.cutscene.dialogue.DialogueBox; import funkin.modding.IScriptedClass.IDialogueScriptedClass; import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData; import flixel.addons.display.FlxPieDial; +import funkin.data.dialogue.ConversationData; +import funkin.data.dialogue.ConversationData.DialogueEntryData; +import funkin.data.dialogue.ConversationRegistry; +import funkin.data.dialogue.SpeakerData; +import funkin.data.dialogue.SpeakerRegistry; +import funkin.data.dialogue.DialogueBoxData; +import funkin.data.dialogue.DialogueBoxRegistry; /** * A high-level handler for dialogue. * * This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric */ -class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass +class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<ConversationData> { static final CONVERSATION_SKIP_TIMER:Float = 1.5; var skipHeldTimer:Float = 0.0; /** - * DATA + * The ID of the conversation. */ - /** - * The ID of the associated dialogue. - */ - public final conversationId:String; + public final id:String; /** * The current state of the conversation. @@ -41,9 +46,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass var state:ConversationState = ConversationState.Start; /** - * The data for the associated dialogue. + * Conversation data as parsed from the JSON file. */ - var conversationData:ConversationData; + public final _data:ConversationData; /** * The current entry in the dialogue. @@ -54,7 +59,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass function get_currentDialogueEntryCount():Int { - return conversationData.dialogue.length; + return _data.dialogue.length; } /** @@ -73,10 +78,10 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass function get_currentDialogueEntryData():DialogueEntryData { - if (conversationData == null || conversationData.dialogue == null) return null; - if (currentDialogueEntry < 0 || currentDialogueEntry >= conversationData.dialogue.length) return null; + if (_data == null || _data.dialogue == null) return null; + if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null; - return conversationData.dialogue[currentDialogueEntry]; + return _data.dialogue[currentDialogueEntry]; } var currentDialogueLineString(get, never):String; @@ -94,7 +99,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass /** * GRAPHICS */ - var backdrop:FlxSprite; + var backdrop:FunkinSprite; var currentSpeaker:Speaker; @@ -102,14 +107,17 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass var skipTimer:FlxPieDial; - public function new(conversationId:String) + public function new(id:String) { super(); - this.conversationId = conversationId; - this.conversationData = ConversationDataParser.parseConversationData(this.conversationId); + this.id = id; + this._data = _fetchData(id); - if (conversationData == null) throw 'Could not load conversation data for conversation ID "$conversationId"'; + if (_data == null) + { + throw 'Could not parse conversation data for id: $id'; + } } public function onCreate(event:ScriptEvent):Void @@ -125,14 +133,14 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass function setupMusic():Void { - if (conversationData.music == null) return; + if (_data.music == null) return; - music = new FlxSound().loadEmbedded(Paths.music(conversationData.music.asset), true, true); + music = new FlxSound().loadEmbedded(Paths.music(_data.music.asset), true, true); music.volume = 0; - if (conversationData.music.fadeTime > 0.0) + if (_data.music.fadeTime > 0.0) { - FlxTween.tween(music, {volume: 1.0}, conversationData.music.fadeTime, {ease: FlxEase.linear}); + FlxTween.tween(music, {volume: 1.0}, _data.music.fadeTime, {ease: FlxEase.linear}); } else { @@ -145,19 +153,20 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass function setupBackdrop():Void { - backdrop = new FlxSprite(0, 0); + backdrop = new FunkinSprite(0, 0); - if (conversationData.backdrop == null) return; + if (_data.backdrop == null) return; // Play intro - switch (conversationData?.backdrop.type) + switch (_data.backdrop) { - case SOLID: - backdrop.makeGraphic(Std.int(FlxG.width), Std.int(FlxG.height), FlxColor.fromString(conversationData.backdrop.data.color)); - if (conversationData.backdrop.data.fadeTime > 0.0) + case SOLID(backdropData): + var targetColor:FlxColor = FlxColor.fromString(backdropData.color); + backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor); + if (backdropData.fadeTime > 0.0) { backdrop.alpha = 0.0; - FlxTween.tween(backdrop, {alpha: 1.0}, conversationData.backdrop.data.fadeTime, {ease: FlxEase.linear}); + FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: FlxEase.linear}); } else { @@ -190,9 +199,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass var nextSpeakerId:String = currentDialogueEntryData.speaker; // Skip the next steps if the current speaker is already displayed. - if (currentSpeaker != null && nextSpeakerId == currentSpeaker.speakerId) return; + if (currentSpeaker != null && nextSpeakerId == currentSpeaker.id) return; - var nextSpeaker:Speaker = SpeakerDataParser.fetchSpeaker(nextSpeakerId); + var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId); if (currentSpeaker != null) { @@ -241,7 +250,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass var nextDialogueBoxId:String = currentDialogueEntryData?.box; // Skip the next steps if the current speaker is already displayed. - if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.dialogueBoxId) return; + if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.id) return; if (currentDialogueBox != null) { @@ -250,7 +259,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass currentDialogueBox = null; } - var nextDialogueBox:DialogueBox = DialogueBoxDataParser.fetchDialogueBox(nextDialogueBoxId); + var nextDialogueBox:DialogueBox = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId); if (nextDialogueBox == null) { @@ -378,20 +387,18 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass public function startOutro():Void { - switch (conversationData?.outro?.type) + switch (_data?.outro) { - case FADE: - var fadeTime:Float = conversationData?.outro.data.fadeTime ?? 1.0; - - outroTween = FlxTween.tween(this, {alpha: 0.0}, fadeTime, + case FADE(outroData): + outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime, { type: ONESHOT, // holy shit like the game no way startDelay: 0, onComplete: (_) -> endOutro(), }); - FlxTween.tween(this.music, {volume: 0.0}, fadeTime); - case NONE: + FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime); + case NONE(_): // Immediately clean up. endOutro(); default: @@ -400,7 +407,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass } } - public var completeCallback:Void->Void; + public var completeCallback:() -> Void; public function endOutro():Void { @@ -596,7 +603,12 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass public override function toString():String { - return 'Conversation($conversationId)'; + return 'Conversation($id)'; + } + + static function _fetchData(id:String):Null<ConversationData> + { + return ConversationRegistry.instance.parseEntryDataWithMigration(id, ConversationRegistry.instance.fetchEntryVersion(id)); } } diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx deleted file mode 100644 index 8c4aa9684..000000000 --- a/source/funkin/play/cutscene/dialogue/ConversationData.hx +++ /dev/null @@ -1,240 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import funkin.util.SerializerUtil; - -/** - * Data about a conversation. - * Includes what speakers are in the conversation, and what phrases they say. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class ConversationData -{ - public var version:String; - public var backdrop:BackdropData; - public var outro:OutroData; - public var music:MusicData; - public var dialogue:Array<DialogueEntryData>; - - public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array<DialogueEntryData>) - { - this.version = version; - this.backdrop = backdrop; - this.outro = outro; - this.music = music; - this.dialogue = dialogue; - } - - public static function fromString(i:String):ConversationData - { - if (i == null || i == '') return null; - var data: - { - version:String, - backdrop:Dynamic, // TODO: tink.Json doesn't like when these are typed - ?outro:Dynamic, // TODO: tink.Json doesn't like when these are typed - ?music:Dynamic, // TODO: tink.Json doesn't like when these are typed - dialogue:Array<Dynamic> // TODO: tink.Json doesn't like when these are typed - } = tink.Json.parse(i); - return fromJson(data); - } - - public static function fromJson(j:Dynamic):ConversationData - { - // TODO: Check version and perform migrations if necessary. - if (j == null) return null; - return new ConversationData(j.version, BackdropData.fromJson(j.backdrop), OutroData.fromJson(j.outro), MusicData.fromJson(j.music), - j.dialogue.map(d -> DialogueEntryData.fromJson(d))); - } - - public function toJson():Dynamic - { - return { - version: this.version, - backdrop: this.backdrop.toJson(), - dialogue: this.dialogue.map(d -> d.toJson()) - }; - } -} - -/** - * Data about a single dialogue entry. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class DialogueEntryData -{ - /** - * The speaker who says this phrase. - */ - public var speaker:String; - - /** - * The animation the speaker will play. - */ - public var speakerAnimation:String; - - /** - * The text box that will appear. - */ - public var box:String; - - /** - * The animation the dialogue box will play. - */ - public var boxAnimation:String; - - /** - * The lines of text that will appear in the text box. - */ - public var text:Array<String>; - - /** - * The relative speed at which the text will scroll. - * @default 1.0 - */ - public var speed:Float = 1.0; - - public function new(speaker:String, speakerAnimation:String, box:String, boxAnimation:String, text:Array<String>, speed:Float = null) - { - this.speaker = speaker; - this.speakerAnimation = speakerAnimation; - this.box = box; - this.boxAnimation = boxAnimation; - this.text = text; - if (speed != null) this.speed = speed; - } - - public static function fromJson(j:Dynamic):DialogueEntryData - { - if (j == null) return null; - return new DialogueEntryData(j.speaker, j.speakerAnimation, j.box, j.boxAnimation, j.text, j.speed); - } - - public function toJson():Dynamic - { - var result:Dynamic = - { - speaker: this.speaker, - speakerAnimation: this.speakerAnimation, - box: this.box, - boxAnimation: this.boxAnimation, - text: this.text, - }; - - if (this.speed != 1.0) result.speed = this.speed; - - return result; - } -} - -/** - * Data about a backdrop. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.BackdropData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class BackdropData -{ - public var type:BackdropType; - public var data:Dynamic; - - public function new(typeStr:String, data:Dynamic) - { - this.type = typeStr; - this.data = data; - } - - public static function fromJson(j:Dynamic):BackdropData - { - if (j == null) return null; - return new BackdropData(j.type, j.data); - } - - public function toJson():Dynamic - { - return { - type: this.type, - data: this.data - }; - } -} - -enum abstract BackdropType(String) from String to String -{ - public var SOLID:BackdropType = 'solid'; -} - -/** - * Data about a music track. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.MusicData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class MusicData -{ - public var asset:String; - - public var fadeTime:Float; - - @:optional - @:default(false) - public var looped:Bool; - - public function new(asset:String, looped:Bool, fadeTime:Float = 0.0) - { - this.asset = asset; - this.looped = looped; - this.fadeTime = fadeTime; - } - - public static function fromJson(j:Dynamic):MusicData - { - if (j == null) return null; - return new MusicData(j.asset, j.looped, j.fadeTime); - } - - public function toJson():Dynamic - { - return { - asset: this.asset, - looped: this.looped, - fadeTime: this.fadeTime - }; - } -} - -/** - * Data about an outro. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.OutroData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class OutroData -{ - public var type:OutroType; - public var data:Dynamic; - - public function new(?typeStr:String, data:Dynamic) - { - this.type = typeStr ?? OutroType.NONE; - this.data = data; - } - - public static function fromJson(j:Dynamic):OutroData - { - if (j == null) return null; - return new OutroData(j.type, j.data); - } - - public function toJson():Dynamic - { - return { - type: this.type, - data: this.data - }; - } -} - -enum abstract OutroType(String) from String to String -{ - public var NONE:OutroType = 'none'; - public var FADE:OutroType = 'fade'; -} diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx deleted file mode 100644 index 9f80f8f9b..000000000 --- a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx +++ /dev/null @@ -1,163 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import openfl.Assets; -import funkin.util.assets.DataAssets; -import funkin.play.cutscene.dialogue.ScriptedConversation; - -/** - * Contains utilities for loading and parsing conversation data. - * TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you. - */ -class ConversationDataParser -{ - public static final CONVERSATION_DATA_VERSION:String = '1.0.0'; - public static final CONVERSATION_DATA_VERSION_RULE:String = '1.0.x'; - - static final conversationCache:Map<String, Conversation> = new Map<String, Conversation>(); - static final conversationScriptedClass:Map<String, String> = new Map<String, String>(); - - static final DEFAULT_CONVERSATION_ID:String = 'UNKNOWN'; - - /** - * Parses and preloads the game's conversation data and scripts when the game starts. - * - * If you want to force conversations to be reloaded, you can just call this function again. - */ - public static function loadConversationCache():Void - { - clearConversationCache(); - trace('Loading dialogue conversation cache...'); - - // - // SCRIPTED CONVERSATIONS - // - var scriptedConversationClassNames:Array<String> = ScriptedConversation.listScriptClasses(); - trace(' Instantiating ${scriptedConversationClassNames.length} scripted conversations...'); - for (conversationCls in scriptedConversationClassNames) - { - var conversation:Conversation = ScriptedConversation.init(conversationCls, DEFAULT_CONVERSATION_ID); - if (conversation != null) - { - trace(' Loaded scripted conversation: ${conversationCls}'); - // Disable the rendering logic for conversation until it's loaded. - // Note that kill() =/= destroy() - conversation.kill(); - - // Then store it. - conversationCache.set(conversation.conversationId, conversation); - } - else - { - trace(' Failed to instantiate scripted conversation class: ${conversationCls}'); - } - } - - // - // UNSCRIPTED CONVERSATIONS - // - // Scripts refers to code here, not the actual dialogue. - var conversationIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/conversations/'); - // Filter out conversations that are scripted. - var unscriptedConversationIds:Array<String> = conversationIdList.filter(function(conversationId:String):Bool { - return !conversationCache.exists(conversationId); - }); - trace(' Fetching data for ${unscriptedConversationIds.length} conversations...'); - for (conversationId in unscriptedConversationIds) - { - try - { - var conversation:Conversation = new Conversation(conversationId); - // Say something offensive to kill the conversation. - // We will revive it later. - conversation.kill(); - if (conversation != null) - { - trace(' Loaded conversation data: ${conversation.conversationId}'); - conversationCache.set(conversation.conversationId, conversation); - } - } - catch (e) - { - trace(e); - continue; - } - } - } - - /** - * Fetches data for a conversation and returns a Conversation instance, - * ready to be displayed. - * @param conversationId The ID of the conversation to fetch. - * @return The conversation instance, or null if the conversation was not found. - */ - public static function fetchConversation(conversationId:String):Null<Conversation> - { - if (conversationId != null && conversationId != '' && conversationCache.exists(conversationId)) - { - trace('Successfully fetched conversation: ${conversationId}'); - var conversation:Conversation = conversationCache.get(conversationId); - // ...ANYway... - conversation.revive(); - return conversation; - } - else - { - trace('Failed to fetch conversation, not found in cache: ${conversationId}'); - return null; - } - } - - static function clearConversationCache():Void - { - if (conversationCache != null) - { - for (conversation in conversationCache) - { - conversation.destroy(); - } - conversationCache.clear(); - } - } - - public static function listConversationIds():Array<String> - { - return conversationCache.keys().array(); - } - - /** - * Load a conversation's JSON file, parse its data, and return it. - * - * @param conversationId The conversation to load. - * @return The conversation data, or null if validation failed. - */ - public static function parseConversationData(conversationId:String):Null<ConversationData> - { - trace('Parsing conversation data: ${conversationId}'); - var rawJson:String = loadConversationFile(conversationId); - - try - { - var conversationData:ConversationData = ConversationData.fromString(rawJson); - return conversationData; - } - catch (e) - { - trace('Failed to parse conversation ($conversationId).'); - trace(e); - return null; - } - } - - static function loadConversationFile(conversationPath:String):String - { - var conversationFilePath:String = Paths.json('dialogue/conversations/${conversationPath}'); - var rawJson:String = Assets.getText(conversationFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } -} diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx index cdac3c233..6f8a0086a 100644 --- a/source/funkin/play/cutscene/dialogue/DialogueBox.hx +++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx @@ -1,6 +1,7 @@ package funkin.play.cutscene.dialogue; import flixel.FlxSprite; +import funkin.data.IRegistryEntry; import flixel.group.FlxSpriteGroup; import flixel.graphics.frames.FlxFramesCollection; import flixel.text.FlxText; @@ -9,18 +10,21 @@ import funkin.util.assets.FlxAnimationUtil; import funkin.modding.events.ScriptEvent; import funkin.modding.IScriptedClass.IDialogueScriptedClass; import flixel.util.FlxColor; +import funkin.data.dialogue.DialogueBoxData; +import funkin.data.dialogue.DialogueBoxRegistry; -class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass +class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<DialogueBoxData> { - public final dialogueBoxId:String; + public final id:String; + public var dialogueBoxName(get, never):String; function get_dialogueBoxName():String { - return boxData?.name ?? 'UNKNOWN'; + return _data.name ?? 'UNKNOWN'; } - var boxData:DialogueBoxData; + public final _data:DialogueBoxData; /** * Offset the speaker's sprite by this much when playing each animation. @@ -88,13 +92,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass return this.speed; } - public function new(dialogueBoxId:String) + public function new(id:String) { super(); - this.dialogueBoxId = dialogueBoxId; - this.boxData = DialogueBoxDataParser.parseDialogueBoxData(this.dialogueBoxId); + this.id = id; + this._data = _fetchData(id); - if (boxData == null) throw 'Could not load dialogue box data for box ID "$dialogueBoxId"'; + if (_data == null) + { + throw 'Could not parse dialogue box data for id: $id'; + } } public function onCreate(event:ScriptEvent):Void @@ -115,18 +122,18 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass function loadSpritesheet():Void { - trace('[DIALOGUE BOX] Loading spritesheet ${boxData.assetPath} for ${dialogueBoxId}'); + trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}'); - var tex:FlxFramesCollection = Paths.getSparrowAtlas(boxData.assetPath); + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); if (tex == null) { - trace('Could not load Sparrow sprite: ${boxData.assetPath}'); + trace('Could not load Sparrow sprite: ${_data.assetPath}'); return; } this.boxSprite.frames = tex; - if (boxData.isPixel) + if (_data.isPixel) { this.boxSprite.antialiasing = false; } @@ -135,9 +142,10 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass this.boxSprite.antialiasing = true; } - this.flipX = boxData.flipX; - this.globalOffsets = boxData.offsets; - this.setScale(boxData.scale); + this.flipX = _data.flipX; + this.flipY = _data.flipY; + this.globalOffsets = _data.offsets; + this.setScale(_data.scale); } public function setText(newText:String):Void @@ -184,11 +192,11 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass function loadAnimations():Void { - trace('[DIALOGUE BOX] Loading ${boxData.animations.length} animations for ${dialogueBoxId}'); + trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}'); - FlxAnimationUtil.addAtlasAnimations(this.boxSprite, boxData.animations); + FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations); - for (anim in boxData.animations) + for (anim in _data.animations) { if (anim.offsets == null) { @@ -201,7 +209,7 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass } var animNames:Array<String> = this.boxSprite?.animation?.getNameList() ?? []; - trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${dialogueBoxId}'); + trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}'); boxSprite.animation.callback = this.onAnimationFrame; boxSprite.animation.finishCallback = this.onAnimationFinished; @@ -234,16 +242,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass function loadText():Void { textDisplay = new FlxTypeText(0, 0, 300, '', 32); - textDisplay.fieldWidth = boxData.text.width; - textDisplay.setFormat('Pixel Arial 11 Bold', boxData.text.size, FlxColor.fromString(boxData.text.color), LEFT, SHADOW, - FlxColor.fromString(boxData.text.shadowColor ?? '#00000000'), false); - textDisplay.borderSize = boxData.text.shadowWidth ?? 2; + textDisplay.fieldWidth = _data.text.width; + 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)]; textDisplay.completeCallback = onTypingComplete; - textDisplay.x += boxData.text.offsets[0]; - textDisplay.y += boxData.text.offsets[1]; + textDisplay.x += _data.text.offsets[0]; + textDisplay.y += _data.text.offsets[1]; add(textDisplay); } @@ -374,4 +382,14 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass } public function onScriptEvent(event:ScriptEvent):Void {} + + public override function toString():String + { + return 'DialogueBox($id)'; + } + + static function _fetchData(id:String):Null<DialogueBoxData> + { + return DialogueBoxRegistry.instance.parseEntryDataWithMigration(id, DialogueBoxRegistry.instance.fetchEntryVersion(id)); + } } diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx deleted file mode 100644 index 801a01dd7..000000000 --- a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx +++ /dev/null @@ -1,124 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import funkin.data.animation.AnimationData; -import funkin.util.SerializerUtil; - -/** - * Data about a text box. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class DialogueBoxData -{ - public var version:String; - public var name:String; - public var assetPath:String; - public var flipX:Bool; - public var flipY:Bool; - public var isPixel:Bool; - public var offsets:Array<Float>; - public var text:DialogueBoxTextData; - public var scale:Float; - public var animations:Array<AnimationData>; - - public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null<Array<Float>>, - text:DialogueBoxTextData, scale:Float = 1.0, animations:Array<AnimationData>) - { - this.version = version; - this.name = name; - this.assetPath = assetPath; - this.flipX = flipX; - this.flipY = flipY; - this.isPixel = isPixel; - this.offsets = offsets ?? [0, 0]; - this.text = text; - this.scale = scale; - this.animations = animations; - } - - public static function fromString(i:String):DialogueBoxData - { - if (i == null || i == '') return null; - var data: - { - version:String, - name:String, - assetPath:String, - flipX:Bool, - flipY:Bool, - isPixel:Bool, - ?offsets:Array<Float>, - text:Dynamic, - scale:Float, - animations:Array<AnimationData> - } = tink.Json.parse(i); - return fromJson(data); - } - - public static function fromJson(j:Dynamic):DialogueBoxData - { - // TODO: Check version and perform migrations if necessary. - if (j == null) return null; - return new DialogueBoxData(j.version, j.name, j.assetPath, j.flipX, j.flipY, j.isPixel, j.offsets, DialogueBoxTextData.fromJson(j.text), j.scale, - j.animations); - } - - public function toJson():Dynamic - { - return { - version: this.version, - name: this.name, - assetPath: this.assetPath, - flipX: this.flipX, - flipY: this.flipY, - isPixel: this.isPixel, - offsets: this.offsets, - scale: this.scale, - animations: this.animations - }; - } -} - -/** - * Data about text in a text box. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxTextData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class DialogueBoxTextData -{ - public var offsets:Array<Float>; - public var width:Int; - public var size:Int; - public var color:String; - public var shadowColor:Null<String>; - public var shadowWidth:Null<Int>; - - public function new(offsets:Null<Array<Float>>, ?width:Int, ?size:Int, color:String, ?shadowColor:String, shadowWidth:Null<Int>) - { - this.offsets = offsets ?? [0, 0]; - this.width = width ?? 300; - this.size = size ?? 32; - this.color = color; - this.shadowColor = shadowColor; - this.shadowWidth = shadowWidth; - } - - public static function fromJson(j:Dynamic):DialogueBoxTextData - { - // TODO: Check version and perform migrations if necessary. - if (j == null) return null; - return new DialogueBoxTextData(j.offsets, j.width, j.size, j.color, j.shadowColor, j.shadowWidth); - } - - public function toJson():Dynamic - { - return { - offsets: this.offsets, - width: this.width, - size: this.size, - color: this.color, - shadowColor: this.shadowColor, - shadowWidth: this.shadowWidth, - }; - } -} diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx deleted file mode 100644 index cb00dd80d..000000000 --- a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx +++ /dev/null @@ -1,159 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import openfl.Assets; -import funkin.util.assets.DataAssets; -import funkin.play.cutscene.dialogue.DialogueBox; -import funkin.play.cutscene.dialogue.ScriptedDialogueBox; - -/** - * Contains utilities for loading and parsing dialogueBox data. - */ -class DialogueBoxDataParser -{ - public static final DIALOGUE_BOX_DATA_VERSION:String = '1.0.0'; - public static final DIALOGUE_BOX_DATA_VERSION_RULE:String = '1.0.x'; - - static final dialogueBoxCache:Map<String, DialogueBox> = new Map<String, DialogueBox>(); - - static final dialogueBoxScriptedClass:Map<String, String> = new Map<String, String>(); - - static final DEFAULT_DIALOGUE_BOX_ID:String = 'UNKNOWN'; - - /** - * Parses and preloads the game's dialogueBox data and scripts when the game starts. - * - * If you want to force dialogue boxes to be reloaded, you can just call this function again. - */ - public static function loadDialogueBoxCache():Void - { - clearDialogueBoxCache(); - trace('Loading dialogue box cache...'); - - // - // SCRIPTED CONVERSATIONS - // - var scriptedDialogueBoxClassNames:Array<String> = ScriptedDialogueBox.listScriptClasses(); - trace(' Instantiating ${scriptedDialogueBoxClassNames.length} scripted dialogue boxes...'); - for (dialogueBoxCls in scriptedDialogueBoxClassNames) - { - var dialogueBox:DialogueBox = ScriptedDialogueBox.init(dialogueBoxCls, DEFAULT_DIALOGUE_BOX_ID); - if (dialogueBox != null) - { - trace(' Loaded scripted dialogue box: ${dialogueBox.dialogueBoxName}'); - // Disable the rendering logic for dialogueBox until it's loaded. - // Note that kill() =/= destroy() - dialogueBox.kill(); - - // Then store it. - dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox); - } - else - { - trace(' Failed to instantiate scripted dialogueBox class: ${dialogueBoxCls}'); - } - } - - // - // UNSCRIPTED CONVERSATIONS - // - // Scripts refers to code here, not the actual dialogue. - var dialogueBoxIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/boxes/'); - // Filter out dialogue boxes that are scripted. - var unscriptedDialogueBoxIds:Array<String> = dialogueBoxIdList.filter(function(dialogueBoxId:String):Bool { - return !dialogueBoxCache.exists(dialogueBoxId); - }); - trace(' Fetching data for ${unscriptedDialogueBoxIds.length} dialogue boxes...'); - for (dialogueBoxId in unscriptedDialogueBoxIds) - { - try - { - var dialogueBox:DialogueBox = new DialogueBox(dialogueBoxId); - if (dialogueBox != null) - { - trace(' Loaded dialogueBox data: ${dialogueBox.dialogueBoxName}'); - dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox); - } - } - catch (e) - { - trace(e); - continue; - } - } - } - - /** - * Fetches data for a dialogueBox and returns a DialogueBox instance, - * ready to be displayed. - * @param dialogueBoxId The ID of the dialogueBox to fetch. - * @return The dialogueBox instance, or null if the dialogueBox was not found. - */ - public static function fetchDialogueBox(dialogueBoxId:String):Null<DialogueBox> - { - if (dialogueBoxId != null && dialogueBoxId != '' && dialogueBoxCache.exists(dialogueBoxId)) - { - trace('Successfully fetched dialogueBox: ${dialogueBoxId}'); - var dialogueBox:DialogueBox = dialogueBoxCache.get(dialogueBoxId); - dialogueBox.revive(); - return dialogueBox; - } - else - { - trace('Failed to fetch dialogueBox, not found in cache: ${dialogueBoxId}'); - return null; - } - } - - static function clearDialogueBoxCache():Void - { - if (dialogueBoxCache != null) - { - for (dialogueBox in dialogueBoxCache) - { - dialogueBox.destroy(); - } - dialogueBoxCache.clear(); - } - } - - public static function listDialogueBoxIds():Array<String> - { - return dialogueBoxCache.keys().array(); - } - - /** - * Load a dialogueBox's JSON file, parse its data, and return it. - * - * @param dialogueBoxId The dialogueBox to load. - * @return The dialogueBox data, or null if validation failed. - */ - public static function parseDialogueBoxData(dialogueBoxId:String):Null<DialogueBoxData> - { - var rawJson:String = loadDialogueBoxFile(dialogueBoxId); - - try - { - var dialogueBoxData:DialogueBoxData = DialogueBoxData.fromString(rawJson); - return dialogueBoxData; - } - catch (e) - { - trace('Failed to parse dialogueBox ($dialogueBoxId).'); - trace(e); - return null; - } - } - - static function loadDialogueBoxFile(dialogueBoxPath:String):String - { - var dialogueBoxFilePath:String = Paths.json('dialogue/boxes/${dialogueBoxPath}'); - var rawJson:String = Assets.getText(dialogueBoxFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } -} diff --git a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx index 4fe383a5e..cb7344273 100644 --- a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx +++ b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx @@ -1,4 +1,10 @@ package funkin.play.cutscene.dialogue; +/** + * A script that can be tied to a Conversation. + * Create a scripted class that extends Conversation to use this. + * This allows you to customize how a specific conversation appears and behaves. + * Someone clever could use this to add branching dialogue I think. + */ @:hscriptClass class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx index a1b36c7c2..7689fc0d9 100644 --- a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx +++ b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx @@ -1,4 +1,9 @@ package funkin.play.cutscene.dialogue; +/** + * A script that can be tied to a DialogueBox. + * Create a scripted class that extends DialogueBox to use this. + * This allows you to customize how a specific dialogue box appears. + */ @:hscriptClass class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx index d7ed004f1..d5bffd7b0 100644 --- a/source/funkin/play/cutscene/dialogue/Speaker.hx +++ b/source/funkin/play/cutscene/dialogue/Speaker.hx @@ -1,27 +1,30 @@ package funkin.play.cutscene.dialogue; import flixel.FlxSprite; +import funkin.data.IRegistryEntry; import funkin.modding.events.ScriptEvent; import flixel.graphics.frames.FlxFramesCollection; import funkin.util.assets.FlxAnimationUtil; import funkin.modding.IScriptedClass.IDialogueScriptedClass; +import funkin.data.dialogue.SpeakerData; +import funkin.data.dialogue.SpeakerRegistry; /** * The character sprite which displays during dialogue. * * Most conversations have two speakers, with one being flipped. */ -class Speaker extends FlxSprite implements IDialogueScriptedClass +class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRegistryEntry<SpeakerData> { /** * The internal ID for this speaker. */ - public final speakerId:String; + public final id:String; /** * The full data for a speaker. */ - var speakerData:SpeakerData; + public final _data:SpeakerData; /** * A readable name for this speaker. @@ -30,7 +33,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass function get_speakerName():String { - return speakerData.name; + return _data.name; } /** @@ -75,14 +78,17 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass return globalOffsets = value; } - public function new(speakerId:String) + public function new(id:String) { super(); - this.speakerId = speakerId; - this.speakerData = SpeakerDataParser.parseSpeakerData(this.speakerId); + this.id = id; + this._data = _fetchData(id); - if (speakerData == null) throw 'Could not load speaker data for speaker ID "$speakerId"'; + if (_data == null) + { + throw 'Could not parse speaker data for id: $id'; + } } /** @@ -102,18 +108,18 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass function loadSpritesheet():Void { - trace('[SPEAKER] Loading spritesheet ${speakerData.assetPath} for ${speakerId}'); + trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}'); - var tex:FlxFramesCollection = Paths.getSparrowAtlas(speakerData.assetPath); + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); if (tex == null) { - trace('Could not load Sparrow sprite: ${speakerData.assetPath}'); + trace('Could not load Sparrow sprite: ${_data.assetPath}'); return; } this.frames = tex; - if (speakerData.isPixel) + if (_data.isPixel) { this.antialiasing = false; } @@ -122,9 +128,10 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass this.antialiasing = true; } - this.flipX = speakerData.flipX; - this.globalOffsets = speakerData.offsets; - this.setScale(speakerData.scale); + this.flipX = _data.flipX; + this.flipY = _data.flipY; + this.globalOffsets = _data.offsets; + this.setScale(_data.scale); } /** @@ -141,11 +148,11 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass function loadAnimations():Void { - trace('[SPEAKER] Loading ${speakerData.animations.length} animations for ${speakerId}'); + trace('[SPEAKER] Loading ${_data.animations.length} animations for ${id}'); - FlxAnimationUtil.addAtlasAnimations(this, speakerData.animations); + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); - for (anim in speakerData.animations) + for (anim in _data.animations) { if (anim.offsets == null) { @@ -158,7 +165,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass } var animNames:Array<String> = this.animation.getNameList(); - trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${speakerId}'); + trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${id}'); } /** @@ -271,4 +278,14 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass } public function onScriptEvent(event:ScriptEvent):Void {} + + public override function toString():String + { + return 'Speaker($id)'; + } + + static function _fetchData(id:String):Null<SpeakerData> + { + return SpeakerRegistry.instance.parseEntryDataWithMigration(id, SpeakerRegistry.instance.fetchEntryVersion(id)); + } } diff --git a/source/funkin/play/cutscene/dialogue/SpeakerData.hx b/source/funkin/play/cutscene/dialogue/SpeakerData.hx deleted file mode 100644 index 88883ead8..000000000 --- a/source/funkin/play/cutscene/dialogue/SpeakerData.hx +++ /dev/null @@ -1,78 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import funkin.data.animation.AnimationData; - -/** - * Data about a conversation. - * Includes what speakers are in the conversation, and what phrases they say. - */ -@:jsonParse(j -> funkin.play.cutscene.dialogue.SpeakerData.fromJson(j)) -@:jsonStringify(v -> v.toJson()) -class SpeakerData -{ - public var version:String; - public var name:String; - public var assetPath:String; - public var flipX:Bool; - public var isPixel:Bool; - public var offsets:Array<Float>; - public var scale:Float; - public var animations:Array<AnimationData>; - - public function new(version:String, name:String, assetPath:String, animations:Array<AnimationData>, ?offsets:Array<Float>, flipX:Bool = false, - isPixel:Bool = false, ?scale:Float = 1.0) - { - this.version = version; - this.name = name; - this.assetPath = assetPath; - this.animations = animations; - - this.offsets = offsets; - if (this.offsets == null || this.offsets == []) this.offsets = [0, 0]; - - this.flipX = flipX; - this.isPixel = isPixel; - this.scale = scale; - } - - public static function fromString(i:String):SpeakerData - { - if (i == null || i == '') return null; - var data: - { - version:String, - name:String, - assetPath:String, - animations:Array<AnimationData>, - ?offsets:Array<Float>, - ?flipX:Bool, - ?isPixel:Bool, - ?scale:Float - } = tink.Json.parse(i); - return fromJson(data); - } - - public static function fromJson(j:Dynamic):SpeakerData - { - // TODO: Check version and perform migrations if necessary. - if (j == null) return null; - return new SpeakerData(j.version, j.name, j.assetPath, j.animations, j.offsets, j.flipX, j.isPixel, j.scale); - } - - public function toJson():Dynamic - { - var result:Dynamic = - { - version: this.version, - name: this.name, - assetPath: this.assetPath, - animations: this.animations, - flipX: this.flipX, - isPixel: this.isPixel - }; - - if (this.scale != 1.0) result.scale = this.scale; - - return result; - } -} diff --git a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx deleted file mode 100644 index f7ddb099f..000000000 --- a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx +++ /dev/null @@ -1,159 +0,0 @@ -package funkin.play.cutscene.dialogue; - -import openfl.Assets; -import funkin.util.assets.DataAssets; -import funkin.play.cutscene.dialogue.Speaker; -import funkin.play.cutscene.dialogue.ScriptedSpeaker; - -/** - * Contains utilities for loading and parsing speaker data. - */ -class SpeakerDataParser -{ - public static final SPEAKER_DATA_VERSION:String = '1.0.0'; - public static final SPEAKER_DATA_VERSION_RULE:String = '1.0.x'; - - static final speakerCache:Map<String, Speaker> = new Map<String, Speaker>(); - - static final speakerScriptedClass:Map<String, String> = new Map<String, String>(); - - static final DEFAULT_SPEAKER_ID:String = 'UNKNOWN'; - - /** - * Parses and preloads the game's speaker data and scripts when the game starts. - * - * If you want to force speakers to be reloaded, you can just call this function again. - */ - public static function loadSpeakerCache():Void - { - clearSpeakerCache(); - trace('Loading dialogue speaker cache...'); - - // - // SCRIPTED CONVERSATIONS - // - var scriptedSpeakerClassNames:Array<String> = ScriptedSpeaker.listScriptClasses(); - trace(' Instantiating ${scriptedSpeakerClassNames.length} scripted speakers...'); - for (speakerCls in scriptedSpeakerClassNames) - { - var speaker:Speaker = ScriptedSpeaker.init(speakerCls, DEFAULT_SPEAKER_ID); - if (speaker != null) - { - trace(' Loaded scripted speaker: ${speaker.speakerName}'); - // Disable the rendering logic for speaker until it's loaded. - // Note that kill() =/= destroy() - speaker.kill(); - - // Then store it. - speakerCache.set(speaker.speakerId, speaker); - } - else - { - trace(' Failed to instantiate scripted speaker class: ${speakerCls}'); - } - } - - // - // UNSCRIPTED CONVERSATIONS - // - // Scripts refers to code here, not the actual dialogue. - var speakerIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/speakers/'); - // Filter out speakers that are scripted. - var unscriptedSpeakerIds:Array<String> = speakerIdList.filter(function(speakerId:String):Bool { - return !speakerCache.exists(speakerId); - }); - trace(' Fetching data for ${unscriptedSpeakerIds.length} speakers...'); - for (speakerId in unscriptedSpeakerIds) - { - try - { - var speaker:Speaker = new Speaker(speakerId); - if (speaker != null) - { - trace(' Loaded speaker data: ${speaker.speakerName}'); - speakerCache.set(speaker.speakerId, speaker); - } - } - catch (e) - { - trace(e); - continue; - } - } - } - - /** - * Fetches data for a speaker and returns a Speaker instance, - * ready to be displayed. - * @param speakerId The ID of the speaker to fetch. - * @return The speaker instance, or null if the speaker was not found. - */ - public static function fetchSpeaker(speakerId:String):Null<Speaker> - { - if (speakerId != null && speakerId != '' && speakerCache.exists(speakerId)) - { - trace('Successfully fetched speaker: ${speakerId}'); - var speaker:Speaker = speakerCache.get(speakerId); - speaker.revive(); - return speaker; - } - else - { - trace('Failed to fetch speaker, not found in cache: ${speakerId}'); - return null; - } - } - - static function clearSpeakerCache():Void - { - if (speakerCache != null) - { - for (speaker in speakerCache) - { - speaker.destroy(); - } - speakerCache.clear(); - } - } - - public static function listSpeakerIds():Array<String> - { - return speakerCache.keys().array(); - } - - /** - * Load a speaker's JSON file, parse its data, and return it. - * - * @param speakerId The speaker to load. - * @return The speaker data, or null if validation failed. - */ - public static function parseSpeakerData(speakerId:String):Null<SpeakerData> - { - var rawJson:String = loadSpeakerFile(speakerId); - - try - { - var speakerData:SpeakerData = SpeakerData.fromString(rawJson); - return speakerData; - } - catch (e) - { - trace('Failed to parse speaker ($speakerId).'); - trace(e); - return null; - } - } - - static function loadSpeakerFile(speakerPath:String):String - { - var speakerFilePath:String = Paths.json('dialogue/speakers/${speakerPath}'); - var rawJson:String = Assets.getText(speakerFilePath).trim(); - - while (!rawJson.endsWith('}') && rawJson.length > 0) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } -} diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx index 5f63254b0..847df4a60 100644 --- a/source/funkin/play/event/FocusCameraSongEvent.hx +++ b/source/funkin/play/event/FocusCameraSongEvent.hx @@ -5,8 +5,8 @@ import funkin.data.song.SongData; import funkin.data.song.SongData.SongEventData; // Data from the event schema import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData.SongEventSchema; -import funkin.data.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; /** * This class represents a handler for a type of song event. @@ -132,13 +132,13 @@ class FocusCameraSongEvent extends SongEvent */ public override function getEventSchema():SongEventSchema { - return [ + return new SongEventSchema([ { name: "char", - title: "Character", + title: "Target", defaultValue: 0, type: SongEventFieldType.ENUM, - keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2] + keys: ["Position" => -1, "Player" => 0, "Opponent" => 1, "Girlfriend" => 2] }, { name: "x", @@ -146,6 +146,7 @@ class FocusCameraSongEvent extends SongEvent defaultValue: 0, step: 10.0, type: SongEventFieldType.FLOAT, + units: "px" }, { name: "y", @@ -153,7 +154,8 @@ class FocusCameraSongEvent extends SongEvent defaultValue: 0, step: 10.0, type: SongEventFieldType.FLOAT, + units: "px" } - ]; + ]); } } diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx index 6bc625517..4e6669479 100644 --- a/source/funkin/play/event/PlayAnimationSongEvent.hx +++ b/source/funkin/play/event/PlayAnimationSongEvent.hx @@ -7,8 +7,8 @@ import funkin.data.song.SongData; import funkin.data.song.SongData.SongEventData; // Data from the event schema import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData.SongEventSchema; -import funkin.data.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; class PlayAnimationSongEvent extends SongEvent { @@ -89,7 +89,7 @@ class PlayAnimationSongEvent extends SongEvent */ public override function getEventSchema():SongEventSchema { - return [ + return new SongEventSchema([ { name: 'target', title: 'Target', @@ -108,6 +108,6 @@ class PlayAnimationSongEvent extends SongEvent type: SongEventFieldType.BOOL, defaultValue: false } - ]; + ]); } } diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx index 3cdeb9a67..a82577a5f 100644 --- a/source/funkin/play/event/SetCameraBopSongEvent.hx +++ b/source/funkin/play/event/SetCameraBopSongEvent.hx @@ -8,8 +8,8 @@ import funkin.data.song.SongData; import funkin.data.song.SongData.SongEventData; // Data from the event schema import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData.SongEventSchema; -import funkin.data.event.SongEventData.SongEventFieldType; +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; /** * This class represents a handler for configuring camera bop intensity and rate. @@ -72,21 +72,23 @@ class SetCameraBopSongEvent extends SongEvent */ public override function getEventSchema():SongEventSchema { - return [ + return new SongEventSchema([ { name: 'intensity', title: 'Intensity', defaultValue: 1.0, step: 0.1, - type: SongEventFieldType.FLOAT + type: SongEventFieldType.FLOAT, + units: 'x' }, { name: 'rate', - title: 'Rate (beats/zoom)', + title: 'Rate', defaultValue: 4, step: 1, type: SongEventFieldType.INTEGER, + units: 'beats/zoom' } - ]; + ]); } } diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx index 36a886673..29b394c0e 100644 --- a/source/funkin/play/event/SongEvent.hx +++ b/source/funkin/play/event/SongEvent.hx @@ -1,7 +1,7 @@ package funkin.play.event; import funkin.data.song.SongData.SongEventData; -import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventSchema; /** * This class represents a handler for a type of song event. diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx index 1ae76039e..809130499 100644 --- a/source/funkin/play/event/ZoomCameraSongEvent.hx +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -8,8 +8,8 @@ import funkin.data.song.SongData; import funkin.data.song.SongData.SongEventData; // Data from the event schema import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData.SongEventFieldType; -import funkin.data.event.SongEventData.SongEventSchema; +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; /** * This class represents a handler for camera zoom events. @@ -79,7 +79,8 @@ class ZoomCameraSongEvent extends SongEvent return; } - FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000), {ease: easeFunction}); + FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.instance.stepLengthMs * duration / 1000), + {ease: easeFunction}); } } @@ -99,20 +100,22 @@ class ZoomCameraSongEvent extends SongEvent */ public override function getEventSchema():SongEventSchema { - return [ + return new SongEventSchema([ { name: 'zoom', title: 'Zoom Level', defaultValue: 1.0, step: 0.1, - type: SongEventFieldType.FLOAT + type: SongEventFieldType.FLOAT, + units: 'x' }, { name: 'duration', - title: 'Duration (in steps)', + title: 'Duration', defaultValue: 4.0, step: 0.5, type: SongEventFieldType.FLOAT, + units: 'steps' }, { name: 'ease', @@ -145,6 +148,6 @@ class ZoomCameraSongEvent extends SongEvent 'Elastic In/Out' => 'elasticInOut', ] } - ]; + ]); } } diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx index e5c4786d3..0368b18e9 100644 --- a/source/funkin/play/notes/NoteSprite.hx +++ b/source/funkin/play/notes/NoteSprite.hx @@ -4,6 +4,7 @@ import funkin.data.song.SongData.SongNoteData; import funkin.play.notes.notestyle.NoteStyle; import flixel.graphics.frames.FlxAtlasFrames; import flixel.FlxSprite; +import funkin.graphics.shaders.HSVShader; class NoteSprite extends FlxSprite { @@ -11,6 +12,8 @@ class NoteSprite extends FlxSprite public var holdNoteSprite:SustainTrail; + var hsvShader:HSVShader; + /** * The time at which the note should be hit, in milliseconds. */ @@ -102,6 +105,8 @@ class NoteSprite extends FlxSprite this.strumTime = strumTime; this.direction = direction; + this.hsvShader = new HSVShader(); + if (this.strumTime < 0) this.strumTime = 0; setupNoteGraphic(noteStyle); @@ -116,16 +121,57 @@ class NoteSprite extends FlxSprite setGraphicSize(Strumline.STRUMLINE_SIZE); updateHitbox(); + + this.shader = hsvShader; + } + + #if FLX_DEBUG + /** + * Call this to override how debug bounding boxes are drawn for this sprite. + */ + public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) return; + + var gfx = beginDrawDebug(camera); + + var rect = getBoundingBox(camera); + trace('note sprite bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height); + + gfx.lineStyle(2, 0xFFFF66FF, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y, rect.width, rect.height); + + gfx.lineStyle(2, 0xFFFFFF66, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y + rect.height / 2, rect.width, 1); + + endDrawDebug(camera); + } + #end + + public function desaturate():Void + { + this.hsvShader.saturation = 0.2; + } + + public function setHue(hue:Float):Void + { + this.hsvShader.hue = hue; } public override function revive():Void { super.revive(); + this.visible = true; + this.alpha = 1.0; this.active = false; this.tooEarly = false; this.hasBeenHit = false; this.mayHit = false; this.hasMissed = false; + + this.hsvShader.hue = 1.0; + this.hsvShader.saturation = 1.0; + this.hsvShader.value = 1.0; } public override function kill():Void diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 0145dee3f..5fdd3945f 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -274,10 +274,12 @@ class Strumline extends FlxSpriteGroup static function calculateNoteYPos(strumTime:Float, vwoosh:Bool = true):Float { // Make the note move faster visually as it moves offscreen. - var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0; + // var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0; + // ^^^ commented this out... do NOT make it move faster as it moves offscreen! + var vwoosh:Float = 1.0; var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0; - return Constants.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1); + return Constants.PIXELS_PER_MS * (Conductor.instance.songPosition - strumTime) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1); } function updateNotes():Void @@ -285,8 +287,8 @@ class Strumline extends FlxSpriteGroup if (noteData.length == 0) return; var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0; - var hitWindowStart:Float = Conductor.songPosition - Constants.HIT_WINDOW_MS; - var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS; + var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS; + var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS; for (noteIndex in nextNoteIndex...noteData.length) { @@ -314,7 +316,7 @@ class Strumline extends FlxSpriteGroup // Update rendering of notes. for (note in notes.members) { - if (note == null || !note.alive || note.hasBeenHit) continue; + if (note == null || !note.alive) continue; var vwoosh:Bool = note.holdNoteSprite == null; // Set the note's position. @@ -333,7 +335,7 @@ class Strumline extends FlxSpriteGroup { if (holdNote == null || !holdNote.alive) continue; - if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote) + if (Conductor.instance.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote) { if (isPlayer && !isKeyHeld(holdNote.noteDirection)) { @@ -341,13 +343,13 @@ class Strumline extends FlxSpriteGroup playStatic(holdNote.noteDirection); holdNote.missedNote = true; holdNote.visible = true; - holdNote.alpha = 0.0; + holdNote.alpha = 0.0; // Completely hide the dropped hold note. } } var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8; - if (holdNote.missedNote && Conductor.songPosition >= renderWindowEnd) + if (holdNote.missedNote && Conductor.instance.songPosition >= renderWindowEnd) { // Hold note is offscreen, kill it. holdNote.visible = false; @@ -382,10 +384,6 @@ class Strumline extends FlxSpriteGroup var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Constants.PIXELS_PER_MS; - trace('yOffset: ' + yOffset); - trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength); - trace('holdNote.sustainLength: ' + holdNote.sustainLength); - var vwoosh:Bool = false; if (Preferences.downscroll) @@ -397,13 +395,13 @@ class Strumline extends FlxSpriteGroup holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2; } } - else if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote) + else if (Conductor.instance.songPosition > holdNote.strumTime && holdNote.hitNote) { // Hold note is currently being hit, clip it off. holdConfirm(holdNote.noteDirection); holdNote.visible = true; - holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition; + holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.instance.songPosition; if (holdNote.sustainLength <= 10) { @@ -529,11 +527,24 @@ class Strumline extends FlxSpriteGroup this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING)); } - public function hitNote(note:NoteSprite):Void + /** + * @param note The note to hit. + * @param removeNote True to remove the note immediately, false to make it transparent and let it move offscreen. + */ + public function hitNote(note:NoteSprite, removeNote:Bool = true):Void { playConfirm(note.direction); note.hasBeenHit = true; - killNote(note); + + if (removeNote) + { + killNote(note); + } + else + { + note.alpha = 0.5; + note.desaturate(); + } if (note.holdNoteSprite != null) { diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx index ab4bf5f16..056a6a5a9 100644 --- a/source/funkin/play/notes/SustainTrail.hx +++ b/source/funkin/play/notes/SustainTrail.hx @@ -47,6 +47,11 @@ class SustainTrail extends FlxSprite */ public var missedNote:Bool = false; + /** + * Set to `true` after handling additional logic for missing notes. + */ + public var handledMiss:Bool = false; + // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support! /** @@ -82,6 +87,9 @@ class SustainTrail extends FlxSprite public var isPixel:Bool; + var graphicWidth:Float = 0; + var graphicHeight:Float = 0; + /** * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?) * @param NoteData @@ -110,8 +118,8 @@ class SustainTrail extends FlxSprite zoom *= 0.7; // CALCULATE SIZE - width = graphic.width / 8 * zoom; // amount of notes * 2 - height = sustainHeight(sustainLength, getScrollSpeed()); + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + graphicHeight = sustainHeight(sustainLength, getScrollSpeed()); // instead of scrollSpeed, PlayState.SONG.speed flipY = Preferences.downscroll; @@ -148,10 +156,19 @@ class SustainTrail extends FlxSprite if (sustainLength == s) return s; - height = sustainHeight(s, getScrollSpeed()); - // updateColorTransform(); + graphicHeight = sustainHeight(s, getScrollSpeed()); + this.sustainLength = s; updateClipping(); - return sustainLength = s; + updateHitbox(); + return this.sustainLength; + } + + public override function updateHitbox():Void + { + width = graphicWidth; + height = graphicHeight; + offset.set(0, 0); + origin.set(width * 0.5, height * 0.5); } /** @@ -161,14 +178,16 @@ class SustainTrail extends FlxSprite */ public function updateClipping(songTime:Float = 0):Void { - var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, height); - if (clipHeight == 0) + var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, graphicHeight); + if (clipHeight <= 0.1) { visible = false; return; } else + { visible = true; + } var bottomHeight:Float = graphic.height * zoom * endOffset; var partHeight:Float = clipHeight - bottomHeight; @@ -176,10 +195,10 @@ class SustainTrail extends FlxSprite // ===HOLD VERTICES== // Top left vertices[0 * 2] = 0.0; // Inline with left side - vertices[0 * 2 + 1] = flipY ? clipHeight : height - clipHeight; + vertices[0 * 2 + 1] = flipY ? clipHeight : graphicHeight - clipHeight; // Top right - vertices[1 * 2] = width; + vertices[1 * 2] = graphicWidth; vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex // Bottom left @@ -195,7 +214,7 @@ class SustainTrail extends FlxSprite } // Bottom right - vertices[3 * 2] = width; + vertices[3 * 2] = graphicWidth; vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex // ===HOLD UVs=== @@ -231,7 +250,7 @@ class SustainTrail extends FlxSprite // Bottom left vertices[6 * 2] = vertices[2 * 2]; // Inline with left side - vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (height + graphic.height * (bottomClip - endOffset) * zoom); + vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (graphicHeight + graphic.height * (bottomClip - endOffset) * zoom); // Bottom right vertices[7 * 2] = vertices[3 * 2]; // Inline with right side @@ -275,6 +294,10 @@ class SustainTrail extends FlxSprite getScreenPosition(_point, camera).subtractPoint(offset); camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing); } + + #if FLX_DEBUG + if (FlxG.debugger.drawDebug) drawDebug(); + #end } public override function kill():Void @@ -303,6 +326,7 @@ class SustainTrail extends FlxSprite hitNote = false; missedNote = false; + handledMiss = false; } override public function destroy():Void diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx index 75d002cb5..edfb2cae7 100644 --- a/source/funkin/play/scoring/Scoring.hx +++ b/source/funkin/play/scoring/Scoring.hx @@ -158,8 +158,8 @@ class Scoring return switch (absTiming) { - case(_ < PBOT1_KILLER_THRESHOLD) => true: - 'killer'; + // case(_ < PBOT1_KILLER_THRESHOLD) => true: + // 'killer'; case(_ < PBOT1_SICK_THRESHOLD) => true: 'sick'; case(_ < PBOT1_GOOD_THRESHOLD) => true: diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 9e5de6143..0434607f3 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -174,7 +174,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta difficulty.timeChanges = metadata.timeChanges; difficulty.looped = metadata.looped; difficulty.generatedBy = metadata.generatedBy; - difficulty.offsets = metadata.offsets; + difficulty.offsets = metadata?.offsets ?? new SongOffsets(); + + difficulty.difficultyRating = metadata.playData.ratings.get(diffId) ?? 0; + difficulty.album = metadata.playData.album; difficulty.stage = metadata.playData.stage; difficulty.noteStyle = metadata.playData.noteStyle; @@ -232,6 +235,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta difficulty.timeChanges = metadata.timeChanges; difficulty.looped = metadata.looped; difficulty.generatedBy = metadata.generatedBy; + difficulty.offsets = metadata?.offsets ?? new SongOffsets(); difficulty.stage = metadata.playData.stage; difficulty.noteStyle = metadata.playData.noteStyle; @@ -405,6 +409,9 @@ class SongDifficulty public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED; + public var difficultyRating:Int = 0; + public var album:Null<String> = null; + public function new(song:Song, diffId:String, variation:String) { this.song = song; diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index 639ed02da..fc23b6ace 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -12,13 +12,16 @@ import flixel.math.FlxPoint; import flixel.system.FlxAssets.FlxShader; import flixel.util.FlxSort; import openfl.display.BitmapData; +import flixel.util.FlxColor; import funkin.modding.IScriptedClass; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventType; import funkin.modding.events.ScriptEventDispatcher; import funkin.play.character.BaseCharacter; -import funkin.play.stage.StageData.StageDataCharacter; -import funkin.play.stage.StageData.StageDataParser; +import funkin.data.IRegistryEntry; +import funkin.data.stage.StageData; +import funkin.data.stage.StageData.StageDataCharacter; +import funkin.data.stage.StageRegistry; import funkin.play.stage.StageProp; import funkin.util.SortUtil; import funkin.util.assets.FlxAnimationUtil; @@ -30,14 +33,25 @@ typedef StagePropGroup = FlxTypedSpriteGroup<StageProp>; * * A Stage is comprised of one or more props, each of which is a FlxSprite. */ -class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass +class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements IRegistryEntry<StageData> { - public final stageId:String; - public final stageName:String; + public final id:String; - final _data:StageData; + public final _data:StageData; - public var camZoom:Float = 1.0; + public var stageName(get, never):String; + + function get_stageName():String + { + return _data?.name ?? 'Unknown'; + } + + public var camZoom(get, never):Float; + + function get_camZoom():Float + { + return _data?.cameraZoom ?? 1.0; + } var frameBufferMan:FrameBufferManager; @@ -55,21 +69,18 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass * They're used to cache the data needed to build the stage, * then accessed and fleshed out when the stage needs to be built. * - * @param stageId + * @param id */ - public function new(stageId:String) + public function new(id:String) { super(); - this.stageId = stageId; - _data = StageDataParser.parseStageData(this.stageId); + this.id = id; + _data = _fetchData(id); + if (_data == null) { - throw 'Could not find stage data for stageId: $stageId'; - } - else - { - this.stageName = _data.name; + throw 'Could not find stage data for stage id: $id'; } } @@ -147,9 +158,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass */ function buildStage():Void { - trace('Building stage for display: ${this.stageId}'); - - this.camZoom = _data.cameraZoom; + trace('Building stage for display: ${this.id}'); this.debugIconGroup = new FlxSpriteGroup(); @@ -157,6 +166,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass { trace(' Placing prop: ${dataProp.name} (${dataProp.assetPath})'); + var isSolidColor = dataProp.assetPath.startsWith('#'); var isAnimated = dataProp.animations.length > 0; var propSprite:StageProp; @@ -180,6 +190,22 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath); } } + else if (isSolidColor) + { + var width:Int = 1; + var height:Int = 1; + switch (dataProp.scale) + { + case Left(value): + width = Std.int(value); + height = Std.int(value); + + case Right(values): + width = Std.int(values[0]); + height = Std.int(values[1]); + } + propSprite.makeSolidColor(width, height, FlxColor.fromString(dataProp.assetPath)); + } else { // Initalize static sprite. @@ -195,13 +221,16 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass continue; } - switch (dataProp.scale) + if (!isSolidColor) { - case Left(value): - propSprite.scale.set(value); + switch (dataProp.scale) + { + case Left(value): + propSprite.scale.set(value); - case Right(values): - propSprite.scale.set(values[0], values[1]); + case Right(values): + propSprite.scale.set(values[0], values[1]); + } } propSprite.updateHitbox(); @@ -213,15 +242,8 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass // If pixel, disable antialiasing. propSprite.antialiasing = !dataProp.isPixel; - switch (dataProp.scroll) - { - case Left(value): - propSprite.scrollFactor.x = value; - propSprite.scrollFactor.y = value; - case Right(values): - propSprite.scrollFactor.x = values[0]; - propSprite.scrollFactor.y = values[1]; - } + propSprite.scrollFactor.x = dataProp.scroll[0]; + propSprite.scrollFactor.y = dataProp.scroll[1]; propSprite.zIndex = dataProp.zIndex; @@ -801,6 +823,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass } } + static function _fetchData(id:String):Null<StageData> + { + return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id)); + } + public function onScriptEvent(event:ScriptEvent) {} public function onPause(event:PauseScriptEvent) {} diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx deleted file mode 100644 index d89995ef3..000000000 --- a/source/funkin/play/stage/StageData.hx +++ /dev/null @@ -1,547 +0,0 @@ -package funkin.play.stage; - -import funkin.data.animation.AnimationData; -import funkin.play.stage.ScriptedStage; -import funkin.play.stage.Stage; -import funkin.util.VersionUtil; -import funkin.util.assets.DataAssets; -import haxe.Json; -import openfl.Assets; - -/** - * Contains utilities for loading and parsing stage data. - */ -class StageDataParser -{ - /** - * The current version string for the stage data format. - * Handle breaking changes by incrementing this value - * and adding migration to the `migrateStageData()` function. - */ - public static final STAGE_DATA_VERSION:String = "1.0.0"; - - /** - * The current version rule check for the stage data format. - */ - public static final STAGE_DATA_VERSION_RULE:String = "1.0.x"; - - static final stageCache:Map<String, Stage> = new Map<String, Stage>(); - - static final DEFAULT_STAGE_ID = 'UNKNOWN'; - - /** - * Parses and preloads the game's stage data and scripts when the game starts. - * - * If you want to force stages to be reloaded, you can just call this function again. - */ - public static function loadStageCache():Void - { - // Clear any stages that are cached if there were any. - clearStageCache(); - trace("Loading stage cache..."); - - // - // SCRIPTED STAGES - // - var scriptedStageClassNames:Array<String> = ScriptedStage.listScriptClasses(); - trace(' Instantiating ${scriptedStageClassNames.length} scripted stages...'); - for (stageCls in scriptedStageClassNames) - { - var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID); - if (stage != null) - { - trace(' Loaded scripted stage: ${stage.stageName}'); - // Disable the rendering logic for stage until it's loaded. - // Note that kill() =/= destroy() - stage.kill(); - - // Then store it. - stageCache.set(stage.stageId, stage); - } - else - { - trace(' Failed to instantiate scripted stage class: ${stageCls}'); - } - } - - // - // UNSCRIPTED STAGES - // - var stageIdList:Array<String> = DataAssets.listDataFilesInPath('stages/'); - var unscriptedStageIds:Array<String> = stageIdList.filter(function(stageId:String):Bool { - return !stageCache.exists(stageId); - }); - trace(' Instantiating ${unscriptedStageIds.length} non-scripted stages...'); - for (stageId in unscriptedStageIds) - { - var stage:Stage; - try - { - stage = new Stage(stageId); - if (stage != null) - { - trace(' Loaded stage data: ${stage.stageName}'); - stageCache.set(stageId, stage); - } - } - catch (e) - { - trace(' An error occurred while loading stage data: ${stageId}'); - // Assume error was already logged. - continue; - } - } - - trace(' Successfully loaded ${Lambda.count(stageCache)} stages.'); - } - - public static function fetchStage(stageId:String):Null<Stage> - { - if (stageCache.exists(stageId)) - { - trace('Successfully fetch stage: ${stageId}'); - var stage:Stage = stageCache.get(stageId); - stage.revive(); - return stage; - } - else - { - trace('Failed to fetch stage, not found in cache: ${stageId}'); - return null; - } - } - - static function clearStageCache():Void - { - if (stageCache != null) - { - for (stage in stageCache) - { - stage.destroy(); - } - stageCache.clear(); - } - } - - /** - * Load a stage's JSON file, parse its data, and return it. - * - * @param stageId The stage to load. - * @return The stage data, or null if validation failed. - */ - public static function parseStageData(stageId:String):Null<StageData> - { - var rawJson:String = loadStageFile(stageId); - - var stageData:StageData = migrateStageData(rawJson, stageId); - - return validateStageData(stageId, stageData); - } - - public static function listStageIds():Array<String> - { - return stageCache.keys().array(); - } - - static function loadStageFile(stagePath:String):String - { - var stageFilePath:String = Paths.json('stages/${stagePath}'); - var rawJson = Assets.getText(stageFilePath).trim(); - - while (!rawJson.endsWith("}")) - { - rawJson = rawJson.substr(0, rawJson.length - 1); - } - - return rawJson; - } - - static function migrateStageData(rawJson:String, stageId:String):Null<StageData> - { - // If you update the stage data format in a breaking way, - // handle migration here by checking the `version` value. - - try - { - var parser = new json2object.JsonParser<StageData>(); - parser.fromJson(rawJson, '$stageId.json'); - - if (parser.errors.length > 0) - { - trace('[STAGE] Failed to parse stage data'); - - for (error in parser.errors) - funkin.data.DataError.printError(error); - - return null; - } - return parser.value; - } - catch (e) - { - trace(' Error parsing data for stage: ${stageId}'); - trace(' ${e}'); - return null; - } - } - - static final DEFAULT_ANIMTYPE:String = "sparrow"; - static final DEFAULT_CAMERAZOOM:Float = 1.0; - static final DEFAULT_DANCEEVERY:Int = 0; - static final DEFAULT_ISPIXEL:Bool = false; - static final DEFAULT_NAME:String = "Untitled Stage"; - static final DEFAULT_OFFSETS:Array<Float> = [0, 0]; - static final DEFAULT_CAMERA_OFFSETS_BF:Array<Float> = [-100, -100]; - static final DEFAULT_CAMERA_OFFSETS_DAD:Array<Float> = [150, -100]; - static final DEFAULT_POSITION:Array<Float> = [0, 0]; - static final DEFAULT_SCALE:Float = 1.0; - static final DEFAULT_ALPHA:Float = 1.0; - static final DEFAULT_SCROLL:Array<Float> = [0, 0]; - static final DEFAULT_ZINDEX:Int = 0; - - static final DEFAULT_CHARACTER_DATA:StageDataCharacter = - { - zIndex: DEFAULT_ZINDEX, - position: DEFAULT_POSITION, - cameraOffsets: DEFAULT_OFFSETS, - } - - /** - * Set unspecified parameters to their defaults. - * If the parameter is mandatory, print an error message. - * @param id - * @param input - * @return The validated stage data - */ - static function validateStageData(id:String, input:StageData):Null<StageData> - { - if (input == null) - { - trace('ERROR: Could not parse stage data for "${id}".'); - return null; - } - - if (input.version == null) - { - trace('ERROR: Could not load stage data for "$id": missing version'); - return null; - } - - if (!VersionUtil.validateVersionStr(input.version, STAGE_DATA_VERSION_RULE)) - { - trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})'); - return null; - } - - if (input.name == null) - { - trace('WARN: Stage data for "$id" missing name'); - input.name = DEFAULT_NAME; - } - - if (input.cameraZoom == null) - { - input.cameraZoom = DEFAULT_CAMERAZOOM; - } - - if (input.props == null) - { - input.props = []; - } - - for (inputProp in input.props) - { - // It's fine for inputProp.name to be null - - if (inputProp.assetPath == null) - { - trace('ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"'); - return null; - } - - if (inputProp.position == null) - { - inputProp.position = DEFAULT_POSITION; - } - - if (inputProp.zIndex == null) - { - inputProp.zIndex = DEFAULT_ZINDEX; - } - - if (inputProp.isPixel == null) - { - inputProp.isPixel = DEFAULT_ISPIXEL; - } - - if (inputProp.danceEvery == null) - { - inputProp.danceEvery = DEFAULT_DANCEEVERY; - } - - if (inputProp.animType == null) - { - inputProp.animType = DEFAULT_ANIMTYPE; - } - - switch (inputProp.scale) - { - case null: - inputProp.scale = Right([DEFAULT_SCALE, DEFAULT_SCALE]); - case Left(value): - inputProp.scale = Right([value, value]); - case Right(_): - // Do nothing - } - - switch (inputProp.scroll) - { - case null: - inputProp.scroll = Right(DEFAULT_SCROLL); - case Left(value): - inputProp.scroll = Right([value, value]); - case Right(_): - // Do nothing - } - - if (inputProp.alpha == null) - { - inputProp.alpha = DEFAULT_ALPHA; - } - - if (inputProp.animations == null) - { - inputProp.animations = []; - } - - if (inputProp.animations.length == 0 && inputProp.startingAnimation != null) - { - trace('ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"'); - return null; - } - - for (inputAnimation in inputProp.animations) - { - if (inputAnimation.name == null) - { - trace('ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"'); - return null; - } - - if (inputAnimation.frameRate == null) - { - inputAnimation.frameRate = 24; - } - - if (inputAnimation.offsets == null) - { - inputAnimation.offsets = DEFAULT_OFFSETS; - } - - if (inputAnimation.looped == null) - { - inputAnimation.looped = true; - } - - if (inputAnimation.flipX == null) - { - inputAnimation.flipX = false; - } - - if (inputAnimation.flipY == null) - { - inputAnimation.flipY = false; - } - } - } - - if (input.characters == null) - { - trace('ERROR: Could not load stage data for "$id": missing characters'); - return null; - } - - if (input.characters.bf == null) - { - input.characters.bf = DEFAULT_CHARACTER_DATA; - } - if (input.characters.dad == null) - { - input.characters.dad = DEFAULT_CHARACTER_DATA; - } - if (input.characters.gf == null) - { - input.characters.gf = DEFAULT_CHARACTER_DATA; - } - - for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf]) - { - if (inputCharacter.position == null || inputCharacter.position.length != 2) - { - inputCharacter.position = [0, 0]; - } - } - - // All good! - return input; - } -} - -class StageData -{ - /** - * The sematic version number of the stage data JSON format. - * Supports fancy comparisons like NPM does it's neat. - */ - public var version:String; - - public var name:String; - public var cameraZoom:Null<Float>; - public var props:Array<StageDataProp>; - public var characters:StageDataCharacters; - - public function new() - { - this.version = StageDataParser.STAGE_DATA_VERSION; - } - - /** - * Convert this StageData into a JSON string. - */ - public function serialize(pretty:Bool = true):String - { - var writer = new json2object.JsonWriter<StageData>(); - return writer.write(this, pretty ? ' ' : null); - } -} - -typedef StageDataCharacters = -{ - var bf:StageDataCharacter; - var dad:StageDataCharacter; - var gf:StageDataCharacter; -}; - -typedef StageDataProp = -{ - /** - * The name of the prop for later lookup by scripts. - * Optional; if unspecified, the prop can't be referenced by scripts. - */ - @:optional - var name:String; - - /** - * The asset used to display the prop. - */ - var assetPath:String; - - /** - * The position of the prop as an [x, y] array of two floats. - */ - var position:Array<Float>; - - /** - * A number determining the stack order of the prop, relative to other props and the characters in the stage. - * Props with lower numbers render below those with higher numbers. - * This is just like CSS, it isn't hard. - * @default 0 - */ - @:optional - @:default(0) - var zIndex:Int; - - /** - * If set to true, anti-aliasing will be forcibly disabled on the sprite. - * This prevents blurry images on pixel-art levels. - * @default false - */ - @:optional - @:default(false) - var isPixel:Bool; - - /** - * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats. - * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory. - */ - @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats) - @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats) - @:optional - var scale:haxe.ds.Either<Float, Array<Float>>; - - /** - * The alpha of the prop, as a float. - * @default 1.0 - */ - @:optional - @:default(1.0) - var alpha:Float; - - /** - * If not zero, this prop will play an animation every X beats of the song. - * This requires animations to be defined. If `danceLeft` and `danceRight` are defined, - * they will alternated between, otherwise the `idle` animation will be used. - * - * @default 0 - */ - @:default(0) - @:optional - var danceEvery:Int; - - /** - * How much the prop scrolls relative to the camera. Used to create a parallax effect. - * Represented as a float or as an [x, y] array of two floats. - * [1, 1] means the prop moves 1:1 with the camera. - * [0.5, 0.5] means the prop half as much as the camera. - * [0, 0] means the prop is not moved. - * @default [0, 0] - */ - @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats) - @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats) - @:optional - var scroll:haxe.ds.Either<Float, Array<Float>>; - - /** - * An optional array of animations which the prop can play. - * @default Prop has no animations. - */ - @:optional - var animations:Array<AnimationData>; - - /** - * If animations are used, this is the name of the animation to play first. - * @default Don't play an animation. - */ - @:optional - var startingAnimation:Null<String>; - - /** - * The animation type to use. - * Options: "sparrow", "packer" - * @default "sparrow" - */ - @:default("sparrow") - @:optional - var animType:String; -}; - -typedef StageDataCharacter = -{ - /** - * A number determining the stack order of the character, relative to props and other characters in the stage. - * Again, just like CSS. - * @default 0 - */ - var zIndex:Int; - - /** - * The position to render the character at. - */ - var position:Array<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 - */ - var cameraOffsets:Array<Float>; -}; diff --git a/source/funkin/play/stage/StageProp.hx b/source/funkin/play/stage/StageProp.hx index 4f67c5e4b..4d846162b 100644 --- a/source/funkin/play/stage/StageProp.hx +++ b/source/funkin/play/stage/StageProp.hx @@ -1,10 +1,10 @@ package funkin.play.stage; import funkin.modding.events.ScriptEvent; -import flixel.FlxSprite; +import funkin.graphics.FunkinSprite; import funkin.modding.IScriptedClass.IStateStageProp; -class StageProp extends FlxSprite implements IStateStageProp +class StageProp extends FunkinSprite implements IStateStageProp { /** * An internal name for this prop. diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 810d0fd93..6a4dd048c 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -14,8 +14,8 @@ import thx.semver.Version; @:forward(volume, mute) abstract Save(RawSaveData) { - // Version 2.0.1 adds attributes to `optionsChartEditor`, that should return default values if they are null. - public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.1"; + // 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"; public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility. @@ -108,9 +108,7 @@ abstract Save(RawSaveData) metronomeVolume: 1.0, hitsoundsEnabledPlayer: true, hitsoundsEnabledOpponent: true, - instVolume: 1.0, - voicesVolume: 1.0, - playbackSpeed: 1.0, + themeMusic: true }, }; } @@ -347,38 +345,21 @@ abstract Save(RawSaveData) return this.optionsChartEditor.hitsoundsEnabledOpponent; } - public var chartEditorInstVolume(get, set):Float; + public var chartEditorThemeMusic(get, set):Bool; - function get_chartEditorInstVolume():Float + function get_chartEditorThemeMusic():Bool { - if (this.optionsChartEditor.instVolume == null) this.optionsChartEditor.instVolume = 1.0; + if (this.optionsChartEditor.themeMusic == null) this.optionsChartEditor.themeMusic = true; - return this.optionsChartEditor.instVolume; + return this.optionsChartEditor.themeMusic; } - function set_chartEditorInstVolume(value:Float):Float + function set_chartEditorThemeMusic(value:Bool):Bool { // Set and apply. - this.optionsChartEditor.instVolume = value; + this.optionsChartEditor.themeMusic = value; flush(); - return this.optionsChartEditor.instVolume; - } - - public var chartEditorVoicesVolume(get, set):Float; - - function get_chartEditorVoicesVolume():Float - { - if (this.optionsChartEditor.voicesVolume == null) this.optionsChartEditor.voicesVolume = 1.0; - - return this.optionsChartEditor.voicesVolume; - } - - function set_chartEditorVoicesVolume(value:Float):Float - { - // Set and apply. - this.optionsChartEditor.voicesVolume = value; - flush(); - return this.optionsChartEditor.voicesVolume; + return this.optionsChartEditor.themeMusic; } public var chartEditorPlaybackSpeed(get, set):Float; @@ -774,7 +755,6 @@ typedef SaveScoreData = typedef SaveScoreTallyData = { - var killer:Int; var sick:Int; var good:Int; var bad:Int; @@ -1027,6 +1007,12 @@ typedef SaveDataChartEditorOptions = */ var ?hitsoundsEnabledOpponent:Bool; + /** + * Theme music in the Chart Editor. + * @default `true` + */ + var ?themeMusic:Bool; + /** * Instrumental volume in the Chart Editor. * @default `1.0` diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx index d5b23cfd9..92bee4ceb 100644 --- a/source/funkin/save/migrator/SaveDataMigrator.hx +++ b/source/funkin/save/migrator/SaveDataMigrator.hx @@ -120,7 +120,6 @@ class SaveDataMigrator accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, @@ -140,7 +139,6 @@ class SaveDataMigrator accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, @@ -160,7 +158,6 @@ class SaveDataMigrator accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, @@ -183,7 +180,6 @@ class SaveDataMigrator accuracy: 0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, @@ -209,7 +205,6 @@ class SaveDataMigrator accuracy: 0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, @@ -235,7 +230,6 @@ class SaveDataMigrator accuracy: 0, tallies: { - killer: 0, sick: 0, good: 0, bad: 0, diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx index fea09de54..186d87c2a 100644 --- a/source/funkin/ui/AtlasText.hx +++ b/source/funkin/ui/AtlasText.hx @@ -274,4 +274,5 @@ enum abstract AtlasFont(String) from String to String { var DEFAULT = "default"; var BOLD = "bold"; + var FREEPLAY_CLEAR = "freeplay-clear"; } diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx index 077e9e495..33333565f 100644 --- a/source/funkin/ui/MusicBeatState.hx +++ b/source/funkin/ui/MusicBeatState.hx @@ -80,25 +80,11 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler if (FlxG.keys.justPressed.F5) debug_refreshModules(); } - function handleQuickWatch():Void - { - // Display Conductor info in the watch window. - FlxG.watch.addQuick("songPosition", Conductor.songPosition); - FlxG.watch.addQuick("songPositionNoOffset", Conductor.songPosition + Conductor.instrumentalOffset); - FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); - FlxG.watch.addQuick("bpm", Conductor.bpm); - FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); - FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime); - FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime); - } - override function update(elapsed:Float) { super.update(elapsed); handleControls(); - handleFunctionControls(); - handleQuickWatch(); dispatchEvent(new UpdateScriptEvent(elapsed)); } @@ -139,7 +125,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler public function stepHit():Bool { - var event = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep); + var event = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep); dispatchEvent(event); @@ -150,7 +136,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler public function beatHit():Bool { - var event = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep); + var event = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep); dispatchEvent(event); diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx index 9dd755b62..0fa55c234 100644 --- a/source/funkin/ui/MusicBeatSubState.hx +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -65,12 +65,8 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl if (FlxG.keys.justPressed.F5) debug_refreshModules(); // Display Conductor info in the watch window. - FlxG.watch.addQuick("songPosition", Conductor.songPosition); FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); - FlxG.watch.addQuick("bpm", Conductor.bpm); - FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); - FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime); - FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime); + Conductor.watchQuick(); dispatchEvent(new UpdateScriptEvent(elapsed)); } @@ -99,7 +95,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl */ public function stepHit():Bool { - var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep); + var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep); dispatchEvent(event); @@ -115,7 +111,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl */ public function beatHit():Bool { - var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep); + var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep); dispatchEvent(event); diff --git a/source/funkin/ui/debug/WaveformTestState.hx b/source/funkin/ui/debug/WaveformTestState.hx new file mode 100644 index 000000000..f3566db85 --- /dev/null +++ b/source/funkin/ui/debug/WaveformTestState.hx @@ -0,0 +1,191 @@ +package funkin.ui.debug; + +import flixel.math.FlxRect; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformSprite; +import funkin.audio.waveform.WaveformDataParser; +import funkin.graphics.rendering.MeshRender; + +class WaveformTestState extends MusicBeatState +{ + public function new() + { + super(); + } + + var waveformData:WaveformData; + var waveformData2:WaveformData; + + var waveformAudio:FunkinSound; + + // var waveformSprite:WaveformSprite; + // var waveformSprite2:WaveformSprite; + var timeMarker:FlxSprite; + + var polygonSprite:MeshRender; + var vertexCount:Int = 3; + + public override function create():Void + { + super.create(); + + var testSprite = new FlxSprite(0, 0); + testSprite.loadGraphic(Paths.image('funkay')); + testSprite.updateHitbox(); + testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(testSprite); + + waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect')); + + waveformData = WaveformDataParser.interpretFlxSound(waveformAudio); + + polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE); + + setPolygonVertices(vertexCount); + add(polygonSprite); + + // waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6")); + // waveformSprite.duration = 5.0 * 160; + // waveformSprite.width = FlxG.width * 160; + // waveformSprite.height = FlxG.height; // / 2; + // waveformSprite.amplitude = 2.0; + // waveformSprite.minWaveformSize = 25; + // waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(waveformSprite); + // + // waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0); + // waveformSprite2.width = FlxG.width; + // waveformSprite2.height = FlxG.height / 2; + // waveformSprite2.y = FlxG.height / 2; + // add(waveformSprite2); + + timeMarker = new FlxSprite(0, FlxG.height * 1 / 6); + timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED); + add(timeMarker); + + // drawWaveform(time, duration); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.SPACE) + { + if (waveformAudio.isPlaying) + { + waveformAudio.stop(); + } + else + { + waveformAudio.play(); + } + } + + if (FlxG.keys.justPressed.ENTER) + { + // if (waveformSprite.orientation == HORIZONTAL) + // { + // // waveformSprite.orientation = VERTICAL; + // // waveformSprite2.orientation = VERTICAL; + // } + // else + // { + // // waveformSprite.orientation = HORIZONTAL; + // // waveformSprite2.orientation = HORIZONTAL; + // } + } + + if (waveformAudio.isPlaying) + { + // waveformSprite takes a time in fractional seconds, not milliseconds. + var timeSeconds = waveformAudio.time / 1000; + // waveformSprite.time = timeSeconds; + // waveformSprite2.time = timeSeconds; + } + + if (FlxG.keys.justPressed.UP) + { + vertexCount += 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration += 1.0; + // waveformSprite2.duration += 1.0; + } + if (FlxG.keys.justPressed.DOWN) + { + vertexCount -= 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration -= 1.0; + // waveformSprite2.duration -= 1.0; + } + if (FlxG.keys.justPressed.LEFT) + { + // waveformSprite.time -= 1.0; + // waveformSprite2.time -= 1.0; + } + if (FlxG.keys.justPressed.RIGHT) + { + // waveformSprite.time += 1.0; + // waveformSprite2.time += 1.0; + } + } + + function setPolygonVertices(count:Int) + { + polygonSprite.clear(); + + var size = 100.0; + + // Build a polygon with count vertices. + + var vertices:Array<Array<Float>> = []; + + var angle = 0.0; + + for (i in 0...count) + { + var x = Math.cos(angle) * size; + var y = Math.sin(angle) * size; + + vertices.push([x, y]); + + angle += 2 * Math.PI / count; + } + + trace('vertices: ${vertices}'); + + var centerVertex = polygonSprite.build_vertex(0, 0); + var firstVertex = -1; + var lastVertex = -1; + + for (vertex in vertices) + { + var x = vertex[0]; + var y = vertex[1]; + + var newVertex = polygonSprite.build_vertex(x, y); + + if (firstVertex == -1) + { + firstVertex = newVertex; + } + + if (lastVertex != -1) + { + polygonSprite.add_tri(centerVertex, lastVertex, newVertex); + } + + lastVertex = newVertex; + } + + polygonSprite.add_tri(centerVertex, lastVertex, firstVertex); + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index fa55750bf..53325acb8 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -1,41 +1,52 @@ package funkin.ui.debug.charting; -import funkin.util.logging.CrashHandler; -import haxe.ui.containers.HBox; -import haxe.ui.containers.Grid; -import haxe.ui.containers.ScrollView; -import haxe.ui.containers.menus.MenuBar; import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxTiledSprite; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxCamera; import flixel.FlxSprite; import flixel.FlxSubState; +import flixel.graphics.FlxGraphic; +import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxSpriteGroup; import flixel.input.keyboard.FlxKey; +import flixel.input.mouse.FlxMouseEvent; import flixel.math.FlxMath; import flixel.math.FlxPoint; import flixel.math.FlxRect; import flixel.sound.FlxSound; +import flixel.system.debug.log.LogStyle; import flixel.system.FlxAssets.FlxSoundAsset; +import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.tweens.misc.VarTween; import flixel.util.FlxColor; import flixel.util.FlxSort; import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.audio.visualize.PolygonSpectogram; import funkin.audio.visualize.PolygonSpectogram; import funkin.audio.VoicesGroup; -import funkin.audio.FunkinSound; +import funkin.audio.waveform.WaveformSprite; import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongDataUtils; +import funkin.data.song.SongDataUtils; import funkin.data.song.SongRegistry; +import funkin.data.song.SongRegistry; +import funkin.data.stage.StageData; +import funkin.graphics.FunkinSprite; import funkin.input.Cursor; import funkin.input.TurboKeyHandler; import funkin.modding.events.ScriptEvent; @@ -46,20 +57,13 @@ import funkin.play.components.HealthIcon; import funkin.play.notes.NoteSprite; import funkin.play.PlayState; import funkin.play.song.Song; -import funkin.data.song.SongData.SongChartData; -import funkin.data.song.SongRegistry; -import funkin.data.song.SongData.SongEventData; -import funkin.data.song.SongData.SongMetadata; -import funkin.data.song.SongData.SongNoteData; -import funkin.data.song.SongData.SongCharacterData; -import funkin.data.song.SongDataUtils; -import funkin.ui.debug.charting.commands.ChartEditorCommand; -import funkin.play.stage.StageData; import funkin.save.Save; import funkin.ui.debug.charting.commands.AddEventsCommand; import funkin.ui.debug.charting.commands.AddNotesCommand; import funkin.ui.debug.charting.commands.ChartEditorCommand; import funkin.ui.debug.charting.commands.ChartEditorCommand; +import funkin.ui.debug.charting.commands.ChartEditorCommand; +import funkin.ui.debug.charting.commands.CopyItemsCommand; import funkin.ui.debug.charting.commands.CutItemsCommand; import funkin.ui.debug.charting.commands.DeselectAllItemsCommand; import funkin.ui.debug.charting.commands.DeselectItemsCommand; @@ -78,16 +82,22 @@ import funkin.ui.debug.charting.commands.SelectItemsCommand; import funkin.ui.debug.charting.commands.SetItemSelectionCommand; import funkin.ui.debug.charting.components.ChartEditorEventSprite; import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite; +import funkin.ui.debug.charting.components.ChartEditorMeasureTicks; import funkin.ui.debug.charting.components.ChartEditorNotePreview; import funkin.ui.debug.charting.components.ChartEditorNoteSprite; import funkin.ui.debug.charting.components.ChartEditorPlaybarHead; import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite; import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler; +import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox; import funkin.ui.haxeui.components.CharacterPlayer; import funkin.ui.haxeui.HaxeUIState; import funkin.ui.mainmenu.MainMenuState; import funkin.util.Constants; import funkin.util.FileUtil; +import funkin.util.logging.CrashHandler; import funkin.util.SortUtil; import funkin.util.WindowUtil; import haxe.DynamicAccess; @@ -95,22 +105,26 @@ import haxe.io.Bytes; import haxe.io.Path; import haxe.ui.backend.flixel.UIRuntimeState; import haxe.ui.backend.flixel.UIState; -import haxe.ui.components.DropDown; -import haxe.ui.components.Label; import haxe.ui.components.Button; +import haxe.ui.components.DropDown; +import haxe.ui.components.Image; +import haxe.ui.components.Label; import haxe.ui.components.NumberStepper; import haxe.ui.components.Slider; import haxe.ui.components.TextField; +import haxe.ui.containers.Box; import haxe.ui.containers.dialogs.CollapsibleDialog; import haxe.ui.containers.Frame; +import haxe.ui.containers.Grid; +import haxe.ui.containers.HBox; import haxe.ui.containers.menus.Menu; import haxe.ui.containers.menus.MenuBar; -import haxe.ui.containers.menus.MenuItem; +import haxe.ui.containers.menus.MenuBar; import haxe.ui.containers.menus.MenuCheckBox; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.containers.ScrollView; import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; -import haxe.ui.components.Image; -import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox; import haxe.ui.core.Component; import haxe.ui.core.Screen; import haxe.ui.events.DragEvent; @@ -118,12 +132,8 @@ import haxe.ui.events.MouseEvent; import haxe.ui.events.UIEvent; import haxe.ui.events.UIEvent; import haxe.ui.focus.FocusManager; +import haxe.ui.Toolkit; import openfl.display.BitmapData; -import funkin.audio.visualize.PolygonSpectogram; -import flixel.group.FlxGroup.FlxTypedGroup; -import funkin.audio.visualize.PolygonVisGroup; -import flixel.input.mouse.FlxMouseEvent; -import flixel.text.FlxText; using Lambda; @@ -145,17 +155,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ // ============================== // Layouts - public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata'); - - public static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata'); - public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties'); - public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata'); public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty'); + public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview'); public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview'); + public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata'); + public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets'); + public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata'); + public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata'); + public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay'); + public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties'); // Validation - public static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg']; + public static final SUPPORTED_MUSIC_FORMATS:Array<String> = #if sys ['ogg'] #else ['mp3'] #end; // Layout @@ -167,7 +179,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState /** * The width of the scroll area. */ - public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12; + public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = Std.int(GRID_SIZE); /** * The height of the playhead, in pixels. @@ -189,10 +201,40 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ public static final PLAYBAR_HEIGHT:Int = 48; + /** + * The height of the note selection buttons above the grid. + */ + public static final NOTE_SELECT_BUTTON_HEIGHT:Int = 24; + /** * The amount of padding between the menu bar and the chart grid when fully scrolled up. */ - public static final GRID_TOP_PAD:Int = 8; + public static final GRID_TOP_PAD:Int = NOTE_SELECT_BUTTON_HEIGHT + 12; + + /** + * The initial vertical position of the chart grid. + */ + public static final GRID_INITIAL_Y_POS:Int = MENU_BAR_HEIGHT + GRID_TOP_PAD; + + /** + * The X position of the note preview area. + */ + public static final NOTE_PREVIEW_X_POS:Int = 320; + + /** + * The Y position of the note preview area. + */ + public static final NOTE_PREVIEW_Y_POS:Int = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 4; + + /** + * The X position of the note grid. + */ + public static var GRID_X_POS(get, never):Float; + + static function get_GRID_X_POS():Float + { + return FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; + } // Colors // Background color tint. @@ -236,6 +278,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ public static final BASE_QUANT_INDEX:Int = 3; + /** + * The duration before the welcome music starts to fade back in after the user stops playing music in the chart editor. + */ + public static final WELCOME_MUSIC_FADE_IN_DELAY:Float = 30.0; + + /** + * The duration of the welcome music fade in. + */ + public static final WELCOME_MUSIC_FADE_IN_DURATION:Float = 10.0; + /** * INSTANCE DATA */ @@ -273,13 +325,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_songLengthInSteps():Float { - return Conductor.getTimeInSteps(songLengthInMs); + return Conductor.instance.getTimeInSteps(songLengthInMs); } function set_songLengthInSteps(value:Float):Float { - // Getting a reasonable result from setting songLengthInSteps requires that Conductor.mapBPMChanges be called first. - songLengthInMs = Conductor.getStepTimeInMs(value); + // Getting a reasonable result from setting songLengthInSteps requires that Conductor.instance.mapBPMChanges be called first. + songLengthInMs = Conductor.instance.getStepTimeInMs(value); return value; } @@ -333,25 +385,24 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.scrollPositionInPixels = value; // Move the grid sprite to the correct position. - if (gridTiledSprite != null && gridPlayheadScrollArea != null) + if (gridTiledSprite != null && measureTicks != null) { if (isViewDownscroll) { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - gridPlayheadScrollArea.y = gridTiledSprite.y; + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); + measureTicks.y = gridTiledSprite.y; } else { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); - gridPlayheadScrollArea.y = gridTiledSprite.y; + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); + measureTicks.y = gridTiledSprite.y; - if (audioVisGroup != null && audioVisGroup.playerVis != null) + for (member in audioWaveforms.members) { - audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT); - } - if (audioVisGroup != null && audioVisGroup.opponentVis != null) - { - audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT); + member.time = scrollPositionInMs / Constants.MS_PER_SEC; + + // Doing this desyncs the waveforms from the grid. + // member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); } } } @@ -365,6 +416,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff; // Update the note preview viewport box. setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + // Update the measure tick display. + if (measureTicks != null) measureTicks.y = gridTiledSprite?.y ?? 0.0; return this.scrollPositionInPixels; } @@ -393,12 +446,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_scrollPositionInMs():Float { - return Conductor.getStepTimeInMs(scrollPositionInSteps); + return Conductor.instance.getStepTimeInMs(scrollPositionInSteps); } function set_scrollPositionInMs(value:Float):Float { - scrollPositionInSteps = Conductor.getTimeInSteps(value); + scrollPositionInSteps = Conductor.instance.getTimeInSteps(value); return value; } @@ -421,7 +474,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.playheadPositionInPixels = value; // Move the playhead sprite to the correct position. - gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + gridPlayhead.y = this.playheadPositionInPixels + GRID_INITIAL_Y_POS; return this.playheadPositionInPixels; } @@ -451,16 +504,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_playheadPositionInMs():Float { - if (audioVisGroup != null && audioVisGroup.playerVis != null) - audioVisGroup.playerVis.realtimeStartOffset = -Conductor.getStepTimeInMs(playheadPositionInSteps); - return Conductor.getStepTimeInMs(playheadPositionInSteps); + return Conductor.instance.getStepTimeInMs(playheadPositionInSteps); } function set_playheadPositionInMs(value:Float):Float { - playheadPositionInSteps = Conductor.getTimeInSteps(value); + playheadPositionInSteps = Conductor.instance.getTimeInSteps(value); - if (audioVisGroup != null && audioVisGroup.playerVis != null) audioVisGroup.playerVis.realtimeStartOffset = -value; return value; } @@ -489,17 +539,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState /** * The note kind to use for notes being placed in the chart. Defaults to `''`. */ - var selectedNoteKind:String = ''; + var noteKindToPlace:String = ''; /** * The event type to use for events being placed in the chart. Defaults to `''`. */ - var selectedEventKind:String = 'FocusCamera'; + var eventKindToPlace:String = 'FocusCamera'; /** * The event data to use for events being placed in the chart. */ - var selectedEventData:DynamicAccess<Dynamic> = {}; + var eventDataToPlace:DynamicAccess<Dynamic> = {}; /** * The internal index of what note snapping value is in use. @@ -544,6 +594,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var playtestPracticeMode:Bool = false; + /** + * Enables or disables the "debugger" popup that appears when you run into a flixel error. + */ + var enabledDebuggerPopup:Bool = true; + // Visuals /** @@ -635,6 +690,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>(); + /** + * The camera component we're using for this state. + */ + var uiCamera:FlxCamera; + // Audio /** @@ -707,7 +767,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState * `null` if the user isn't currently placing a note. * As the user drags, we will update this note's sustain length, and finalize the note when they release. */ - var currentPlaceNoteData:Null<SongNoteData> = null; + var currentPlaceNoteData(default, set):Null<SongNoteData> = null; + + function set_currentPlaceNoteData(value:Null<SongNoteData>):Null<SongNoteData> + { + noteDisplayDirty = true; + + return currentPlaceNoteData = value; + } // Note Movement @@ -753,15 +820,23 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function set_currentNoteSelection(value:Array<SongNoteData>):Array<SongNoteData> { + // This value is true if all elements of the current selection are also in the new selection. + var isSuperset:Bool = currentNoteSelection.isSubset(value); + var isEqual:Bool = currentNoteSelection.isEqualUnordered(value); + currentNoteSelection = value; - if (currentNoteSelection.length > 0) + if (!isEqual) { - notePreview.addNotes(currentNoteSelection, Std.int(songLengthInMs), true); - } - else - { - notePreviewDirty = true; + if (currentNoteSelection.length > 0 && isSuperset) + { + notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs)); + } + else + { + // The new selection removes elements from the old selection, so we have to redraw the note preview. + notePreviewDirty = true; + } } return currentNoteSelection; @@ -860,6 +935,70 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return Save.get().chartEditorHasBackup = value; } + /** + * A list of previous working file paths. + * Also known as the "recent files" list. + * The first element is [null] if the current working file has not been saved anywhere yet. + */ + public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null]; + + function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>> + { + // Called only when the WHOLE LIST is overridden. + previousWorkingFilePaths = value; + applyWindowTitle(); + populateOpenRecentMenu(); + applyCanQuickSave(); + return value; + } + + /** + * The current file path which the chart editor is working with. + * If `null`, the current chart has not been saved yet. + */ + public var currentWorkingFilePath(get, set):Null<String>; + + function get_currentWorkingFilePath():Null<String> + { + return previousWorkingFilePaths[0]; + } + + function set_currentWorkingFilePath(value:Null<String>):Null<String> + { + if (value == previousWorkingFilePaths[0]) return value; + + if (previousWorkingFilePaths.contains(null)) + { + // Filter all instances of `null` from the array. + previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool { + return x != null; + }); + } + + if (previousWorkingFilePaths.contains(value)) + { + // Move the path to the front of the list. + previousWorkingFilePaths.remove(value); + previousWorkingFilePaths.unshift(value); + } + else + { + // Add the path to the front of the list. + previousWorkingFilePaths.unshift(value); + } + + while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES) + { + // Remove the last path in the list. + previousWorkingFilePaths.pop(); + } + + populateOpenRecentMenu(); + applyWindowTitle(); + + return value; + } + /** * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. * This happens when we add/remove difficulties. @@ -889,6 +1028,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var commandHistoryDirty:Bool = true; + /** + * If true, we are currently in the process of quitting the chart editor. + * Skip any update functions as most of them will call a crash. + */ + var criticalFailure:Bool = false; + // Input /** @@ -961,14 +1106,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState * `null` until vocal track(s) are loaded. * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. */ - var audioVocalTrackGroup:Null<VoicesGroup> = null; + var audioVocalTrackGroup:VoicesGroup = new VoicesGroup(); /** - * The audio vis for the inst/vocals. + * The audio waveform visualization for the inst/vocals. * `null` until vocal track(s) are loaded. - * When switching characters, the elements of the PolygonVisGroup will be swapped to match the new character. + * When switching characters, the elements will be swapped to match the new character. */ - var audioVisGroup:Null<PolygonVisGroup> = null; + var audioWaveforms:FlxTypedSpriteGroup<WaveformSprite> = new FlxTypedSpriteGroup<WaveformSprite>(); /** * A map of the audio tracks for each character's vocals. @@ -1010,7 +1155,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_availableDifficulties():Array<String> { var m:Null<SongMetadata> = songMetadata.get(selectedVariation); - return m?.playData?.difficulties ?? []; + return m?.playData?.difficulties ?? [Constants.DEFAULT_DIFFICULTY]; } /** @@ -1069,7 +1214,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var result:Null<SongChartData> = songChartData.get(selectedVariation); if (result == null) { - result = new SongChartData(["normal" => 1.0], [], ["normal" => []]); + result = new SongChartData([Constants.DEFAULT_DIFFICULTY => 1.0], [], [Constants.DEFAULT_DIFFICULTY => []]); songChartData.set(selectedVariation, result); } return result; @@ -1168,6 +1313,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return currentSongMetadata.playData.noteStyle = value; } + var currentSongFreeplayPreviewStart(get, set):Int; + + function get_currentSongFreeplayPreviewStart():Int + { + return currentSongMetadata.playData.previewStart; + } + + function set_currentSongFreeplayPreviewStart(value:Int):Int + { + return currentSongMetadata.playData.previewStart = value; + } + + var currentSongFreeplayPreviewEnd(get, set):Int; + + function get_currentSongFreeplayPreviewEnd():Int + { + return currentSongMetadata.playData.previewEnd; + } + + function set_currentSongFreeplayPreviewEnd(value:Int):Int + { + return currentSongMetadata.playData.previewEnd = value; + } + var currentSongStage(get, set):String; function get_currentSongStage():String @@ -1226,6 +1395,46 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return currentSongMetadata.artist = value; } + /** + * Convenience property to get the player charId for the current variation. + */ + var currentPlayerChar(get, set):String; + + function get_currentPlayerChar():String + { + if (currentSongMetadata.playData.characters.player == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.characters.player = Constants.DEFAULT_CHARACTER; + } + return currentSongMetadata.playData.characters.player; + } + + function set_currentPlayerChar(value:String):String + { + return currentSongMetadata.playData.characters.player = value; + } + + /** + * Convenience property to get the opponent charId for the current variation. + */ + var currentOpponentChar(get, set):String; + + function get_currentOpponentChar():String + { + if (currentSongMetadata.playData.characters.opponent == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.characters.opponent = Constants.DEFAULT_CHARACTER; + } + return currentSongMetadata.playData.characters.opponent; + } + + function set_currentOpponentChar(value:String):String + { + return currentSongMetadata.playData.characters.opponent = value; + } + /** * Convenience property to get the song offset data for the current variation. */ @@ -1261,6 +1470,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return value; } + var currentVocalOffsetPlayer(get, set):Float; + + function get_currentVocalOffsetPlayer():Float + { + return currentSongOffsets.getVocalOffset(currentPlayerChar); + } + + function set_currentVocalOffsetPlayer(value:Float):Float + { + currentSongOffsets.setVocalOffset(currentPlayerChar, value); + return value; + } + + var currentVocalOffsetOpponent(get, set):Float; + + function get_currentVocalOffsetOpponent():Float + { + return currentSongOffsets.getVocalOffset(currentOpponentChar); + } + + function set_currentVocalOffsetOpponent(value:Float):Float + { + currentSongOffsets.setVocalOffset(currentOpponentChar, value); + return value; + } + /** * The variation ID for the difficulty which is currently being edited. */ @@ -1293,6 +1528,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function set_selectedDifficulty(value:String):String { + if (value == null) value = availableDifficulties[0] ?? Constants.DEFAULT_DIFFICULTY; + selectedDifficulty = value; // Make sure view is updated when the difficulty changes. @@ -1506,6 +1743,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var menubarItemOpponentHitsounds:MenuCheckBox; + /** + * The `Audio -> Play Theme Music` menu checkbox. + */ + var menubarItemThemeMusic:MenuCheckBox; + /** * The `Audio -> Hitsound Volume` label. */ @@ -1527,14 +1769,24 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var menubarItemVolumeInstrumental:Slider; /** - * The `Audio -> Vocal Volume` label. + * The `Audio -> Player Volume` label. */ - var menubarLabelVolumeVocals:Label; + var menubarLabelVolumeVocalsPlayer:Label; /** - * The `Audio -> Vocal Volume` slider. + * The `Audio -> Enemy Volume` label. */ - var menubarItemVolumeVocals:Slider; + var menubarLabelVolumeVocalsOpponent:Label; + + /** + * The `Audio -> Player Volume` slider. + */ + var menubarItemVolumeVocalsPlayer:Slider; + + /** + * The `Audio -> Enemy Volume` slider. + */ + var menubarItemVolumeVocalsOpponent:Slider; /** * The `Audio -> Playback Speed` label. @@ -1586,6 +1838,36 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var playbarEnd:Button; + /** + * The button above the grid that selects all notes on the opponent's side. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectOpponent:Button; + + /** + * The button above the grid that selects all notes on the player's side. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectPlayer:Button; + + /** + * The button above the grid that selects all song events. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectEvent:Button; + + /** + * The slider above the grid that sets the volume of the player's sounds. + * Constructed manually and added to the layout so we can control its position. + */ + var sliderVolumePlayer:Slider; + + /** + * The slider above the grid that sets the volume of the opponent's sounds. + * Constructed manually and added to the layout so we can control its position. + */ + var sliderVolumeOpponent:Slider; + /** * RENDER OBJECTS */ @@ -1614,23 +1896,33 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var notePreviewViewportBitmap:Null<BitmapData> = null; + /** + * The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler. + */ + var measureTickBitmap:Null<BitmapData> = null; + + /** + * The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler. + */ + var offsetTickBitmap:Null<BitmapData> = null; + /** * The tiled sprite used to display the grid. * The height is the length of the song, and scrolling is done by simply the sprite. */ var gridTiledSprite:Null<FlxSprite> = null; + /** + * The measure ticks area. Includes the numbers and the background sprite. + */ + var measureTicks:Null<ChartEditorMeasureTicks> = null; + /** * The playhead representing the current position in the song. * Can move around on the grid independently of the view. */ var gridPlayhead:FlxSpriteGroup = new FlxSpriteGroup(); - /** - * The sprite for the scroll area under - */ - var gridPlayheadScrollArea:Null<FlxSprite> = null; - /** * A sprite used to indicate the note that will be placed on click. */ @@ -1717,70 +2009,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var params:Null<ChartEditorParams>; - /** - * A list of previous working file paths. - * Also known as the "recent files" list. - * The first element is [null] if the current working file has not been saved anywhere yet. - */ - public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null]; - - function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>> - { - // Called only when the WHOLE LIST is overridden. - previousWorkingFilePaths = value; - applyWindowTitle(); - populateOpenRecentMenu(); - applyCanQuickSave(); - return value; - } - - /** - * The current file path which the chart editor is working with. - * If `null`, the current chart has not been saved yet. - */ - public var currentWorkingFilePath(get, set):Null<String>; - - function get_currentWorkingFilePath():Null<String> - { - return previousWorkingFilePaths[0]; - } - - function set_currentWorkingFilePath(value:Null<String>):Null<String> - { - if (value == previousWorkingFilePaths[0]) return value; - - if (previousWorkingFilePaths.contains(null)) - { - // Filter all instances of `null` from the array. - previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool { - return x != null; - }); - } - - if (previousWorkingFilePaths.contains(value)) - { - // Move the path to the front of the list. - previousWorkingFilePaths.remove(value); - previousWorkingFilePaths.unshift(value); - } - else - { - // Add the path to the front of the list. - previousWorkingFilePaths.unshift(value); - } - - while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES) - { - // Remove the last path in the list. - previousWorkingFilePaths.pop(); - } - - populateOpenRecentMenu(); - applyWindowTitle(); - - return value; - } - public function new(?params:ChartEditorParams) { super(); @@ -1832,19 +2060,20 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Set the z-index of the HaxeUI. this.root.zIndex = 100; - // Show the mouse cursor. - Cursor.show(); - - loadPreferences(); - - fixCamera(); - // Get rid of any music from the previous state. if (FlxG.sound.music != null) FlxG.sound.music.stop(); // Play the welcome music. setupWelcomeMusic(); + // Show the mouse cursor. + Cursor.show(); + + loadPreferences(); + + uiCamera = new FlxCamera(); + FlxG.cameras.reset(uiCamera); + buildDefaultSongData(); buildBackground(); @@ -1852,8 +2081,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.updateTheme(); buildGrid(); + buildMeasureTicks(); buildNotePreview(); - buildSelectionBox(); buildAdditionalUI(); populateOpenRecentMenu(); @@ -1861,6 +2090,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Setup the onClick listeners for the UI after it's been created. setupUIListeners(); + setupContextMenu(); setupTurboKeyHandlers(); setupAutoSave(); @@ -1936,6 +2166,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState hitsoundVolume = save.chartEditorHitsoundVolume; hitsoundsEnabledPlayer = save.chartEditorHitsoundsEnabledPlayer; hitsoundsEnabledOpponent = save.chartEditorHitsoundsEnabledOpponent; + this.welcomeMusic.active = save.chartEditorThemeMusic; // audioInstTrack.volume = save.chartEditorInstVolume; // audioInstTrack.pitch = save.chartEditorPlaybackSpeed; @@ -1965,6 +2196,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState save.chartEditorHitsoundVolume = hitsoundVolume; save.chartEditorHitsoundsEnabledPlayer = hitsoundsEnabledPlayer; save.chartEditorHitsoundsEnabledOpponent = hitsoundsEnabledOpponent; + save.chartEditorThemeMusic = this.welcomeMusic.active; // save.chartEditorInstVolume = audioInstTrack.volume; // save.chartEditorVoicesVolume = audioVocalTrackGroup.volume; @@ -2025,10 +2257,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function fadeInWelcomeMusic(?extraWait:Float = 0, ?fadeInTime:Float = 5):Void { + if (!this.welcomeMusic.active) + { + stopWelcomeMusic(); + return; + } + bgMusicTimer = new FlxTimer().start(extraWait, (_) -> { this.welcomeMusic.volume = 0; - this.welcomeMusic.play(); - this.welcomeMusic.fadeIn(fadeInTime, 0, 1.0); + if (this.welcomeMusic.active) + { + this.welcomeMusic.play(); + this.welcomeMusic.fadeIn(fadeInTime, 0, 1.0); + } }); } @@ -2049,8 +2290,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Initialize the song chart data. songChartData = new Map<String, SongChartData>(); - - audioVocalTrackGroup = new VoicesGroup(); } /** @@ -2078,8 +2317,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (gridBitmap == null) throw 'ERROR: Tried to build grid, but gridBitmap is null! Check ChartEditorThemeHandler.updateTheme().'; gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); - gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. - gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. + gridTiledSprite.x = GRID_X_POS; // Center the grid. + gridTiledSprite.y = GRID_INITIAL_Y_POS; // Push down to account for the menu bar. add(gridTiledSprite); gridTiledSprite.zIndex = 10; @@ -2097,7 +2336,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState add(gridGhostHoldNote); gridGhostHoldNote.zIndex = 11; - gridGhostEvent = new ChartEditorEventSprite(this); + gridGhostEvent = new ChartEditorEventSprite(this, true); gridGhostEvent.alpha = 0.6; gridGhostEvent.eventData = new SongEventData(-1, '', {}); gridGhostEvent.visible = false; @@ -2106,23 +2345,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState buildNoteGroup(); - gridPlayheadScrollArea = new FlxSprite(0, 0); - gridPlayheadScrollArea.makeGraphic(10, 10, PLAYHEAD_SCROLL_AREA_COLOR); // Make it 10x10px and then scale it as needed. - add(gridPlayheadScrollArea); - gridPlayheadScrollArea.setGraphicSize(PLAYHEAD_SCROLL_AREA_WIDTH, 3000); - gridPlayheadScrollArea.updateHitbox(); - gridPlayheadScrollArea.x = gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH; - gridPlayheadScrollArea.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; - gridPlayheadScrollArea.zIndex = 25; - // The playhead that show the current position in the song. add(gridPlayhead); gridPlayhead.zIndex = 30; var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); - var playheadBaseYPos:Float = MENU_BAR_HEIGHT + GRID_TOP_PAD; - gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); - var playheadSprite:FlxSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); + var playheadBaseYPos:Float = GRID_INITIAL_Y_POS; + gridPlayhead.setPosition(GRID_X_POS, playheadBaseYPos); + var playheadSprite:FunkinSprite = new FunkinSprite().makeSolidColor(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; playheadSprite.y = 0; gridPlayhead.add(playheadSprite); @@ -2146,16 +2376,27 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState add(healthIconBF); healthIconBF.zIndex = 30; - audioVisGroup = new PolygonVisGroup(); - add(audioVisGroup); + add(audioWaveforms); + } + + function buildMeasureTicks():Void + { + measureTicks = new ChartEditorMeasureTicks(this); + var measureTicksWidth = (GRID_SIZE); + measureTicks.x = gridTiledSprite.x - measureTicksWidth; + measureTicks.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; + measureTicks.zIndex = 20; + + add(measureTicks); } function buildNotePreview():Void { - var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD; - notePreview = new ChartEditorNotePreview(height); - notePreview.x = 350; - notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; + var playbarHeightWithPad = PLAYBAR_HEIGHT + 10; + var notePreviewHeight:Int = FlxG.height - NOTE_PREVIEW_Y_POS - playbarHeightWithPad; + notePreview = new ChartEditorNotePreview(notePreviewHeight); + notePreview.x = NOTE_PREVIEW_X_POS; + notePreview.y = NOTE_PREVIEW_Y_POS; add(notePreview); if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().'; @@ -2167,17 +2408,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); } - function buildSelectionBox():Void - { - if (selectionBoxSprite == null) throw 'ERROR: Tried to build selection box, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().'; - - selectionBoxSprite.scrollFactor.set(0, 0); - add(selectionBoxSprite); - selectionBoxSprite.zIndex = 30; - - setSelectionBoxBounds(); - } - function setSelectionBoxBounds(bounds:FlxRect = null):Void { if (selectionBoxSprite == null) @@ -2199,6 +2429,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } } + /** + * Automatically goes through and calls render on everything you added. + */ + override public function draw():Void + { + if (selectionBoxStartPos != null) + { + trace('selectionBoxSprite: ${selectionBoxSprite.visible} ${selectionBoxSprite.exists} ${this.members.contains(selectionBoxSprite)}'); + } + + super.draw(); + } + function calculateNotePreviewViewportBounds():FlxRect { var bounds:FlxRect = new FlxRect(); @@ -2234,6 +2477,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState bounds.height = MIN_HEIGHT; } + // trace('Note preview viewport bounds: ' + bounds.toString()); + return bounds; } @@ -2339,6 +2584,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState add(playbarHeadLayout); + // Little text that shows up when you copy something. txtCopyNotif = new FlxText(0, 0, 0, '', 24); txtCopyNotif.setBorderStyle(OUTLINE, 0xFF074809, 1); txtCopyNotif.color = 0xFF52FF77; @@ -2363,6 +2609,77 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.openCharacterDropdown(CharacterType.BF, true); } }); + + buttonSelectOpponent = new Button(); + buttonSelectOpponent.allowFocus = false; + buttonSelectOpponent.text = "Opponent"; // Default text. + buttonSelectOpponent.x = GRID_X_POS; + buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 8; + buttonSelectOpponent.width = GRID_SIZE * 4; + buttonSelectOpponent.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectOpponent.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection."; + buttonSelectOpponent.zIndex = 110; + add(buttonSelectOpponent); + + buttonSelectOpponent.onClick = (_) -> { + var notesToSelect:Array<SongNoteData> = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, STRUMLINE_SIZE, STRUMLINE_SIZE * 2 - 1); + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand(notesToSelect, [])); + } + else + { + performCommand(new SetItemSelectionCommand(notesToSelect, [])); + } + } + + buttonSelectPlayer = new Button(); + buttonSelectPlayer.allowFocus = false; + buttonSelectPlayer.text = "Player"; // Default text. + buttonSelectPlayer.x = buttonSelectOpponent.x + buttonSelectOpponent.width; + buttonSelectPlayer.y = buttonSelectOpponent.y; + buttonSelectPlayer.width = GRID_SIZE * 4; + buttonSelectPlayer.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectPlayer.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection."; + buttonSelectPlayer.zIndex = 110; + add(buttonSelectPlayer); + + buttonSelectPlayer.onClick = (_) -> { + var notesToSelect:Array<SongNoteData> = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, 0, STRUMLINE_SIZE - 1); + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand(notesToSelect, [])); + } + else + { + performCommand(new SetItemSelectionCommand(notesToSelect, [])); + } + } + + buttonSelectEvent = new Button(); + buttonSelectEvent.allowFocus = false; + buttonSelectEvent.icon = Paths.image('ui/chart-editor/events/Default'); + buttonSelectEvent.iconPosition = "top"; + buttonSelectEvent.x = buttonSelectPlayer.x + buttonSelectPlayer.width; + buttonSelectEvent.y = buttonSelectPlayer.y; + buttonSelectEvent.width = GRID_SIZE; + buttonSelectEvent.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectEvent.tooltip = "Click to set selection to all events.\nShift-click to add all events to selection."; + buttonSelectEvent.zIndex = 110; + add(buttonSelectEvent); + + buttonSelectEvent.onClick = (_) -> { + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand([], currentSongChartEventData)); + } + else + { + performCommand(new SetItemSelectionCommand([], currentSongChartEventData)); + } + } } /** @@ -2402,16 +2719,33 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } else { - Conductor.currentTimeChange.bpm += 1; + Conductor.instance.currentTimeChange.bpm += 1; this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); } } playbarBPM.onRightClick = _ -> { - Conductor.currentTimeChange.bpm -= 1; + Conductor.instance.currentTimeChange.bpm -= 1; this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); } + playbarDifficulty.onClick = _ -> { + if (FlxG.keys.pressed.CONTROL) + { + this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, true); + } + else + { + incrementDifficulty(-1); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + } + + playbarDifficulty.onRightClick = _ -> { + incrementDifficulty(1); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + // Add functionality to the menu items. // File @@ -2434,31 +2768,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemUndo.onClick = _ -> undoLastCommand(); menubarItemRedo.onClick = _ -> redoLastCommand(); menubarItemCopy.onClick = function(_) { - // Doesn't use a command because it's not undoable. - - // Calculate a single time offset for all the notes and events. - var timeOffset:Null<Int> = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null; - if (currentEventSelection.length > 0) - { - if (timeOffset == null || currentEventSelection[0].time < timeOffset) - { - timeOffset = Std.int(currentEventSelection[0].time); - } - } - - SongDataUtils.writeItemsToClipboard( - { - notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset), - events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset), - }); + copySelection(); }; menubarItemCut.onClick = _ -> performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); menubarItemPaste.onClick = _ -> { var targetMs:Float = scrollPositionInMs + playheadPositionInMs; - var targetStep:Float = Conductor.getTimeInSteps(targetMs); + var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs); var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio; - var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep); + var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep); performCommand(new PasteItemsCommand(targetSnappedMs)); }; @@ -2488,11 +2806,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemFlipNotes.onClick = _ -> performCommand(new FlipNotesCommand(currentNoteSelection)); - menubarItemSelectAll.onClick = _ -> performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)); + menubarItemSelectAllNotes.onClick = _ -> performCommand(new SelectAllItemsCommand(true, false)); - menubarItemSelectInverse.onClick = _ -> performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)); + menubarItemSelectAllEvents.onClick = _ -> performCommand(new SelectAllItemsCommand(false, true)); - menubarItemSelectNone.onClick = _ -> performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + menubarItemSelectInverse.onClick = _ -> performCommand(new InvertSelectedItemsCommand()); + + menubarItemSelectNone.onClick = _ -> performCommand(new DeselectAllItemsCommand()); menubarItemPlaytestFull.onClick = _ -> testSongInPlayState(false); menubarItemPlaytestMinimal.onClick = _ -> testSongInPlayState(true); @@ -2546,8 +2866,25 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemPlayPause.onClick = _ -> toggleAudioPlayback(); - menubarItemLoadInstrumental.onClick = _ -> this.openUploadInstDialog(true); - menubarItemLoadVocals.onClick = _ -> this.openUploadVocalsDialog(true); + menubarItemLoadInstrumental.onClick = _ -> { + var dialog = this.openUploadInstDialog(true); + // Ensure instrumental and vocals are reloaded properly. + dialog.onDialogClosed = function(_) { + this.isHaxeUIDialogOpen = false; + this.switchToCurrentInstrumental(); + this.postLoadInstrumental(); + } + }; + + menubarItemLoadVocals.onClick = _ -> { + var dialog = this.openUploadVocalsDialog(true); + // Ensure instrumental and vocals are reloaded properly. + dialog.onDialogClosed = function(_) { + this.isHaxeUIDialogOpen = false; + this.switchToCurrentInstrumental(); + this.postLoadInstrumental(); + } + }; menubarItemVolumeMetronome.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; @@ -2562,6 +2899,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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 -> { var volume:Float = event.value.toFloat() / 100.0; hitsoundVolume = volume; @@ -2575,31 +2918,35 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarLabelVolumeInstrumental.text = 'Instrumental - ${Std.int(event.value)}%'; }; - menubarItemVolumeVocals.onChange = event -> { + menubarItemVolumeVocalsPlayer.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume; - menubarLabelVolumeVocals.text = 'Voices - ${Std.int(event.value)}%'; - } + audioVocalTrackGroup.playerVolume = volume; + menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%'; + }; + + menubarItemVolumeVocalsOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + audioVocalTrackGroup.opponentVolume = volume; + menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%'; + }; menubarItemPlaybackSpeed.onChange = event -> { - var pitch:Float = (event.value * 2.0) / 100.0; + var pitch:Float = (event.value.toFloat() * 2.0) / 100.0; pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25. #if FLX_PITCH if (audioInstTrack != null) audioInstTrack.pitch = pitch; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch; + audioVocalTrackGroup.pitch = pitch; #end var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places. menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x'; } - playbarDifficulty.onClick = _ -> { - this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, true); - } - menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value); menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value); + menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value); menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value); - menubarItemToggleToolboxEvents.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value); + menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value); + menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value); menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value); menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value); menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value); @@ -2608,6 +2955,42 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // registerContextMenu(null, Paths.ui('chart-editor/context/test')); } + function setupContextMenu():Void + { + Screen.instance.registerEvent(MouseEvent.RIGHT_MOUSE_UP, function(e:MouseEvent) { + var xPos = e.screenX; + var yPos = e.screenY; + onContextMenu(xPos, yPos); + }); + } + + function onContextMenu(xPos:Float, yPos:Float) + { + trace('User right clicked to open menu at (${xPos}, ${yPos})'); + // this.openDefaultContextMenu(xPos, yPos); + } + + function copySelection():Void + { + // Doesn't use a command because it's not undoable. + + // Calculate a single time offset for all the notes and events. + var timeOffset:Null<Int> = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null; + if (currentEventSelection.length > 0) + { + if (timeOffset == null || currentEventSelection[0].time < timeOffset) + { + timeOffset = Std.int(currentEventSelection[0].time); + } + } + + SongDataUtils.writeItemsToClipboard( + { + notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset), + events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset), + }); + } + /** * Initialize TurboKeyHandlers and add them to the state (so `update()` is called) * We can then probe `keyHandler.activated` to see if the key combo's action should be taken. @@ -2639,10 +3022,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState saveDataDirty = false; } + var displayAutosavePopup:Bool = false; + /** * UPDATE FUNCTIONS */ - function autoSave():Void + function autoSave(?beforePlaytest:Bool = false):Void { var needsAutoSave:Bool = saveDataDirty; @@ -2660,13 +3045,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (needsAutoSave) { this.exportAllSongData(true, null); - var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); - this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [ - { - text: "Take Me There", - callback: openBackupsFolder, - } - ]); + if (beforePlaytest) + { + displayAutosavePopup = true; + } + else + { + displayAutosavePopup = false; + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); + this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [ + { + text: "Take Me There", + callback: openBackupsFolder, + } + ]); + } } #end } @@ -2734,7 +3127,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState public override function update(elapsed:Float):Void { // Override F4 behavior to include the autosave. - if (FlxG.keys.justPressed.F4) + if (FlxG.keys.justPressed.F4 && !criticalFailure) { quitChartEditor(); return; @@ -2743,6 +3136,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // dispatchEvent gets called here. super.update(elapsed); + if (criticalFailure) return; + // These ones happen even if the modal dialog is open. handleMusicPlayback(elapsed); handleNoteDisplay(); @@ -2782,9 +3177,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (metronomeVolume > 0.0 && this.subState == null && (audioInstTrack != null && audioInstTrack.isPlaying)) { - playMetronomeTick(Conductor.currentBeat % 4 == 0); + playMetronomeTick(Conductor.instance.currentBeat % Conductor.instance.beatsPerMeasure == 0); } + // Show the mouse cursor. + // Just throwing this somewhere convenient and infrequently called because sometimes Flixel's debug thing hides the cursor. + Cursor.show(); + return true; } @@ -2798,8 +3197,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (audioInstTrack != null && audioInstTrack.isPlaying) { - if (healthIconDad != null) healthIconDad.onStepHit(Conductor.currentStep); - if (healthIconBF != null) healthIconBF.onStepHit(Conductor.currentStep); + if (healthIconDad != null) healthIconDad.onStepHit(Conductor.instance.currentStep); + if (healthIconBF != null) healthIconBF.onStepHit(Conductor.instance.currentStep); } // Updating these every step keeps it more accurate. @@ -2827,12 +3226,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState audioInstTrack.update(elapsed); // If the song starts 50ms in, make sure we start the song there. - if (Conductor.instrumentalOffset < 0) + if (Conductor.instance.instrumentalOffset < 0) { - if (audioInstTrack.time < -Conductor.instrumentalOffset) + if (audioInstTrack.time < -Conductor.instance.instrumentalOffset) { - trace('Resetting instrumental time to ${- Conductor.instrumentalOffset}ms'); - audioInstTrack.time = -Conductor.instrumentalOffset; + trace('Resetting instrumental time to ${- Conductor.instance.instrumentalOffset}ms'); + audioInstTrack.time = -Conductor.instance.instrumentalOffset; } } } @@ -2843,16 +3242,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! - var oldStepTime:Float = Conductor.currentStepTime; - var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset; - Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset); + var oldStepTime:Float = Conductor.instance.currentStepTime; + var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; + Conductor.instance.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); // Resync vocals. - if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } - var diffStepTime:Float = Conductor.currentStepTime - oldStepTime; + var diffStepTime:Float = Conductor.instance.currentStepTime - oldStepTime; // Move the playhead. playheadPositionInPixels += diffStepTime * GRID_SIZE; @@ -2862,18 +3261,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // Else, move the entire view. - var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset; - Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset); + var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; + Conductor.instance.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); // Resync vocals. - if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } // We need time in fractional steps here to allow the song to actually play. // Also account for a potentially offset playhead. - scrollPositionInPixels = (Conductor.currentStepTime + Conductor.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels; + scrollPositionInPixels = (Conductor.instance.currentStepTime + Conductor.instance.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels; // DO NOT move song to scroll position here specifically. @@ -2943,8 +3342,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue; - if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)) + if (holdNoteSprite.noteData == currentPlaceNoteData) { + // This hold note is for the note we are currently dragging. + // It will be displayed by gridGhostHoldNoteSprite instead. + holdNoteSprite.kill(); + } + else if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)) + { + // This hold note is off-screen. + // Kill the hold note sprite and recycle it. holdNoteSprite.kill(); } else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0) @@ -2962,7 +3369,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { displayedHoldNoteData.push(holdNoteSprite.noteData); - // Update the event sprite's position. + // Update the event sprite's height and position. + // var holdNoteHeight = holdNoteSprite.noteData.getStepLength() * GRID_SIZE; + // holdNoteSprite.setHeightDirectly(holdNoteHeight); holdNoteSprite.updateHoldNotePosition(renderedNotes); } } @@ -2979,7 +3388,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Resolve an issue where dragging an event too far would cause it to be hidden. var isSelectedAndDragged = currentEventSelection.fastContains(eventSprite.eventData) && (dragTargetCurrentStep != 0); - if ((eventSprite.isEventVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD) + if ((eventSprite.isEventVisible(FlxG.height - PLAYBAR_HEIGHT, MENU_BAR_HEIGHT) && currentSongChartEventData.fastContains(eventSprite.eventData)) || isSelectedAndDragged) { @@ -2988,6 +3397,8 @@ 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); } else { @@ -3002,8 +3413,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Let's try testing only notes within a certain range of the view area. // TODO: I don't think this messes up really long sustains, does it? - var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.measureLengthMs * 2); // Is 2 measures enough? - var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.measureLengthMs * 2); // Is 2 measures enough? + var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough? + var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough? // Add notes that are now visible. for (noteData in currentSongChartNoteData) @@ -3038,7 +3449,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState noteSprite.updateNotePosition(renderedNotes); // Add hold notes that are now visible (and not already displayed). - if (noteSprite.noteData != null && noteSprite.noteData.length > 0 && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1) + if (noteSprite.noteData != null + && noteSprite.noteData.length > 0 + && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1 + && noteSprite.noteData != currentPlaceNoteData) { var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this)); // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})'); @@ -3051,6 +3465,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState holdNoteSprite.setHeightDirectly(noteLengthPixels); holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); + + trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height); } } @@ -3081,6 +3497,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Setting event data resets position relative to the grid so we fix that. eventSprite.x += renderedEvents.x; eventSprite.y += renderedEvents.y; + eventSprite.updateTooltipPosition(); } // Add hold notes that have been made visible (but not their parents) @@ -3089,6 +3506,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Is the note a hold note? if (noteData == null || noteData.length <= 0) continue; + // Is the note the one we are dragging? If so, ghostHoldNoteSprite will handle it. + if (noteData == currentPlaceNoteData) continue; + // Is the hold note rendered already? if (displayedHoldNoteData.indexOf(noteData) != -1) continue; @@ -3178,7 +3598,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState selectionSquare.x = noteSprite.x; selectionSquare.y = noteSprite.y; selectionSquare.width = GRID_SIZE; - selectionSquare.height = GRID_SIZE; + + var stepLength = noteSprite.noteData.getStepLength(); + selectionSquare.height = (stepLength <= 0) ? GRID_SIZE : ((stepLength + 1) * GRID_SIZE); } } @@ -3290,14 +3712,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // PAGE UP = Jump up to nearest measure if (pageUpKeyHandler.activated) { - var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight; // If we would move less than one grid, instead move to the top of the previous measure. var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); if (targetScrollAmount < GRID_SIZE) { - targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure; + targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure; } scrollAmount = targetScrollPosition - playheadPos; @@ -3306,21 +3728,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (playbarButtonPressed == 'playbarBack') { playbarButtonPressed = ''; - scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; + scrollAmount = -GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; shouldPause = true; } // PAGE DOWN = Jump down to nearest measure if (pageDownKeyHandler.activated) { - var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight; // If we would move less than one grid, instead move to the top of the next measure. var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); if (targetScrollAmount < GRID_SIZE) { - targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure; + targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure; } scrollAmount = targetScrollPosition - playheadPos; @@ -3329,7 +3751,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (playbarButtonPressed == 'playbarForward') { playbarButtonPressed = ''; - scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + scrollAmount = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; shouldPause = true; } @@ -3444,6 +3866,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // trace('shouldHandleCursor: $shouldHandleCursor'); + // TODO: TBH some of this should be using FlxMouseEventManager... + if (shouldHandleCursor) { // Over the course of this big conditional block, @@ -3455,6 +3879,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite); + var overlapsRenderedNotes:Bool = FlxG.mouse.overlaps(renderedNotes); + var overlapsRenderedHoldNotes:Bool = FlxG.mouse.overlaps(renderedHoldNotes); + var overlapsRenderedEvents:Bool = FlxG.mouse.overlaps(renderedEvents); + // Cursor position relative to the grid. var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; @@ -3487,7 +3915,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { scrollAnchorScreenPos = null; } - else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea) && !isCursorOverHaxeUI) + else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks) && !isCursorOverHaxeUI) { gridPlayheadScrollAreaPressed = true; } @@ -3523,7 +3951,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { // Clicked on the playhead scroll area. // Move the playhead to the cursor position. - this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD; + this.playheadPositionInPixels = FlxG.mouse.screenY - (GRID_INITIAL_Y_POS); moveSongToScrollPosition(); // Cursor should be a grabby hand. @@ -3532,10 +3960,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // The song position of the cursor, in steps. var cursorFractionalStep:Float = cursorY / GRID_SIZE; - var cursorMs:Float = Conductor.getStepTimeInMs(cursorFractionalStep); + var cursorMs:Float = Conductor.instance.getStepTimeInMs(cursorFractionalStep); // Round the cursor step to the nearest snap quant. var cursorSnappedStep:Float = Math.floor(cursorFractionalStep / noteSnapRatio) * noteSnapRatio; - var cursorSnappedMs:Float = Conductor.getStepTimeInMs(cursorSnappedStep); + var cursorSnappedMs:Float = Conductor.instance.getStepTimeInMs(cursorSnappedStep); // The direction value for the column at the cursor. var cursorGridPos:Int = Math.floor(cursorX / GRID_SIZE); @@ -3557,7 +3985,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // We released the mouse. Select the notes in the box. var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE; var cursorStepStart:Int = Math.floor(cursorFractionalStepStart); - var cursorMsStart:Float = Conductor.getStepTimeInMs(cursorStepStart); + var cursorMsStart:Float = Conductor.instance.getStepTimeInMs(cursorStepStart); var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE); var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE); @@ -3616,7 +4044,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // Set the selection. - performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection)); + performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect)); } } else @@ -3629,7 +4057,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); if (shouldDeselect) { - performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + performCommand(new DeselectAllItemsCommand()); } } } @@ -3696,12 +4124,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } if (FlxG.keys.pressed.CONTROL) { if (highlightedNote != null && highlightedNote.noteData != null) { - // TODO: Handle the case of clicking on a sustain piece. // Control click to select/deselect an individual note. if (isNoteSelected(highlightedNote.noteData)) { @@ -3724,6 +4158,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); } } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Control click to select/deselect an individual note. + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], [])); + } + } else { // Do nothing if you control-clicked on an empty space. @@ -3734,12 +4180,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (highlightedNote != null && highlightedNote.noteData != null) { // Click a note to select it. - performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [])); } else if (highlightedEvent != null && highlightedEvent.eventData != null) { // Click an event to select it. - performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData])); + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Click a hold note to select it. + performCommand(new SetItemSelectionCommand([highlightedHoldNote.noteData], [])); } else { @@ -3747,7 +4198,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); if (shouldDeselect) { - performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + performCommand(new DeselectAllItemsCommand()); } } } @@ -3762,7 +4213,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); if (shouldDeselect) { - performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + performCommand(new DeselectAllItemsCommand()); } } } @@ -3793,11 +4244,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var dragDistanceMs:Float = 0; if (dragTargetNote != null && dragTargetNote.noteData != null) { - dragDistanceMs = Conductor.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time; + dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time; } else if (dragTargetEvent != null && dragTargetEvent.eventData != null) { - dragDistanceMs = Conductor.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time; + dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time; } var dragDistanceColumns:Int = dragTargetCurrentColumn; @@ -3857,7 +4308,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { stepTime = dragTargetEvent.eventData.getStepTime(); } - var dragDistanceSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime; + var dragDistanceSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime; var data:Int = 0; var noteGridPos:Int = 0; if (dragTargetNote != null && dragTargetNote.noteData != null) @@ -3889,11 +4340,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Handle extending the note as you drag. var stepTime:Float = inline currentPlaceNoteData.getStepTime(); - var dragLengthSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs) - stepTime; - var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs; + var dragLengthSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs) - stepTime; + var dragLengthMs:Float = dragLengthSteps * Conductor.instance.stepLengthMs; var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE; - if (gridGhostNote != null && gridGhostNote.noteData != null && gridGhostHoldNote != null) + if (gridGhostHoldNote != null) { if (dragLengthSteps > 0) { @@ -3906,8 +4357,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } gridGhostHoldNote.visible = true; - gridGhostHoldNote.noteData = gridGhostNote.noteData; - gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection(); + gridGhostHoldNote.noteData = currentPlaceNoteData; + gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection(); gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true); @@ -3928,6 +4379,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Apply the new length. performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs)); } + else + { + // Apply the new (zero) length if we are changing the length. + if (currentPlaceNoteData.length > 0) + { + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, 0)); + } + } // Finished dragging. Release the note. currentPlaceNoteData = null; @@ -3960,6 +4420,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + // If holdNote.alive is false, the holdNote is dead and awaiting recycling. + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } if (FlxG.keys.pressed.CONTROL) { @@ -3986,6 +4454,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); } } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], [])); + } + } else { // Do nothing when control clicking nothing. @@ -4003,7 +4482,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // If you click an unselected note, and aren't holding Control, deselect everything else. - performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [])); } } else if (highlightedEvent != null && highlightedEvent.eventData != null) @@ -4016,9 +4495,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // If you click an unselected event, and aren't holding Control, deselect everything else. - performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData])); } } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Clicked a hold note, start dragging TO EXTEND NOTE LENGTH. + currentPlaceNoteData = highlightedHoldNote.noteData; + } else { // Click a blank space to place a note and select it. @@ -4027,14 +4511,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, selectedEventKind, selectedEventData.clone()); + var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.clone()); 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, selectedNoteKind.clone()); + var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace.clone()); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); @@ -4045,7 +4529,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } else { - // If we clicked and released outside the grid, do nothing. + // If we clicked and released outside the grid (or on HaxeUI), do nothing. } } @@ -4068,17 +4552,90 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + // If holdNote.alive is false, the holdNote is dead and awaiting recycling. + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } if (highlightedNote != null && highlightedNote.noteData != null) { // TODO: Handle the case of clicking on a sustain piece. - // Remove the note. - performCommand(new RemoveNotesCommand([highlightedNote.noteData])); + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu. + var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedNote.noteData); + var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected) + || (isHighlightedNoteSelected && currentNoteSelection.length == 1); + // Show the context menu connected to the note. + if (useSingleNoteContextMenu) + { + this.openNoteContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedNote.noteData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + } + else + { + // Right click removes the note. + performCommand(new RemoveNotesCommand([highlightedNote.noteData])); + } } else if (highlightedEvent != null && highlightedEvent.eventData != null) { - // Remove the event. - performCommand(new RemoveEventsCommand([highlightedEvent.eventData])); + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Event context menu. + var isHighlightedEventSelected:Bool = isEventSelected(highlightedEvent.eventData); + var useSingleEventContextMenu:Bool = (!isHighlightedEventSelected) + || (isHighlightedEventSelected && currentEventSelection.length == 1); + if (useSingleEventContextMenu) + { + this.openEventContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedEvent.eventData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + } + else + { + // Right click removes the event. + performCommand(new RemoveEventsCommand([highlightedEvent.eventData])); + } + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu. + var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedHoldNote.noteData); + var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected) + || (isHighlightedNoteSelected && currentNoteSelection.length == 1); + // Show the context menu connected to the note. + if (useSingleNoteContextMenu) + { + this.openHoldNoteContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedHoldNote.noteData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + } + else + { + // Right click removes hold from the note. + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + performCommand(new ExtendNoteLengthCommand(highlightedHoldNote.noteData, 0)); + } } else { @@ -4086,7 +4643,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } } - var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null; + var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null || overlapsRenderedNotes || overlapsRenderedHoldNotes + || overlapsRenderedEvents; // Handle grid cursor. if (!isCursorOverHaxeUI && overlapsGrid && !isOrWillSelect && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed) { @@ -4099,11 +4657,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (gridGhostEvent == null) throw "ERROR: Tried to handle cursor, but gridGhostEvent is null! Check ChartEditorState.buildGrid()"; - var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, selectedEventKind, null); + var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, eventKindToPlace, null); - if (selectedEventKind != eventData.event) + if (eventKindToPlace != eventData.event) { - eventData.event = selectedEventKind; + eventData.event = eventKindToPlace; } eventData.time = cursorSnappedMs; @@ -4119,11 +4677,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (gridGhostNote == null) throw "ERROR: Tried to handle cursor, but gridGhostNote is null! Check ChartEditorState.buildGrid()"; - var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); + var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, noteKindToPlace); - if (cursorColumn != noteData.data || selectedNoteKind != noteData.kind) + if (cursorColumn != noteData.data || noteKindToPlace != noteData.kind) { - noteData.kind = selectedNoteKind; + noteData.kind = noteKindToPlace; noteData.data = cursorColumn; gridGhostNote.playNoteAnimation(); } @@ -4165,7 +4723,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { targetCursorMode = Pointer; } - else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea)) + else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks)) { targetCursorMode = Pointer; } @@ -4177,6 +4735,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { targetCursorMode = Crosshair; } + else if (overlapsRenderedNotes) + { + targetCursorMode = Pointer; + } + else if (overlapsRenderedHoldNotes) + { + targetCursorMode = Pointer; + } + else if (overlapsRenderedEvents) + { + targetCursorMode = Pointer; + } else if (overlapsGrid) { targetCursorMode = Cell; @@ -4215,48 +4785,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { difficultySelectDirty = false; - // Manage the Select Difficulty tree view. - var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + var difficultyToolbox:ChartEditorDifficultyToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); if (difficultyToolbox == null) return; - var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree'); - if (treeView == null) return; - - // Clear the tree view so we can rebuild it. - treeView.clearNodes(); - - // , icon: 'haxeui-core/styles/default/haxeui_tiny.png' - var treeSong:TreeViewNode = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName'}); - treeSong.expanded = true; - - for (curVariation in availableVariations) - { - trace('DIFFICULTY TOOLBOX: Variation ${curVariation}'); - var variationMetadata:Null<SongMetadata> = songMetadata.get(curVariation); - if (variationMetadata == null) continue; - - var treeVariation:TreeViewNode = treeSong.addNode( - { - id: 'stv_variation_$curVariation', - text: 'V: ${curVariation.toTitleCase()}' - }); - treeVariation.expanded = true; - - var difficultyList:Array<String> = variationMetadata.playData.difficulties; - - for (difficulty in difficultyList) - { - trace('DIFFICULTY TOOLBOX: Difficulty ${curVariation}_$difficulty'); - var _treeDifficulty:TreeViewNode = treeVariation.addNode( - { - id: 'stv_difficulty_${curVariation}_$difficulty', - text: 'D: ${difficulty.toTitleCase()}' - }); - } - } - - treeView.onChange = onChangeTreeDifficulty; - refreshDifficultyTreeSelection(treeView); + difficultyToolbox.updateTree(); } } @@ -4278,7 +4810,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (currentSongMetadata.playData.characters.player != charPlayer.charId) { - if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player; + if (healthIconBF != null) + { + healthIconBF.characterId = currentSongMetadata.playData.characters.player; + } charPlayer.loadCharacter(currentSongMetadata.playData.characters.player); charPlayer.characterType = CharacterType.BF; @@ -4314,7 +4849,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (currentSongMetadata.playData.characters.opponent != charPlayer.charId) { - if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; + if (healthIconDad != null) + { + healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; + } charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent); charPlayer.characterType = CharacterType.DAD; @@ -4332,6 +4870,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } } + function handleSelectionButtons():Void + { + // Make sure buttons are never nudged out of the correct spot. + // TODO: Why do these even move in the first place? The camera never moves, LOL. + buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + buttonSelectPlayer.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + buttonSelectEvent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + } + /** * Handles display elements for the playbar at the bottom. */ @@ -4351,7 +4898,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (playbarHeadLayout.playbarHead.value != songPosPercent) playbarHeadLayout.playbarHead.value = songPosPercent; } - var songPos:Float = Conductor.songPosition + Conductor.instrumentalOffset; + var songPos:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; var songPosSeconds:String = Std.string(Math.floor((Math.abs(songPos) / 1000) % 60)).lpad('0', 2); var songPosMinutes:String = Std.string(Math.floor((Math.abs(songPos) / 1000) / 60)).lpad('0', 2); if (songPos < 0) songPosMinutes = '-' + songPosMinutes; @@ -4367,8 +4914,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (playbarSongRemaining.value != songRemainingString) playbarSongRemaining.value = songRemainingString; playbarNoteSnap.text = '1/${noteSnapQuant}'; - playbarDifficulty.text = "Difficulty: " + selectedDifficulty.toTitleCase(); - playbarBPM.text = "BPM: " + Conductor.currentTimeChange.bpm; + playbarDifficulty.text = '${selectedDifficulty.toTitleCase()}'; + // playbarBPM.text = 'BPM: ${(Conductor.currentTimeChange?.bpm ?? 0.0)}'; } function handlePlayhead():Void @@ -4407,16 +4954,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); - var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio; + var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio; // Look for notes within 1 step of the playhead. var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs, - playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio); + playheadPosSnappedMs + Conductor.instance.stepLengthMs * noteSnapRatio); notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]); if (notesAtPos.length == 0) { - var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind); + var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); } else @@ -4440,11 +4987,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState healthIconBF.size *= 0.5; // Make the icon smaller in Chart Editor. healthIconBF.flipX = !healthIconBF.flipX; // BF faces the other way. } + if (buttonSelectPlayer != null) + { + buttonSelectPlayer.text = charDataBF?.name ?? 'Player'; + } if (healthIconDad != null) { healthIconDad.configure(charDataDad?.healthIcon); healthIconDad.size *= 0.5; // Make the icon smaller in Chart Editor. } + if (buttonSelectOpponent != null) + { + buttonSelectOpponent.text = charDataDad?.name ?? 'Opponent'; + } healthIconsDirty = false; } @@ -4452,15 +5007,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (healthIconBF != null) { // Base X position to the right of the grid. - healthIconBF.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 45 - (healthIconBF.width / 2)); - healthIconBF.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconBF.height / 2)); + var xOffset = 45 - (healthIconBF.width / 2); + healthIconBF.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS + gridTiledSprite.width + xOffset); + var yOffset = 30 - (healthIconBF.height / 2); + healthIconBF.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset; } // Visibly center the Dad health icon. if (healthIconDad != null) { - healthIconDad.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x - 45 - (healthIconDad.width / 2)); - healthIconDad.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconDad.height / 2)); + var xOffset = 75 + (healthIconDad.width / 2); + healthIconDad.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS - xOffset); + var yOffset = 30 - (healthIconDad.height / 2); + healthIconDad.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset; } } @@ -4518,6 +5077,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState FlxG.switchState(new MainMenuState()); resetWindowTitle(); + + criticalFailure = true; } /** @@ -4540,54 +5101,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // CTRL + C = Copy if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C) { - if (currentNoteSelection.length > 0) - { - txtCopyNotif.visible = true; - txtCopyNotif.text = "Copied " + currentNoteSelection.length + " notes to clipboard"; - txtCopyNotif.x = FlxG.mouse.x - (txtCopyNotif.width / 2); - txtCopyNotif.y = FlxG.mouse.y - 16; - FlxTween.tween(txtCopyNotif, {y: txtCopyNotif.y - 32}, 0.5, - { - type: FlxTween.ONESHOT, - ease: FlxEase.quadOut, - onComplete: function(_) { - txtCopyNotif.visible = false; - } - }); - - for (note in renderedNotes.members) - { - if (isNoteSelected(note.noteData)) - { - FlxTween.globalManager.cancelTweensOf(note); - FlxTween.globalManager.cancelTweensOf(note.scale); - note.playNoteAnimation(); - var prevX:Float = note.scale.x; - var prevY:Float = note.scale.y; - - note.scale.x *= 1.2; - note.scale.y *= 1.2; - - note.angle = FlxG.random.bool() ? -10 : 10; - FlxTween.tween(note, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut}); - - FlxTween.tween(note.scale, {"y": prevX, "x": prevY}, 0.7, - { - ease: FlxEase.elasticOut, - onComplete: function(_) { - note.playNoteAnimation(); - } - }); - } - } - } - - // We don't need a command for this since we can't undo it. - SongDataUtils.writeItemsToClipboard( - { - notes: SongDataUtils.buildNoteClipboard(currentNoteSelection), - events: SongDataUtils.buildEventClipboard(currentEventSelection), - }); + performCommand(new CopyItemsCommand(currentNoteSelection, currentEventSelection)); } // CTRL + X = Cut @@ -4608,9 +5122,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { var targetMs:Float = scrollPositionInMs + playheadPositionInMs; - var targetStep:Float = Conductor.getTimeInSteps(targetMs); + var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs); var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio; - var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep); + var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep); targetSnappedMs; } performCommand(new PasteItemsCommand(targetMs)); @@ -4648,25 +5162,50 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState performCommand(new FlipNotesCommand(currentNoteSelection)); } - // CTRL + A = Select All + // CTRL + A = Select All Notes if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A) { // Select all items. - performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)); + if (FlxG.keys.pressed.ALT) + { + if (FlxG.keys.pressed.SHIFT) + { + // CTRL + ALT + SHIFT + A = Append All Events to Selection + performCommand(new SelectItemsCommand([], currentSongChartEventData)); + } + else + { + // CTRL + ALT + A = Set Selection to All Events + performCommand(new SelectAllItemsCommand(false, true)); + } + } + else + { + if (FlxG.keys.pressed.SHIFT) + { + // CTRL + SHIFT + A = Append All Notes to Selection + performCommand(new SelectItemsCommand(currentSongChartNoteData, [])); + } + else + { + // CTRL + A = Set Selection to All Notes + performCommand(new SelectAllItemsCommand(true, false)); + } + } } // CTRL + I = Select Inverse if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I) { // Select unselected items and deselect selected items. - performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)); + performCommand(new InvertSelectedItemsCommand()); } // CTRL + D = Select None if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D) { // Deselect all items. - performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); + performCommand(new DeselectAllItemsCommand()); } } @@ -4719,20 +5258,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState #end } - override function handleQuickWatch():Void + function handleQuickWatch():Void { - super.handleQuickWatch(); - FlxG.watch.addQuick('musicTime', audioInstTrack?.time ?? 0.0); FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels); FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels); - FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length); - FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length); - FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length); - FlxG.watch.addQuick("notesSelected", currentNoteSelection.length); - FlxG.watch.addQuick("eventsSelected", currentEventSelection.length); + FlxG.watch.addQuick("tapNotesRendered", renderedNotes?.members?.length); + FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes?.members?.length); + FlxG.watch.addQuick("eventsRendered", renderedEvents?.members?.length); + FlxG.watch.addQuick("notesSelected", currentNoteSelection?.length); + FlxG.watch.addQuick("eventsSelected", currentEventSelection?.length); } function handlePostUpdate():Void @@ -4750,9 +5287,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ function testSongInPlayState(minimal:Bool = false):Void { - autoSave(); + autoSave(true); stopWelcomeMusic(); + stopAudioPlayback(); var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; @@ -4768,6 +5306,9 @@ 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) { @@ -4789,7 +5330,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState Paths.setCurrentLevel('weekend1'); } - subStateClosed.add(fixCamera); + subStateClosed.add(reviveUICamera); subStateClosed.add(resetConductorAfterTest); FlxTransitionableState.skipNextTransIn = false; @@ -4812,7 +5353,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { FlxG.sound.music = audioInstTrack; } - if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup; + targetState.vocals = audioVocalTrackGroup; + + // Kill and replace the UI camera so it doesn't get destroyed during the state transition. + uiCamera.kill(); + FlxG.cameras.remove(uiCamera, false); + FlxG.cameras.reset(new FlxCamera()); this.persistentUpdate = false; this.persistentDraw = false; @@ -4829,13 +5375,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState * Perform (or redo) a command, then add it to the undo stack. * * @param command The command to perform. - * @param purgeRedoStack If true, the redo stack will be cleared. + * @param purgeRedoStack If `true`, the redo stack will be cleared after performing the command. */ function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void { command.execute(this); - undoHistory.push(command); - commandHistoryDirty = true; + if (command.shouldAddToHistory(this)) + { + undoHistory.push(command); + commandHistoryDirty = true; + } if (purgeRedoStack) redoHistory = []; } @@ -4846,6 +5395,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function undoCommand(command:ChartEditorCommand):Void { command.undo(this); + // Note, if we are undoing a command, it should already be in the history, + // therefore we don't need to check `shouldAddToHistory(state)` redoHistory.push(command); commandHistoryDirty = true; } @@ -4892,19 +5443,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()"; // FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap"); - var result = new ChartEditorSelectionSquareSprite(); + var result = new ChartEditorSelectionSquareSprite(this); result.loadGraphic(selectionSquareBitmap); return result; } /** - * Fix a camera issue caused when closing the PlayState used when testing. + * Revive the UI camera and re-establish it as the main camera so UI elements depending on it don't explode. */ - function fixCamera(_:FlxSubState = null):Void + function reviveUICamera(_:FlxSubState = null):Void { - FlxG.cameras.reset(new FlxCamera()); - FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2)); - FlxG.camera.zoom = 1.0; + uiCamera.revive(); + FlxG.cameras.reset(uiCamera); add(this.root); } @@ -4919,7 +5469,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (audioInstTrack != null) { audioInstTrack.play(false, audioInstTrack.time); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time); + audioVocalTrackGroup.play(false, audioInstTrack.time); } playbarPlay.text = '||'; // Pause @@ -4942,16 +5492,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function onSongLengthChanged():Void { - if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels; - if (gridPlayheadScrollArea != null) + if (gridTiledSprite != null) { - gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels); - gridPlayheadScrollArea.updateHitbox(); + gridTiledSprite.height = songLengthInPixels; + } + if (measureTicks != null) + { + measureTicks.setHeight(songLengthInPixels); } // Remove any notes past the end of the song. var songCutoffPointSteps:Float = songLengthInSteps - 0.1; - var songCutoffPointMs:Float = Conductor.getStepTimeInMs(songCutoffPointSteps); + var songCutoffPointMs:Float = Conductor.instance.getStepTimeInMs(songCutoffPointSteps); currentSongChartNoteData = SongDataUtils.clampSongNoteData(currentSongChartNoteData, 0.0, songCutoffPointMs); currentSongChartEventData = SongDataUtils.clampSongEventData(currentSongChartEventData, 0.0, songCutoffPointMs); @@ -4986,7 +5538,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event != null && currentEventSelection.indexOf(event) != -1; } - function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0) + function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0):Void { var variationMetadata:Null<SongMetadata> = songMetadata.get(variation); if (variationMetadata == null) return; @@ -5008,6 +5560,42 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState difficultySelectDirty = true; // Force the Difficulty toolbox to update. } + function removeDifficulty(variation:String, difficulty:String):Void + { + var variationMetadata:Null<SongMetadata> = songMetadata.get(variation); + if (variationMetadata == null) return; + + variationMetadata.playData.difficulties.remove(difficulty); + + var resultChartData = songChartData.get(variation); + if (resultChartData != null) + { + resultChartData.scrollSpeed.remove(difficulty); + resultChartData.notes.remove(difficulty); + } + + if (songMetadata.size() > 1) + { + if (variationMetadata.playData.difficulties.length == 0) + { + songMetadata.remove(variation); + songChartData.remove(variation); + } + + if (variation == selectedVariation) + { + var firstVariation = songMetadata.keyValues()[0]; + if (firstVariation != null) selectedVariation = firstVariation; + variationMetadata = songMetadata.get(selectedVariation); + } + } + + if (selectedDifficulty == difficulty + || !variationMetadata.playData.difficulties.contains(selectedDifficulty)) selectedDifficulty = variationMetadata.playData.difficulties[0]; + + difficultySelectDirty = true; // Force the Difficulty toolbox to update. + } + function incrementDifficulty(change:Int):Void { var currentDifficultyIndex:Int = availableDifficulties.indexOf(selectedDifficulty); @@ -5053,10 +5641,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var prevDifficulty = availableDifficulties[availableDifficulties.length - 1]; selectedDifficulty = prevDifficulty; - Conductor.mapTimeChanges(this.currentSongMetadata.timeChanges); + Conductor.instance.mapTimeChanges(this.currentSongMetadata.timeChanges); + updateTimeSignature(); - refreshDifficultyTreeSelection(); this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); } else { @@ -5064,8 +5653,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var prevDifficulty = availableDifficulties[currentDifficultyIndex - 1]; selectedDifficulty = prevDifficulty; - refreshDifficultyTreeSelection(); this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); } } else @@ -5083,8 +5672,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var nextDifficulty = availableDifficulties[0]; selectedDifficulty = nextDifficulty; - refreshDifficultyTreeSelection(); this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); } else { @@ -5092,12 +5681,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState var nextDifficulty = availableDifficulties[currentDifficultyIndex + 1]; selectedDifficulty = nextDifficulty; - refreshDifficultyTreeSelection(); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); } } - this.success('Switch Difficulty', 'Switched difficulty to ${selectedDifficulty.toTitleCase()}'); + // Removed this notification because you can see your difficulty in the playbar now. + // this.success('Switch Difficulty', 'Switched difficulty to ${selectedDifficulty.toTitleCase()}'); } /** @@ -5114,10 +5704,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Update the songPosition in the audio tracks. if (audioInstTrack != null) { - audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instrumentalOffset; + audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset; // Update the songPosition in the Conductor. - Conductor.update(audioInstTrack.time); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time; + Conductor.instance.update(audioInstTrack.time); + audioVocalTrackGroup.time = audioInstTrack.time; } // We need to update the note sprites because we changed the scroll position. @@ -5176,117 +5766,62 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.persistentUpdate = true; this.persistentDraw = true; + if (displayAutosavePopup) + { + displayAutosavePopup = false; + #if sys + Toolkit.callLater(() -> { + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); + this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [ + { + text: "Take Me There", + callback: openBackupsFolder, + } + ]); + }); + #else + // TODO: No auto-save on HTML5? + #end + } + moveSongToScrollPosition(); - fadeInWelcomeMusic(7, 10); + fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); // Reapply the volume. var instTargetVolume:Float = menubarItemVolumeInstrumental.value ?? 1.0; - var vocalTargetVolume:Float = menubarItemVolumeVocals.value ?? 1.0; + var vocalPlayerTargetVolume:Float = menubarItemVolumeVocalsPlayer.value ?? 1.0; + var vocalOpponentTargetVolume:Float = menubarItemVolumeVocalsOpponent.value ?? 1.0; if (audioInstTrack != null) { audioInstTrack.volume = instTargetVolume; audioInstTrack.onComplete = null; } - if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = vocalTargetVolume; + if (audioVocalTrackGroup != null) + { + audioVocalTrackGroup.playerVolume = vocalPlayerTargetVolume; + audioVocalTrackGroup.opponentVolume = vocalOpponentTargetVolume; + } + } + + function updateTimeSignature():Void + { + // Redo the grid bitmap to be 4/4. + this.updateTheme(); + gridTiledSprite.loadGraphic(gridBitmap); + measureTicks.reloadTickBitmap(); } /** * HAXEUI FUNCTIONS */ - // ==================== - - /** - * Set the currently selected item in the Difficulty tree view to the node representing the current difficulty. - * @param treeView The tree view to update. If `null`, the tree view will be found. - */ - function refreshDifficultyTreeSelection(?treeView:TreeView):Void - { - if (treeView == null) - { - // Manage the Select Difficulty tree view. - var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); - if (difficultyToolbox == null) return; - - treeView = difficultyToolbox.findComponent('difficultyToolboxTree'); - if (treeView == null) return; - } - - var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); - if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; - } - - /** - * Retrieve the node representing the current difficulty in the Difficulty tree view. - * @param treeView The tree view to search. If `null`, the tree view will be found. - * @return The node representing the current difficulty, or `null` if not found. - */ - function getCurrentTreeDifficultyNode(?treeView:TreeView = null):Null<TreeViewNode> - { - if (treeView == null) - { - var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); - if (difficultyToolbox == null) return null; - - treeView = difficultyToolbox.findComponent('difficultyToolboxTree'); - if (treeView == null) return null; - } - - var result:TreeViewNode = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', - 'id'); - if (result == null) return null; - - return result; - } - - /** - * Called when selecting a tree element in the Difficulty toolbox. - * @param event The click event. - */ - function onChangeTreeDifficulty(event:UIEvent):Void - { - // Get the newly selected node. - var treeView:TreeView = cast event.target; - var targetNode:TreeViewNode = treeView.selectedNode; - - if (targetNode == null) - { - trace('No target node!'); - // Reset the user's selection. - var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); - if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; - return; - } - - switch (targetNode.data.id.split('_')[1]) - { - case 'difficulty': - var variation:String = targetNode.data.id.split('_')[2]; - var difficulty:String = targetNode.data.id.split('_')[3]; - - if (variation != null && difficulty != null) - { - trace('Changing difficulty to "$variation:$difficulty"'); - selectedVariation = variation; - selectedDifficulty = difficulty; - this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); - } - // case 'song': - // case 'variation': - default: - // Reset the user's selection. - trace('Selected wrong node type, resetting selection.'); - var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); - if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; - this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); - } - } + // ================== /** * STATIC FUNCTIONS */ - // ==================== + // ================== function handleNotePreview():Void { @@ -5297,12 +5832,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // TODO: Only update the notes that have changed. notePreview.erase(); notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs)); + notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs)); notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs)); } if (notePreviewViewportBoundsDirty) { setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + notePreviewViewportBoundsDirty = false; } } @@ -5391,7 +5928,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function stopAudioPlayback():Void { if (audioInstTrack != null) audioInstTrack.pause(); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); + audioVocalTrackGroup.pause(); playbarPlay.text = '>'; } @@ -5404,7 +5941,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { // Pause stopAudioPlayback(); - fadeInWelcomeMusic(7, 10); + fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); } else { @@ -5426,7 +5963,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Keep the track at the end. audioInstTrack.time = audioInstTrack.length; } - if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); + audioVocalTrackGroup.pause(); }; } else @@ -5434,18 +5971,38 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState trace('ERROR: Instrumental track is null!'); } - this.songLengthInMs = (audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset; + this.songLengthInMs = (audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; // Many things get reset when song length changes. healthIconsDirty = true; } + function hardRefreshOffsetsToolbox():Void + { + var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + if (offsetsToolbox != null) + { + offsetsToolbox.refreshAudioPreview(); + offsetsToolbox.refresh(); + } + } + + function hardRefreshFreeplayToolbox():Void + { + var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT); + if (freeplayToolbox != null) + { + freeplayToolbox.refreshAudioPreview(); + freeplayToolbox.refresh(); + } + } + /** * Clear the voices group. */ public function clearVocals():Void { - if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear(); + audioVocalTrackGroup.clear(); } function isNoteSelected(note:Null<SongNoteData>):Bool @@ -5459,11 +6016,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState cleanupAutoSave(); + this.closeAllMenus(); + // Hide the mouse cursor on other states. Cursor.hide(); @:privateAccess ChartEditorNoteSprite.noteFrameCollection = null; + + // Stop the music. + if (welcomeMusic != null) welcomeMusic.destroy(); + if (audioInstTrack != null) audioInstTrack.destroy(); + audioVocalTrackGroup.destroy(); } function applyCanQuickSave():Void diff --git a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx index 9bf8ec3db..a878ee687 100644 --- a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx @@ -59,6 +59,12 @@ class AddEventsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + public function toString():String { var len:Int = events.length; diff --git a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx index ce4e73ea2..ea984c82d 100644 --- a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx +++ b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx @@ -59,6 +59,12 @@ class AddNotesCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + public function toString():String { if (notes.length == 1) diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx index 3c45c1168..bd832fab3 100644 --- a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx @@ -34,7 +34,12 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.currentSongMetadata.timeChanges = timeChanges; - Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); } public function undo(state:ChartEditorState):Void @@ -51,7 +56,18 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.currentSongMetadata.timeChanges = timeChanges; - Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (targetBPM != previousBPM); } public function toString():String diff --git a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx index cfa169908..1fa86ad94 100644 --- a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx @@ -6,6 +6,8 @@ package funkin.ui.debug.charting.commands; * * To make a functionality compatible with the undo/redo history, create a new class * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())` + * + * NOTE: Make the constructor very simple, as it may be called without executing by the command palette. */ interface ChartEditorCommand { @@ -22,6 +24,15 @@ interface ChartEditorCommand */ public function undo(state:ChartEditorState):Void; + /** + * Return whether or not this command should be appended to the in the undo/redo history. + * Generally this should be true, it should only be false if the command is minor and non-destructive, + * like copying to the clipboard. + * + * Called after `execute()` is performed. + */ + public function shouldAddToHistory(state:ChartEditorState):Bool; + /** * Get a short description of the action (for the UI). * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu. diff --git a/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx new file mode 100644 index 000000000..6c5152a29 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx @@ -0,0 +1,170 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * without deleting them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class CopyItemsCommand implements ChartEditorCommand +{ + var notes:Array<SongNoteData>; + var events:Array<SongEventData>; + + public function new(notes:Array<SongNoteData>, events:Array<SongEventData>) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + // Calculate a single time offset for all the notes and events. + var timeOffset:Null<Int> = state.currentNoteSelection.length > 0 ? Std.int(state.currentNoteSelection[0].time) : null; + if (state.currentEventSelection.length > 0) + { + if (timeOffset == null || state.currentEventSelection[0].time < timeOffset) + { + timeOffset = Std.int(state.currentEventSelection[0].time); + } + } + + SongDataUtils.writeItemsToClipboard( + { + notes: SongDataUtils.buildNoteClipboard(state.currentNoteSelection, timeOffset), + events: SongDataUtils.buildEventClipboard(state.currentEventSelection, timeOffset), + }); + + performVisuals(state); + } + + function performVisuals(state:ChartEditorState):Void + { + var hasNotes:Bool = false; + var hasEvents:Bool = false; + + // Wiggle copied notes. + if (state.currentNoteSelection.length > 0) + { + hasNotes = true; + + for (note in state.renderedNotes.members) + { + if (state.isNoteSelected(note.noteData)) + { + FlxTween.globalManager.cancelTweensOf(note); + FlxTween.globalManager.cancelTweensOf(note.scale); + note.playNoteAnimation(); + var prevX:Float = note.scale.x; + var prevY:Float = note.scale.y; + + note.scale.x *= 1.2; + note.scale.y *= 1.2; + + note.angle = FlxG.random.bool() ? -10 : 10; + FlxTween.tween(note, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut}); + + FlxTween.tween(note.scale, {"y": prevX, "x": prevY}, 0.7, + { + ease: FlxEase.elasticOut, + onComplete: function(_) { + note.playNoteAnimation(); + } + }); + } + } + } + + // Wiggle copied events. + if (state.currentEventSelection.length > 0) + { + hasEvents = true; + + for (event in state.renderedEvents.members) + { + if (state.isEventSelected(event.eventData)) + { + FlxTween.globalManager.cancelTweensOf(event); + FlxTween.globalManager.cancelTweensOf(event.scale); + event.playAnimation(); + var prevX:Float = event.scale.x; + var prevY:Float = event.scale.y; + + event.scale.x *= 1.2; + event.scale.y *= 1.2; + + event.angle = FlxG.random.bool() ? -10 : 10; + FlxTween.tween(event, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut}); + + FlxTween.tween(event.scale, {"y": prevX, "x": prevY}, 0.7, + { + ease: FlxEase.elasticOut, + onComplete: function(_) { + event.playAnimation(); + } + }); + } + } + } + + // Display the "Copied Notes" text. + if ((hasNotes || hasEvents) && state.txtCopyNotif != null) + { + var copiedString:String = ''; + if (hasNotes) + { + var copiedNotes:Int = state.currentNoteSelection.length; + copiedString += '${copiedNotes} note'; + if (copiedNotes > 1) copiedString += 's'; + + if (hasEvents) copiedString += ' and '; + } + if (hasEvents) + { + var copiedEvents:Int = state.currentEventSelection.length; + copiedString += '${state.currentEventSelection.length} event'; + if (copiedEvents > 1) copiedString += 's'; + } + + state.txtCopyNotif.visible = true; + state.txtCopyNotif.text = 'Copied ${copiedString} to clipboard'; + state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2); + state.txtCopyNotif.y = FlxG.mouse.y - 16; + FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5, + { + type: FlxTween.ONESHOT, + ease: FlxEase.quadOut, + onComplete: function(_) { + state.txtCopyNotif.visible = false; + } + }); + } + } + + public function undo(state:ChartEditorState):Void + { + // This command is not undoable. Do nothing. + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is not undoable. Don't add it to the history. + return false; + } + + public function toString():String + { + var len:Int = notes.length + events.length; + + if (notes.length == 0) return 'Copy $len Events to Clipboard'; + else if (events.length == 0) return 'Copy $len Notes to Clipboard'; + else + return 'Copy $len Items to Clipboard'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx index d0301b1ec..6cf674f80 100644 --- a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx @@ -56,6 +56,12 @@ class CutItemsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Always add it to the history. + return (notes.length > 0 || events.length > 0); + } + public function toString():String { var len:Int = notes.length + events.length; diff --git a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx index cbde0ab3d..5bfef76cc 100644 --- a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx @@ -10,17 +10,16 @@ import funkin.data.song.SongData.SongEventData; @:access(funkin.ui.debug.charting.ChartEditorState) class DeselectAllItemsCommand implements ChartEditorCommand { - var previousNoteSelection:Array<SongNoteData>; - var previousEventSelection:Array<SongEventData>; + var previousNoteSelection:Array<SongNoteData> = []; + var previousEventSelection:Array<SongEventData> = []; - public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>) - { - this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; - this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; - } + public function new() {} public function execute(state:ChartEditorState):Void { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + state.currentNoteSelection = []; state.currentEventSelection = []; @@ -35,6 +34,12 @@ class DeselectAllItemsCommand implements ChartEditorCommand state.noteDisplayDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (previousNoteSelection.length > 0 || previousEventSelection.length > 0); + } + public function toString():String { return 'Deselect All Items'; diff --git a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx index d679b5363..6a115a26a 100644 --- a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx @@ -45,16 +45,27 @@ class DeselectItemsCommand implements ChartEditorCommand state.notePreviewDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + public function toString():String { - var noteCount = notes.length + events.length; + var isPlural = (notes.length + events.length) > 1; + var notesOnly = (notes.length > 0 && events.length == 0); + var eventsOnly = (notes.length == 0 && events.length > 0); - if (noteCount == 1) + if (notesOnly) { - var dir:String = notes[0].getDirectionName(); - return 'Deselect $dir Items'; + return 'Deselect ${notes.length} ${isPlural ? 'Notes' : 'Note'}'; + } + else if (eventsOnly) + { + return 'Deselect ${events.length} ${isPlural ? 'Events' : 'Event'}'; } - return 'Deselect ${noteCount} Items'; + return 'Deselect ${notes.length + events.length} Items'; } } diff --git a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx index 47da0dde5..3ef9f22d1 100644 --- a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx @@ -13,17 +13,25 @@ class ExtendNoteLengthCommand implements ChartEditorCommand var note:SongNoteData; var oldLength:Float; var newLength:Float; + var unit:Unit; - public function new(note:SongNoteData, newLength:Float) + public function new(note:SongNoteData, newLength:Float, unit:Unit = MILLISECONDS) { this.note = note; this.oldLength = note.length; this.newLength = newLength; + this.unit = unit; } public function execute(state:ChartEditorState):Void { - note.length = newLength; + switch (unit) + { + case MILLISECONDS: + this.note.length = newLength; + case STEPS: + this.note.setStepLength(newLength); + } state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -36,7 +44,8 @@ class ExtendNoteLengthCommand implements ChartEditorCommand { state.playSound(Paths.sound('chartingSounds/undo')); - note.length = oldLength; + // Always use milliseconds for undoing + this.note.length = oldLength; state.saveDataDirty = true; state.noteDisplayDirty = true; @@ -45,8 +54,31 @@ class ExtendNoteLengthCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (oldLength != newLength); + } + public function toString():String { - return 'Extend Note Length'; + if (oldLength == 0) + { + return 'Add Hold to Note'; + } + else if (newLength == 0) + { + return 'Remove Hold from Note'; + } + else + { + return 'Extend Hold Note Length'; + } } } + +enum Unit +{ + MILLISECONDS; + STEPS; +} diff --git a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx index da8ec7fbc..f54ffed15 100644 --- a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx +++ b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx @@ -51,6 +51,12 @@ class FlipNotesCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + public function toString():String { var len:Int = notes.length; diff --git a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx index 6e37bcc03..d9a28f463 100644 --- a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx @@ -12,19 +12,19 @@ import funkin.data.song.SongDataUtils; @:access(funkin.ui.debug.charting.ChartEditorState) class InvertSelectedItemsCommand implements ChartEditorCommand { - var previousNoteSelection:Array<SongNoteData>; - var previousEventSelection:Array<SongEventData>; + var previousNoteSelection:Array<SongNoteData> = []; + var previousEventSelection:Array<SongEventData> = []; - public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>) - { - this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; - this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; - } + public function new() {} public function execute(state:ChartEditorState):Void { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection); state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection); + state.noteDisplayDirty = true; } @@ -36,6 +36,12 @@ class InvertSelectedItemsCommand implements ChartEditorCommand state.noteDisplayDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (previousNoteSelection.length > 0 || previousEventSelection.length > 0); + } + public function toString():String { return 'Invert Selected Items'; diff --git a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx index efe9c25d5..ed50ad33e 100644 --- a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx @@ -33,7 +33,7 @@ class MoveEventsCommand implements ChartEditorCommand { // Clone the notes to prevent editing from affecting the history. var resultEvent = event.clone(); - resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); movedEvents.push(resultEvent); } @@ -65,6 +65,12 @@ class MoveEventsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + public function toString():String { var len:Int = events.length; diff --git a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx index 2eedbbf03..f44cb973a 100644 --- a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx @@ -21,8 +21,8 @@ class MoveItemsCommand implements ChartEditorCommand public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, offset:Float, columns:Int) { // Clone the notes to prevent editing from affecting the history. - this.notes = [for (note in notes) note.clone()]; - this.events = [for (event in events) event.clone()]; + this.notes = notes.clone(); + this.events = events.clone(); this.offset = offset; this.columns = columns; this.movedNotes = []; @@ -41,7 +41,7 @@ class MoveItemsCommand implements ChartEditorCommand { // Clone the notes to prevent editing from affecting the history. var resultNote = note.clone(); - resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0, ChartEditorState.STRUMLINE_SIZE * 2 - 1)); @@ -52,7 +52,7 @@ class MoveItemsCommand implements ChartEditorCommand { // Clone the notes to prevent editing from affecting the history. var resultEvent = event.clone(); - resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); movedEvents.push(resultEvent); } @@ -88,6 +88,12 @@ class MoveItemsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + public function toString():String { var len:Int = notes.length + events.length; diff --git a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx index 8bce747a1..51aeb5bbc 100644 --- a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx @@ -34,7 +34,7 @@ class MoveNotesCommand implements ChartEditorCommand { // Clone the notes to prevent editing from affecting the history. var resultNote = note.clone(); - resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0, ChartEditorState.STRUMLINE_SIZE * 2 - 1)); @@ -67,6 +67,12 @@ class MoveNotesCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + public function toString():String { var len:Int = notes.length; diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx index 75382da41..257db94b4 100644 --- a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx @@ -32,9 +32,9 @@ class PasteItemsCommand implements ChartEditorCommand return; } - var stepEndOfSong:Float = Conductor.getTimeInSteps(state.songLengthInMs); + var stepEndOfSong:Float = Conductor.instance.getTimeInSteps(state.songLengthInMs); var stepCutoff:Float = stepEndOfSong - 1.0; - var msCutoff:Float = Conductor.getStepTimeInMs(stepCutoff); + var msCutoff:Float = Conductor.instance.getStepTimeInMs(stepCutoff); addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp)); addedNotes = SongDataUtils.clampSongNoteData(addedNotes, 0.0, msCutoff); @@ -71,6 +71,12 @@ class PasteItemsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (addedNotes.length > 0 || addedEvents.length > 0); + } + public function toString():String { var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard(); diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx index 7e620c210..b4d913607 100644 --- a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx @@ -48,6 +48,12 @@ class RemoveEventsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + public function toString():String { if (events.length == 1 && events[0] != null) diff --git a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx index 77184209e..69317aff4 100644 --- a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx @@ -62,6 +62,12 @@ class RemoveItemsCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + public function toString():String { return 'Remove ${notes.length + events.length} Items'; diff --git a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx index e189be83e..4811f831d 100644 --- a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx +++ b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx @@ -50,6 +50,12 @@ class RemoveNotesCommand implements ChartEditorCommand state.sortChartData(); } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + public function toString():String { if (notes.length == 1 && notes[0] != null) diff --git a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx index e1a4dceaa..f550e044b 100644 --- a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx @@ -10,19 +10,25 @@ import funkin.data.song.SongData.SongEventData; @:access(funkin.ui.debug.charting.ChartEditorState) class SelectAllItemsCommand implements ChartEditorCommand { - var previousNoteSelection:Array<SongNoteData>; - var previousEventSelection:Array<SongEventData>; + var shouldSelectNotes:Bool; + var shouldSelectEvents:Bool; - public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>) + var previousNoteSelection:Array<SongNoteData> = []; + var previousEventSelection:Array<SongEventData> = []; + + public function new(shouldSelectNotes:Bool, shouldSelectEvents:Bool) { - this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; - this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; + this.shouldSelectNotes = shouldSelectNotes; + this.shouldSelectEvents = shouldSelectEvents; } public function execute(state:ChartEditorState):Void { - state.currentNoteSelection = state.currentSongChartNoteData; - state.currentEventSelection = state.currentSongChartEventData; + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + + state.currentNoteSelection = shouldSelectNotes ? state.currentSongChartNoteData : []; + state.currentEventSelection = shouldSelectEvents ? state.currentSongChartEventData : []; state.noteDisplayDirty = true; } @@ -35,8 +41,29 @@ class SelectAllItemsCommand implements ChartEditorCommand state.noteDisplayDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (state.currentNoteSelection.length > 0 || state.currentEventSelection.length > 0); + } + public function toString():String { - return 'Select All Items'; + if (shouldSelectNotes && !shouldSelectEvents) + { + return 'Select All Notes'; + } + else if (shouldSelectEvents && !shouldSelectNotes) + { + return 'Select All Events'; + } + else if (shouldSelectNotes && shouldSelectEvents) + { + return 'Select All Notes and Events'; + } + else + { + return 'Select Nothing (Huh?)'; + } } } diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx index abe8b9e35..d6c5beeac 100644 --- a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx @@ -15,10 +15,10 @@ class SelectItemsCommand implements ChartEditorCommand var notes:Array<SongNoteData>; var events:Array<SongEventData>; - public function new(notes:Array<SongNoteData>, events:Array<SongEventData>) + public function new(?notes:Array<SongNoteData>, ?events:Array<SongEventData>) { - this.notes = notes; - this.events = events; + this.notes = notes ?? []; + this.events = events ?? []; } public function execute(state:ChartEditorState):Void @@ -33,6 +33,32 @@ class SelectItemsCommand implements ChartEditorCommand state.currentEventSelection.push(event); } + // 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) + { + var eventSelected = this.events[0]; + + state.eventKindToPlace = eventSelected.event; + + // 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. + var eventSchema = eventSelected.getSchema(); + var defaultKey = null; + if (eventSchema == null) + { + trace('[WARNING] Event schema not found for event ${eventSelected.event}.'); + } + else + { + defaultKey = eventSchema.getFirstField()?.name; + } + var eventData = eventSelected.valueAsStruct(defaultKey); + + state.eventDataToPlace = eventData; + + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + state.noteDisplayDirty = true; state.notePreviewDirty = true; } @@ -46,6 +72,12 @@ class SelectItemsCommand implements ChartEditorCommand state.notePreviewDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + public function toString():String { var len:Int = notes.length + events.length; diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx new file mode 100644 index 000000000..ca1fda6b9 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx @@ -0,0 +1,113 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * without deleting them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetAudioOffsetCommand implements ChartEditorCommand +{ + var type:AudioOffsetType; + var oldOffset:Float = 0; + var newOffset:Float; + var refreshOffsetsToolbox:Bool; + + public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true) + { + this.type = type; + this.newOffset = newOffset; + this.refreshOffsetsToolbox = refreshOffsetsToolbox; + } + + public function execute(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + oldOffset = state.currentInstrumentalOffset; + state.currentInstrumentalOffset = newOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + oldOffset = state.currentVocalOffsetPlayer; + state.currentVocalOffsetPlayer = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + oldOffset = state.currentVocalOffsetOpponent; + state.currentVocalOffsetOpponent = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + if (refreshOffsetsToolbox) + { + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT); + } + } + + public function undo(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + state.currentInstrumentalOffset = oldOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + state.currentVocalOffsetPlayer = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + state.currentVocalOffsetOpponent = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (newOffset != oldOffset); + } + + public function toString():String + { + switch (type) + { + case INSTRUMENTAL: + return 'Set Inst. Audio Offset to $newOffset'; + case PLAYER: + return 'Set Player Audio Offset to $newOffset'; + case OPPONENT: + return 'Set Opponent Audio Offset to $newOffset'; + } + } +} + +enum AudioOffsetType +{ + INSTRUMENTAL; + PLAYER; + OPPONENT; +} diff --git a/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx new file mode 100644 index 000000000..232768904 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx @@ -0,0 +1,62 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Command that sets the start time or end time of the Freeplay preview. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetFreeplayPreviewCommand implements ChartEditorCommand +{ + var previousStartTime:Int = 0; + var previousEndTime:Int = 0; + var newStartTime:Null<Int> = null; + var newEndTime:Null<Int> = null; + + public function new(newStartTime:Null<Int>, newEndTime:Null<Int>) + { + this.newStartTime = newStartTime; + this.newEndTime = newEndTime; + } + + public function execute(state:ChartEditorState):Void + { + this.previousStartTime = state.currentSongFreeplayPreviewStart; + this.previousEndTime = state.currentSongFreeplayPreviewEnd; + + if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime; + if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime; + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongFreeplayPreviewStart = previousStartTime; + state.currentSongFreeplayPreviewEnd = previousEndTime; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime); + } + + public function toString():String + { + var setStart = newStartTime != null && newStartTime != previousStartTime; + var setEnd = newEndTime != null && newEndTime != previousEndTime; + + if (setStart && !setEnd) + { + return "Set Freeplay Preview Start Time"; + } + else if (setEnd && !setStart) + { + return "Set Freeplay Preview End Time"; + } + else + { + return "Set Freeplay Preview Start and End Times"; + } + } +} diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx index a06aefabc..35a00e562 100644 --- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx @@ -13,23 +13,49 @@ class SetItemSelectionCommand implements ChartEditorCommand { var notes:Array<SongNoteData>; var events:Array<SongEventData>; - var previousNoteSelection:Array<SongNoteData>; - var previousEventSelection:Array<SongEventData>; + var previousNoteSelection:Array<SongNoteData> = []; + var previousEventSelection:Array<SongEventData> = []; - public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>, - previousEventSelection:Array<SongEventData>) + public function new(notes:Array<SongNoteData>, events:Array<SongEventData>) { this.notes = notes; this.events = events; - this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection; - this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection; } public function execute(state:ChartEditorState):Void { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + state.currentNoteSelection = notes; 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) + { + var eventSelected = this.events[0]; + + state.eventKindToPlace = eventSelected.event; + + // 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. + var eventSchema = eventSelected.getSchema(); + var defaultKey = null; + if (eventSchema == null) + { + trace('[WARNING] Event schema not found for event ${eventSelected.event}.'); + } + else + { + defaultKey = eventSchema.getFirstField()?.name; + } + var eventData = eventSelected.valueAsStruct(defaultKey); + + state.eventDataToPlace = eventData; + + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + state.noteDisplayDirty = true; } @@ -41,8 +67,14 @@ class SetItemSelectionCommand implements ChartEditorCommand state.noteDisplayDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // Add to the history if we actually performed an action. + return (state.currentNoteSelection != previousNoteSelection && state.currentEventSelection != previousEventSelection); + } + public function toString():String { - return 'Select ${notes.length} Items'; + return 'Select ${notes.length + events.length} Items'; } } diff --git a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx index 75e7e5afe..30c2edb61 100644 --- a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx @@ -38,6 +38,12 @@ class SwitchDifficultyCommand implements ChartEditorCommand state.notePreviewDirty = true; } + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // Add to the history if we actually performed an action. + return (prevVariation != newVariation || prevDifficulty != newDifficulty); + } + public function toString():String { return 'Switch Difficulty'; diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx index 4c9d91407..e3dae37cf 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx @@ -1,6 +1,6 @@ package funkin.ui.debug.charting.components; -import funkin.data.event.SongEventData.SongEventParser; +import funkin.data.event.SongEventRegistry; import flixel.graphics.frames.FlxAtlasFrames; import openfl.display.BitmapData; import openfl.utils.Assets; @@ -11,6 +11,9 @@ import flixel.graphics.frames.FlxFramesCollection; import flixel.graphics.frames.FlxTileFrames; import flixel.math.FlxPoint; import funkin.data.song.SongData.SongEventData; +import haxe.ui.tooltips.ToolTipRegionOptions; +import funkin.util.HaxeUIUtil; +import haxe.ui.tooltips.ToolTipManager; /** * A sprite that can be used to display a song event in a chart. @@ -36,6 +39,13 @@ class ChartEditorEventSprite extends FlxSprite public var overrideStepTime(default, set):Null<Float> = null; + public var tooltip:ToolTipRegionOptions; + + /** + * Whether this sprite is a "ghost" sprite used when hovering to place a new event. + */ + public var isGhost:Bool = false; + function set_overrideStepTime(value:Null<Float>):Null<Float> { if (overrideStepTime == value) return overrideStepTime; @@ -45,12 +55,14 @@ class ChartEditorEventSprite extends FlxSprite return overrideStepTime; } - public function new(parent:ChartEditorState) + public function new(parent:ChartEditorState, isGhost:Bool = false) { super(); this.parentState = parent; + this.isGhost = isGhost; + this.tooltip = HaxeUIUtil.buildTooltip('N/A'); this.frames = buildFrames(); buildAnimations(); @@ -79,7 +91,7 @@ class ChartEditorEventSprite extends FlxSprite } // Push all the other events as frames. - for (eventName in SongEventParser.listEventIds()) + for (eventName in SongEventRegistry.listEventIds()) { var exists:Bool = Assets.exists(Paths.image('ui/chart-editor/events/$eventName')); if (!exists) continue; // No graphic for this event. @@ -105,7 +117,7 @@ class ChartEditorEventSprite extends FlxSprite function buildAnimations():Void { - var eventNames:Array<String> = [DEFAULT_EVENT].concat(SongEventParser.listEventIds()); + var eventNames:Array<String> = [DEFAULT_EVENT].concat(SongEventRegistry.listEventIds()); for (eventName in eventNames) { this.animation.addByPrefix(eventName, '${eventName}0', 24, false); @@ -119,8 +131,10 @@ class ChartEditorEventSprite extends FlxSprite return DEFAULT_EVENT; } - public function playAnimation(name:String):Void + public function playAnimation(?name:String):Void { + if (name == null) name = eventData?.event ?? DEFAULT_EVENT; + var correctedName = correctAnimationName(name); this.animation.play(correctedName); refresh(); @@ -140,17 +154,18 @@ class ChartEditorEventSprite extends FlxSprite // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING this.kill(); this.visible = false; + updateTooltipPosition(); return null; } else { this.visible = true; - // Only play the animation if the event type has changed. - // if (this.eventData == null || this.eventData.event != value.event) playAnimation(value.event); this.eventData = value; // Update the position to match the note data. updateEventPosition(); + // Update the tooltip text. + this.tooltip.tipData = {text: this.eventData.buildTooltip()}; return this.eventData; } } @@ -169,6 +184,31 @@ class ChartEditorEventSprite extends FlxSprite this.x += origin.x; this.y += origin.y; } + + this.updateTooltipPosition(); + } + + public function updateTooltipPosition():Void + { + // No tooltip for ghost sprites. + if (this.isGhost) return; + + if (this.eventData == null) + { + // Disable the tooltip. + ToolTipManager.instance.unregisterTooltipRegion(this.tooltip); + } + else + { + // Update the position. + this.tooltip.left = this.x; + this.tooltip.top = this.y; + this.tooltip.width = this.width; + this.tooltip.height = this.height; + + // Enable the tooltip. + ToolTipManager.instance.registerTooltipRegion(this.tooltip); + } } /** diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx index e5971db08..c7f7747c0 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx @@ -39,6 +39,17 @@ class ChartEditorHoldNoteSprite extends SustainTrail setup(); } + public override function updateHitbox():Void + { + // Expand the clickable hitbox to the full column width, then nudge to the left to re-center it. + width = ChartEditorState.GRID_SIZE; + height = graphicHeight; + + var xOffset = (ChartEditorState.GRID_SIZE - graphicWidth) / 2; + offset.set(-xOffset, 0); + origin.set(width * 0.5, height * 0.5); + } + /** * Set the height directly, to a value in pixels. * @param h The desired height in pixels. @@ -52,6 +63,25 @@ class ChartEditorHoldNoteSprite extends SustainTrail fullSustainLength = sustainLength; } + #if FLX_DEBUG + /** + * Call this to override how debug bounding boxes are drawn for this sprite. + */ + public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) return; + + var rect = getBoundingBox(camera); + trace('hold note bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height); + + var gfx = beginDrawDebug(camera); + debugBoundingBoxColor = 0xffFF66FF; + gfx.lineStyle(2, color, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y, rect.width, rect.height); + endDrawDebug(camera); + } + #end + function setup():Void { strumTime = 999999999; @@ -60,7 +90,9 @@ class ChartEditorHoldNoteSprite extends SustainTrail active = true; visible = true; alpha = 1.0; - width = graphic.width / 8 * zoom; // amount of notes * 2 + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + + updateHitbox(); } public override function revive():Void @@ -154,7 +186,7 @@ class ChartEditorHoldNoteSprite extends SustainTrail } this.x += ChartEditorState.GRID_SIZE / 2; - this.x -= this.width / 2; + this.x -= this.graphicWidth / 2; this.y += ChartEditorState.GRID_SIZE / 2; @@ -163,5 +195,8 @@ class ChartEditorHoldNoteSprite extends SustainTrail this.x += origin.x; this.y += origin.y; } + + // Account for expanded clickable hitbox. + this.x += this.offset.x; } } diff --git a/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx new file mode 100644 index 000000000..1a76d1e22 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx @@ -0,0 +1,71 @@ +package funkin.ui.debug.charting.components; + +import flixel.FlxSprite; +import flixel.addons.display.FlxTiledSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.text.FlxText; +import flixel.util.FlxColor; + +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorMeasureTicks extends FlxTypedSpriteGroup<FlxSprite> +{ + var chartEditorState:ChartEditorState; + + var tickTiledSprite:FlxTiledSprite; + var measureNumber:FlxText; + + override function set_y(value:Float):Float + { + var result = super.set_y(value); + + updateMeasureNumber(); + + return result; + } + + public function new(chartEditorState:ChartEditorState) + { + super(); + + this.chartEditorState = chartEditorState; + + tickTiledSprite = new FlxTiledSprite(chartEditorState.measureTickBitmap, chartEditorState.measureTickBitmap.width, 1000, false, true); + add(tickTiledSprite); + + measureNumber = new FlxText(0, 0, ChartEditorState.GRID_SIZE, "1"); + measureNumber.setFormat(Paths.font('vcr.ttf'), 20, FlxColor.WHITE); + measureNumber.borderStyle = FlxTextBorderStyle.OUTLINE; + measureNumber.borderColor = FlxColor.BLACK; + add(measureNumber); + } + + public function reloadTickBitmap():Void + { + tickTiledSprite.loadGraphic(chartEditorState.measureTickBitmap); + } + + /** + * At time of writing, we only have to manipulate one measure number because we can only see one measure at a time. + */ + function updateMeasureNumber() + { + if (measureNumber == null) return; + + var viewTopPosition = 0 - this.y; + var viewHeight = FlxG.height - ChartEditorState.MENU_BAR_HEIGHT - ChartEditorState.PLAYBAR_HEIGHT; + var viewBottomPosition = viewTopPosition + viewHeight; + + 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; + + // trace(measureNumber.text + ' at ' + measureNumber.y); + } + + public function setHeight(songLengthInPixels:Float):Void + { + tickTiledSprite.height = songLengthInPixels; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx index 09c99531d..8d9ec6743 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx @@ -70,9 +70,9 @@ class ChartEditorNotePreview extends FlxSprite * @param event The data for the event. * @param songLengthInMs The total length of the song in milliseconds. */ - public function addEvent(event:SongEventData, songLengthInMs:Int):Void + public function addEvent(event:SongEventData, songLengthInMs:Int, ?isSelection:Bool = false):Void { - drawNote(-1, false, Std.int(event.time), songLengthInMs); + drawNote(-1, false, Std.int(event.time), songLengthInMs, isSelection); } /** @@ -80,11 +80,24 @@ class ChartEditorNotePreview extends FlxSprite * @param notes The data for the notes. * @param songLengthInMs The total length of the song in milliseconds. */ - public function addNotes(notes:Array<SongNoteData>, songLengthInMs:Int, ?isSelection:Bool = false):Void + public function addNotes(notes:Array<SongNoteData>, songLengthInMs:Int):Void { for (note in notes) { - addNote(note, songLengthInMs, isSelection); + addNote(note, songLengthInMs, false); + } + } + + /** + * Add an array of selected notes to the preview. + * @param notes The data for the notes. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addSelectedNotes(notes:Array<SongNoteData>, songLengthInMs:Int):Void + { + for (note in notes) + { + addNote(note, songLengthInMs, true); } } @@ -101,6 +114,19 @@ class ChartEditorNotePreview extends FlxSprite } } + /** + * Add an array of selected events to the preview. + * @param events The data for the events. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addSelectedEvents(events:Array<SongEventData>, songLengthInMs:Int):Void + { + for (event in events) + { + addEvent(event, songLengthInMs, true); + } + } + /** * Draws a note on the preview. * @param dir Note data. diff --git a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx index 8f7c4aaec..14266b71a 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx @@ -1,20 +1,33 @@ package funkin.ui.debug.charting.components; +import flixel.addons.display.FlxSliceSprite; import flixel.FlxSprite; -import funkin.data.song.SongData.SongNoteData; +import flixel.math.FlxRect; import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.handlers.ChartEditorThemeHandler; /** * A sprite that can be used to display a square over a selected note or event in the chart. * Designed to be used and reused efficiently. Has no gameplay functionality. */ -class ChartEditorSelectionSquareSprite extends FlxSprite +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorSelectionSquareSprite extends FlxSliceSprite { public var noteData:Null<SongNoteData>; public var eventData:Null<SongEventData>; - public function new() + public function new(chartEditorState:ChartEditorState) { - super(); + super(chartEditorState.selectionSquareBitmap, + new FlxRect(ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + + 4, ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + + 4, + ChartEditorState.GRID_SIZE + - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8), + ChartEditorState.GRID_SIZE + - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8)), + 32, 32); } } diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx new file mode 100644 index 000000000..f25f3ebb3 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx @@ -0,0 +1,19 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; + +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorBaseContextMenu extends Menu +{ + var chartEditorState:ChartEditorState; + + public function new(chartEditorState:ChartEditorState, xPos:Float = 0, yPos:Float = 0) + { + super(); + + this.chartEditorState = chartEditorState; + + this.left = xPos; + this.top = yPos; + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx new file mode 100644 index 000000000..9529cc2fd --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx @@ -0,0 +1,14 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; +import haxe.ui.core.Screen; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/default.xml")) +class ChartEditorDefaultContextMenu extends ChartEditorBaseContextMenu +{ + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0) + { + super(chartEditorState2, xPos2, yPos2); + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx new file mode 100644 index 000000000..d848f1435 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx @@ -0,0 +1,36 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Screen; +import funkin.data.song.SongData.SongEventData; +import funkin.ui.debug.charting.commands.RemoveEventsCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/event.xml")) +class ChartEditorEventContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuEdit:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongEventData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongEventData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize() + { + contextmenuEdit.onClick = function(_) { + chartEditorState.showToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveEventsCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx new file mode 100644 index 000000000..9f58d2f03 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx @@ -0,0 +1,43 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Screen; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.commands.FlipNotesCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; +import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/hold-note.xml")) +class ChartEditorHoldNoteContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuFlip:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongNoteData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize():Void + { + // NOTE: Remember to use commands here to ensure undo/redo works properly + contextmenuFlip.onClick = function(_) { + chartEditorState.performCommand(new FlipNotesCommand([data])); + } + + contextmenuRemoveHold.onClick = function(_) { + chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 0)); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveNotesCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx new file mode 100644 index 000000000..66bf6f3ee --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx @@ -0,0 +1,43 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Screen; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.commands.FlipNotesCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; +import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/note.xml")) +class ChartEditorNoteContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuFlip:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongNoteData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize():Void + { + // NOTE: Remember to use commands here to ensure undo/redo works properly + contextmenuFlip.onClick = function(_) { + chartEditorState.performCommand(new FlipNotesCommand([data])); + } + + contextmenuAddHold.onClick = function(_) { + chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 4, STEPS)); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveNotesCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx new file mode 100644 index 000000000..feed9b689 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx @@ -0,0 +1,58 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Screen; +import funkin.ui.debug.charting.commands.CutItemsCommand; +import funkin.ui.debug.charting.commands.RemoveEventsCommand; +import funkin.ui.debug.charting.commands.RemoveItemsCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/selection.xml")) +class ChartEditorSelectionContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuCut:MenuItem; + var contextmenuCopy:MenuItem; + var contextmenuPaste:MenuItem; + var contextmenuDelete:MenuItem; + var contextmenuFlip:MenuItem; + var contextmenuSelectAll:MenuItem; + var contextmenuSelectInverse:MenuItem; + var contextmenuSelectNone:MenuItem; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0) + { + super(chartEditorState2, xPos2, yPos2); + + initialize(); + } + + function initialize():Void + { + contextmenuCut.onClick = (_) -> { + chartEditorState.performCommand(new CutItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection)); + }; + contextmenuCopy.onClick = (_) -> { + chartEditorState.copySelection(); + }; + contextmenuFlip.onClick = (_) -> { + if (chartEditorState.currentNoteSelection.length > 0 && chartEditorState.currentEventSelection.length > 0) + { + chartEditorState.performCommand(new RemoveItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection)); + } + else if (chartEditorState.currentNoteSelection.length > 0) + { + chartEditorState.performCommand(new RemoveNotesCommand(chartEditorState.currentNoteSelection)); + } + else if (chartEditorState.currentEventSelection.length > 0) + { + chartEditorState.performCommand(new RemoveEventsCommand(chartEditorState.currentEventSelection)); + } + else + { + // Do nothing??? + } + }; + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx index 5b84148c6..17f047106 100644 --- a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx @@ -13,6 +13,7 @@ import haxe.ui.notifications.NotificationType; // @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. @:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-chart.xml")) +@:access(funkin.ui.debug.charting.ChartEditorState) class ChartEditorUploadChartDialog extends ChartEditorBaseDialog { var dropHandlers:Array<DialogDropTarget> = []; diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx new file mode 100644 index 000000000..537c7c36e --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx @@ -0,0 +1,311 @@ +package funkin.ui.debug.charting.dialogs; + +import funkin.input.Cursor; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams; +import funkin.util.FileUtil; +import funkin.play.character.CharacterData; +import haxe.io.Path; +import haxe.ui.components.Button; +import haxe.ui.components.Label; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import haxe.ui.containers.dialogs.Dialog.DialogEvent; +import haxe.ui.containers.Box; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.core.Component; +import haxe.ui.notifications.NotificationManager; +import haxe.ui.notifications.NotificationType; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals.xml")) +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorUploadVocalsDialog extends ChartEditorBaseDialog +{ + var dropHandlers:Array<DialogDropTarget> = []; + + var vocalContainer:Component; + var dialogCancel:Button; + var dialogNoVocals:Button; + var dialogContinue:Button; + + var charIds:Array<String>; + var instId:String; + var hasClearedVocals:Bool = false; + + public function new(state2:ChartEditorState, charIds:Array<String>, params2:DialogParams) + { + super(state2, params2); + + this.charIds = charIds; + this.instId = chartEditorState.currentInstrumentalId; + + dialogCancel.onClick = function(_) { + hideDialog(DialogButton.CANCEL); + } + + dialogNoVocals.onClick = function(_) { + // Dismiss + chartEditorState.wipeVocalData(); + hideDialog(DialogButton.APPLY); + }; + + dialogContinue.onClick = function(_) { + // Dismiss + hideDialog(DialogButton.APPLY); + }; + + buildDropHandlers(); + } + + function buildDropHandlers():Void + { + for (charKey in charIds) + { + trace('Adding vocal upload for character ${charKey}'); + + var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey); + var charName:String = charMetadata?.name ?? charKey; + + var vocalsEntry = new ChartEditorUploadVocalsEntry(charName); + + var dropHandler:DialogDropTarget = {component: vocalsEntry, handler: null}; + + var onDropFile:String->Void = function(pathStr:String) { + trace('Selected file: $pathStr'); + var path:Path = new Path(pathStr); + + if (chartEditorState.loadVocalsFromPath(path, charKey, this.instId, !this.hasClearedVocals)) + { + this.hasClearedVocals = true; + // Tell the user the load was successful. + chartEditorState.success('Loaded Vocals', 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${chartEditorState.selectedVariation}'); + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}'; + #end + + dialogNoVocals.hidden = true; + chartEditorState.removeDropHandler(dropHandler); + } + else + { + trace('Failed to load vocal track (${path.file}.${path.ext})'); + + chartEditorState.error('Failed to Load Vocals', + 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${chartEditorState.selectedVariation})'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + } + }; + + vocalsEntry.onClick = function(_event) { + Dialogs.openBinaryFile('Open $charName Vocals', [ + {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) { + if (selectedFile != null && selectedFile.bytes != null) + { + trace('Selected file: ' + selectedFile.name); + + if (chartEditorState.loadVocalsFromBytes(selectedFile.bytes, charKey, this.instId, !this.hasClearedVocals)) + { + hasClearedVocals = true; + // Tell the user the load was successful. + chartEditorState.success('Loaded Vocals', + 'Loaded vocals for $charName (${selectedFile.name}), variation ${chartEditorState.selectedVariation}'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}'; + #end + + dialogNoVocals.hidden = true; + } + else + { + trace('Failed to load vocal track (${selectedFile.fullPath})'); + + chartEditorState.error('Failed to Load Vocals', + 'Failed to load vocal track (${selectedFile.name}) for variation (${chartEditorState.selectedVariation})'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + } + } + }); + } + + dropHandler.handler = onDropFile; + + // onDropFile + #if FILE_DROP_SUPPORTED + dropHandlers.push(dropHandler); + #end + + vocalContainer.addComponent(vocalsEntry); + } + } + + public static function build(state:ChartEditorState, charIds:Array<String>, ?closable:Bool, ?modal:Bool):ChartEditorUploadVocalsDialog + { + var dialog = new ChartEditorUploadVocalsDialog(state, charIds, + { + closable: closable ?? false, + modal: modal ?? true + }); + + for (dropTarget in dialog.dropHandlers) + { + state.addDropHandler(dropTarget); + } + + dialog.showDialog(modal ?? true); + + return dialog; + } + + public override function onClose(event:DialogEvent):Void + { + super.onClose(event); + + if (event.button != DialogButton.APPLY && !this.closable) + { + // User cancelled the wizard! Back to the welcome dialog. + chartEditorState.openWelcomeDialog(this.closable); + } + + for (dropTarget in dropHandlers) + { + chartEditorState.removeDropHandler(dropTarget); + } + } + + public override function lock():Void + { + super.lock(); + this.dialogCancel.disabled = true; + } + + public override function unlock():Void + { + super.unlock(); + this.dialogCancel.disabled = false; + } + + /** + * Called when clicking the Upload Chart box. + */ + public function onClickChartBox():Void + { + if (this.locked) return; + + this.lock(); + // TODO / BUG: File filtering not working on mac finder dialog, so we don't use it for now + #if !mac + FileUtil.browseForBinaryFile('Open Chart', [FileUtil.FILE_EXTENSION_INFO_FNFC], onSelectFile, onCancelBrowse); + #else + FileUtil.browseForBinaryFile('Open Chart', null, onSelectFile, onCancelBrowse); + #end + } + + /** + * Called when a file is selected by dropping a file onto the Upload Chart box. + */ + function onDropFileChartBox(pathStr:String):Void + { + var path:Path = new Path(pathStr); + trace('Dropped file (${path})'); + + try + { + var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(chartEditorState, path.toString()); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${path.toString()})' : 'Loaded chart (${path.toString()})\n${result.join("\n")}'); + this.hideDialog(DialogButton.APPLY); + } + else + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()})'); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()}): ${err}'); + } + } + + /** + * Called when a file is selected by the dialog displayed when clicking the Upload Chart box. + */ + function onSelectFile(selectedFile:SelectedFileInfo):Void + { + this.unlock(); + + if (selectedFile != null && selectedFile.bytes != null) + { + try + { + var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFC(chartEditorState, selectedFile.bytes); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${selectedFile.name})' : 'Loaded chart (${selectedFile.name})\n${result.join("\n")}'); + + if (selectedFile.fullPath != null) chartEditorState.currentWorkingFilePath = selectedFile.fullPath; + this.hideDialog(DialogButton.APPLY); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${selectedFile.name}): ${err}'); + } + } + } + + function onCancelBrowse():Void + { + this.unlock(); + } +} + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals-entry.xml")) +class ChartEditorUploadVocalsEntry extends Box +{ + public var vocalsEntryLabel:Label; + + var charName:String; + + public function new(charName:String) + { + super(); + + this.charName = charName; + + #if FILE_DROP_SUPPORTED + vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + + this.onMouseOver = function(_event) { + // if (this.locked) return; + this.swapClass('upload-bg', 'upload-bg-hover'); + Cursor.cursorMode = Pointer; + } + + this.onMouseOut = function(_event) { + this.swapClass('upload-bg-hover', 'upload-bg'); + Cursor.cursorMode = Default; + } + } +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx index 272291a94..76b2a388e 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -3,11 +3,14 @@ package funkin.ui.debug.charting.handlers; import flixel.system.FlxAssets.FlxSoundAsset; import flixel.system.FlxSound; import funkin.audio.VoicesGroup; -import funkin.audio.visualize.PolygonVisGroup; import funkin.audio.FunkinSound; import funkin.play.character.BaseCharacter.CharacterType; import funkin.util.FileUtil; import funkin.util.assets.SoundUtil; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.audio.waveform.WaveformSprite; +import flixel.util.FlxColor; import haxe.io.Bytes; import haxe.io.Path; import openfl.utils.Assets; @@ -28,11 +31,11 @@ class ChartEditorAudioHandler * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool + public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = '', wipeFirst:Bool = false):Bool { #if sys var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); - return loadVocalsFromBytes(state, fileBytes, charId, instId); + return loadVocalsFromBytes(state, fileBytes, charId, instId, wipeFirst); #else trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); return false; @@ -47,12 +50,12 @@ class ChartEditorAudioHandler * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool + public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = '', wipeFirst:Bool = false):Bool { var trackData:Null<Bytes> = Assets.getBytes(path); if (trackData != null) { - return loadVocalsFromBytes(state, trackData, charId, instId); + return loadVocalsFromBytes(state, trackData, charId, instId, wipeFirst); } return false; } @@ -63,10 +66,12 @@ class ChartEditorAudioHandler * @param bytes The audio byte data. * @param charId The character this vocal track will be for. * @param instId The instrumental this vocal track will be for. + * @param wipeFirst Whether to wipe the existing vocal data before loading. */ - public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool + public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = '', wipeFirst:Bool = false):Bool { var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; + if (wipeFirst) wipeVocalData(state); state.audioVocalTrackData.set(trackId, bytes); return true; } @@ -78,11 +83,11 @@ class ChartEditorAudioHandler * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool + public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = '', wipeFirst:Bool = false):Bool { #if sys var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); - return loadInstFromBytes(state, fileBytes, instId); + return loadInstFromBytes(state, fileBytes, instId, wipeFirst); #else trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); return false; @@ -96,12 +101,12 @@ class ChartEditorAudioHandler * @param instId The instrumental this vocal track will be for. * @return Success or failure. */ - public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool + public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = '', wipeFirst:Bool = false):Bool { var trackData:Null<Bytes> = Assets.getBytes(path); if (trackData != null) { - return loadInstFromBytes(state, trackData, instId); + return loadInstFromBytes(state, trackData, instId, wipeFirst); } return false; } @@ -113,23 +118,51 @@ class ChartEditorAudioHandler * @param charId The character this vocal track will be for. * @param instId The instrumental this vocal track will be for. */ - public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool + public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = '', wipeFirst:Bool = false):Bool { if (instId == '') instId = 'default'; + if (wipeFirst) wipeInstrumentalData(state); state.audioInstTrackData.set(instId, bytes); return true; } public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool { + var perfA = haxe.Timer.stamp(); + var result:Bool = playInstrumental(state, instId); if (!result) return false; + var perfB = haxe.Timer.stamp(); + stopExistingVocals(state); + + var perfC = haxe.Timer.stamp(); + result = playVocals(state, BF, playerId, instId); - if (!result) return false; + + var perfD = haxe.Timer.stamp(); + + // if (!result) return false; result = playVocals(state, DAD, opponentId, instId); - if (!result) return false; + // if (!result) return false; + + var perfE = haxe.Timer.stamp(); + + state.hardRefreshOffsetsToolbox(); + + var perfF = haxe.Timer.stamp(); + + state.hardRefreshFreeplayToolbox(); + + var perfG = haxe.Timer.stamp(); + + 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.'); return true; } @@ -141,7 +174,10 @@ class ChartEditorAudioHandler { if (instId == '') instId = 'default'; var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId); + var perfA = haxe.Timer.stamp(); var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData); + var perfB = haxe.Timer.stamp(); + trace('Built instrumental track in ${perfB - perfA} seconds.'); if (instTrack == null) return false; stopExistingInstrumental(state); @@ -169,10 +205,12 @@ class ChartEditorAudioHandler { var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId); + var perfStart = haxe.Timer.stamp(); var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData); + var perfEnd = haxe.Timer.stamp(); + trace('Built vocal track in ${perfEnd - perfStart} seconds.'); if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup(); - if (state.audioVisGroup == null) state.audioVisGroup = new PolygonVisGroup(); if (vocalTrack != null) { @@ -180,24 +218,57 @@ class ChartEditorAudioHandler { case BF: state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); - state.audioVisGroup.addPlayerVis(vocalTrack); - state.audioVisGroup.playerVis.x = 885; - state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.getStepTimeInMs(16) * 0.00195; - state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; - state.audioVisGroup.playerVis.detail = 1; - state.audioVocalTrackGroup.playerVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); + var perfStart = haxe.Timer.stamp(); + var waveformData:Null<WaveformData> = vocalTrack.waveformData; + var perfEnd = haxe.Timer.stamp(); + trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.'); + + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 840; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } + + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; return true; case DAD: state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); - state.audioVisGroup.addOpponentVis(vocalTrack); - state.audioVisGroup.opponentVis.x = 435; - state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.getStepTimeInMs(16) * 0.00195; - state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; - state.audioVisGroup.opponentVis.detail = 1; + var perfStart = haxe.Timer.stamp(); + var waveformData:Null<WaveformData> = vocalTrack.waveformData; + var perfEnd = haxe.Timer.stamp(); + trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.'); - state.audioVocalTrackGroup.opponentVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 360; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } + + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; return true; case OTHER: @@ -214,9 +285,10 @@ class ChartEditorAudioHandler public static function stopExistingVocals(state:ChartEditorState):Void { - if (state.audioVocalTrackGroup != null) + state.audioVocalTrackGroup.clear(); + if (state.audioWaveforms != null) { - state.audioVocalTrackGroup.clear(); + state.audioWaveforms.clear(); } } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx new file mode 100644 index 000000000..c1eea5379 --- /dev/null +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx @@ -0,0 +1,82 @@ +package funkin.ui.debug.charting.handlers; + +import funkin.ui.debug.charting.contextmenus.ChartEditorDefaultContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorEventContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorHoldNoteContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorNoteContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorSelectionContextMenu; +import haxe.ui.containers.menus.Menu; +import haxe.ui.core.Screen; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; + +/** + * Handles context menus (the little menus that appear when you right click on stuff) for the new Chart Editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorContextMenuHandler +{ + static var existingMenus:Array<Menu> = []; + + public static function openDefaultContextMenu(state:ChartEditorState, xPos:Float, yPos:Float) + { + displayMenu(state, new ChartEditorDefaultContextMenu(state, xPos, yPos)); + } + + /** + * Opened when shift+right-clicking a selection of multiple items. + */ + public static function openSelectionContextMenu(state:ChartEditorState, xPos:Float, yPos:Float) + { + displayMenu(state, new ChartEditorSelectionContextMenu(state, xPos, yPos)); + } + + /** + * Opened when shift+right-clicking a single note. + */ + public static function openNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData) + { + displayMenu(state, new ChartEditorNoteContextMenu(state, xPos, yPos, data)); + } + + /** + * Opened when shift+right-clicking a single hold note. + */ + public static function openHoldNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData) + { + displayMenu(state, new ChartEditorHoldNoteContextMenu(state, xPos, yPos, data)); + } + + /** + * Opened when shift+right-clicking a single event. + */ + public static function openEventContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongEventData) + { + displayMenu(state, new ChartEditorEventContextMenu(state, xPos, yPos, data)); + } + + static function displayMenu(state:ChartEditorState, targetMenu:Menu) + { + // Close any existing menus + closeAllMenus(state); + + // Show the new menu + Screen.instance.addComponent(targetMenu); + existingMenus.push(targetMenu); + } + + public static function closeMenu(state:ChartEditorState, targetMenu:Menu) + { + // targetMenu.close(); + existingMenus.remove(targetMenu); + } + + public static function closeAllMenus(state:ChartEditorState) + { + for (existingMenu in existingMenus) + { + closeMenu(state, existingMenu); + } + } +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index 666b3656c..970f021ac 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -13,12 +13,13 @@ import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.Song; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; import funkin.ui.debug.charting.dialogs.ChartEditorAboutDialog; import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; import funkin.ui.debug.charting.dialogs.ChartEditorCharacterIconSelectorMenu; import funkin.ui.debug.charting.dialogs.ChartEditorUploadChartDialog; import funkin.ui.debug.charting.dialogs.ChartEditorWelcomeDialog; +import funkin.ui.debug.charting.dialogs.ChartEditorUploadVocalsDialog; import funkin.ui.debug.charting.util.ChartEditorDropdowns; import funkin.util.Constants; import funkin.util.DateUtil; @@ -59,11 +60,8 @@ using Lambda; class ChartEditorDialogHandler { // Paths to HaxeUI layout files for each dialog. - static final CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-chart'); static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst'); static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata'); - static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals'); - static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry'); static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts'); static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts-entry'); static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart'); @@ -105,6 +103,56 @@ class ChartEditorDialogHandler return dialog; } + /** + * Builds and opens a dialog letting the user browse for a chart file to open. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog> + { + var dialog = ChartEditorUploadChartDialog.build(state, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens a dialog where the user uploads vocals for the current song. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var charData:SongCharacterData = state.currentSongMetadata.playData.characters; + + var hasClearedVocals:Bool = false; + + var charIdsForVocals:Array<String> = [charData.player, charData.opponent]; + + var dialog = ChartEditorUploadVocalsDialog.build(state, charIdsForVocals, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens the dialog for selecting a character. + */ + public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null<Menu> + { + var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition); + + menu.zIndex = 1000; + + return menu; + } + /** * Builds and opens a dialog letting the user know a backup is available, and prompting them to load it. */ @@ -186,22 +234,6 @@ class ChartEditorDialogHandler return dialog; } - /** - * Builds and opens a dialog letting the user browse for a chart file to open. - * @param state The current chart editor state. - * @param closable Whether the dialog can be closed by the user. - * @return The dialog that was opened. - */ - public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog> - { - var dialog = ChartEditorUploadChartDialog.build(state, closable); - - dialog.zIndex = 1000; - state.isHaxeUIDialogOpen = true; - - return dialog; - } - /** * Open the wizard for opening an existing chart from individual files. * @param state @@ -288,15 +320,6 @@ class ChartEditorDialogHandler }; } - public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null<Menu> - { - var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition); - - menu.zIndex = 1000; - - return menu; - } - public static function openCreateSongWizardBasicOnly(state:ChartEditorState, closable:Bool):Void { // Step 1. Song Metadata @@ -684,8 +707,12 @@ class ChartEditorDialogHandler state.songMetadata.set(targetVariation, newSongMetadata); - Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. - Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + state.updateTimeSignature(); + + state.selectedVariation = Constants.DEFAULT_VARIATION; + state.selectedDifficulty = state.availableDifficulties[0]; state.difficultySelectDirty = true; @@ -695,158 +722,6 @@ class ChartEditorDialogHandler return dialog; } - /** - * Builds and opens a dialog where the user uploads vocals for the current song. - * @param state The current chart editor state. - * @param closable Whether the dialog can be closed by the user. - * @return The dialog that was opened. - */ - public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog - { - var instId:String = state.currentInstrumentalId; - var charIdsForVocals:Array<String> = []; - - var charData:SongCharacterData = state.currentSongMetadata.playData.characters; - - var hasClearedVocals:Bool = false; - - charIdsForVocals.push(charData.player); - charIdsForVocals.push(charData.opponent); - - var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable); - if (dialog == null) throw 'Could not locate Upload Vocals dialog'; - - var dialogContainer:Null<Component> = dialog.findComponent('vocalContainer'); - if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog'; - - var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button); - if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Vocals dialog'; - buttonCancel.onClick = function(_) { - dialog.hideDialog(DialogButton.CANCEL); - } - - var dialogNoVocals:Null<Button> = dialog.findComponent('dialogNoVocals', Button); - if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog'; - dialogNoVocals.onClick = function(_) { - // Dismiss - state.wipeVocalData(); - dialog.hideDialog(DialogButton.APPLY); - }; - - for (charKey in charIdsForVocals) - { - trace('Adding vocal upload for character ${charKey}'); - var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey); - var charName:String = charMetadata != null ? charMetadata.name : charKey; - - var vocalsEntry:Component = RuntimeComponentBuilder.fromAsset(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT); - - var vocalsEntryLabel:Null<Label> = vocalsEntry.findComponent('vocalsEntryLabel', Label); - if (vocalsEntryLabel == null) throw 'Could not locate vocalsEntryLabel in Upload Vocals dialog'; - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; - #else - vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; - #end - - var dropHandler:DialogDropTarget = {component: vocalsEntry, handler: null}; - - var onDropFile:String->Void = function(pathStr:String) { - trace('Selected file: $pathStr'); - var path:Path = new Path(pathStr); - - if (!hasClearedVocals) - { - hasClearedVocals = true; - state.stopExistingVocals(); - } - - if (state.loadVocalsFromPath(path, charKey, instId)) - { - // Tell the user the load was successful. - state.success('Loaded Vocals', 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${state.selectedVariation}'); - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}'; - #else - vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}'; - #end - - dialogNoVocals.hidden = true; - state.removeDropHandler(dropHandler); - } - else - { - trace('Failed to load vocal track (${path.file}.${path.ext})'); - - state.error('Failed to Load Vocals', 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${state.selectedVariation})'); - - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; - #else - vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; - #end - } - }; - - dropHandler.handler = onDropFile; - - vocalsEntry.onClick = function(_event) { - Dialogs.openBinaryFile('Open $charName Vocals', [ - {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) { - if (selectedFile != null && selectedFile.bytes != null) - { - trace('Selected file: ' + selectedFile.name); - if (!hasClearedVocals) - { - hasClearedVocals = true; - state.stopExistingVocals(); - } - if (state.loadVocalsFromBytes(selectedFile.bytes, charKey, instId)) - { - // Tell the user the load was successful. - state.success('Loaded Vocals', 'Loaded vocals for $charName (${selectedFile.name}), variation ${state.selectedVariation}'); - - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; - #else - vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}'; - #end - - dialogNoVocals.hidden = true; - } - else - { - trace('Failed to load vocal track (${selectedFile.fullPath})'); - - state.error('Failed to Load Vocals', 'Failed to load vocal track (${selectedFile.name}) for variation (${state.selectedVariation})'); - - #if FILE_DROP_SUPPORTED - vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; - #else - vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; - #end - } - } - }); - } - - // onDropFile - #if FILE_DROP_SUPPORTED - addDropHandler(dropHandler); - #end - dialogContainer.addComponent(vocalsEntry); - } - - var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button); - if (dialogContinue == null) throw 'Could not locate dialogContinue button in Upload Vocals dialog'; - dialogContinue.onClick = function(_) { - // Dismiss - dialog.hideDialog(DialogButton.APPLY); - }; - - return dialog; - } - /** * Builds and opens a dialog where the user upload the JSON files for a song. * @param state The current chart editor state. diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx index 267d2208a..0318bf296 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx @@ -28,6 +28,8 @@ class ChartEditorImportExportHandler */ public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void { + trace('===============START'); + var song:Null<Song> = SongRegistry.instance.fetchEntry(songId); if (song == null) return; @@ -43,7 +45,8 @@ class ChartEditorImportExportHandler var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation; // Clone to prevent modifying the original. - var metadataClone:SongMetadata = metadata.clone(variation); + var metadataClone:SongMetadata = metadata.clone(); + metadataClone.variation = variation; if (metadataClone != null) songMetadata.set(variation, metadataClone); var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation); @@ -97,11 +100,14 @@ class ChartEditorImportExportHandler state.isHaxeUIDialogOpen = false; state.currentWorkingFilePath = null; // New file, so no path. state.switchToCurrentInstrumental(); + state.postLoadInstrumental(); state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})'); + + trace('===============END'); } /** @@ -114,9 +120,10 @@ class ChartEditorImportExportHandler state.songMetadata = newSongMetadata; state.songChartData = newSongChartData; - Conductor.forceBPM(null); // Disable the forced BPM. - Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. - Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); + Conductor.instance.forceBPM(null); // Disable the forced BPM. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + state.updateTimeSignature(); state.notePreviewDirty = true; state.notePreviewViewportBoundsDirty = true; @@ -130,11 +137,8 @@ class ChartEditorImportExportHandler state.audioInstTrack.stop(); state.audioInstTrack = null; } - if (state.audioVocalTrackGroup != null) - { - state.audioVocalTrackGroup.stop(); - state.audioVocalTrackGroup.clear(); - } + state.audioVocalTrackGroup.stop(); + state.audioVocalTrackGroup.clear(); } /** @@ -415,16 +419,34 @@ class ChartEditorImportExportHandler ]); // We have to force write because the program will die before the save dialog is closed. trace('Force exporting to $targetPath...'); - FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode); - if (onSaveCb != null) onSaveCb(targetPath); + try + { + FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode); + // On success. + if (onSaveCb != null) onSaveCb(targetPath); + } + catch (e) + { + // On failure. + if (onCancelCb != null) onCancelCb(); + } } else { // Force write since we know what file the user wants to overwrite. trace('Force exporting to $targetPath...'); - FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode); - state.saveDataDirty = false; - if (onSaveCb != null) onSaveCb(targetPath); + try + { + // On success. + FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode); + state.saveDataDirty = false; + if (onSaveCb != null) onSaveCb(targetPath); + } + catch (e) + { + // On failure. + if (onCancelCb != null) onCancelCb(); + } } } else diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx index f7105d2f7..62f1f4cbc 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx @@ -2,6 +2,10 @@ package funkin.ui.debug.charting.handlers; import funkin.util.PlatformUtil; +/** + * Handles modifying the shortcut text of menu items based on the current platform. + * On MacOS, `Ctrl`, `Alt`, and `Shift` are replaced with `⌘` (or `^`), `⌥`, and `⇧`, respectively. + */ @:access(funkin.ui.debug.charting.ChartEditorState) class ChartEditorShortcutHandler { @@ -18,7 +22,8 @@ class ChartEditorShortcutHandler state.menubarItemCopy.shortcutText = ctrlOrCmd('C'); state.menubarItemPaste.shortcutText = ctrlOrCmd('V'); - state.menubarItemSelectAll.shortcutText = ctrlOrCmd('A'); + state.menubarItemSelectAllNotes.shortcutText = ctrlOrCmd('A'); + state.menubarItemSelectAllEvents.shortcutText = ctrlOrCmd(alt('A')); state.menubarItemSelectInverse.shortcutText = ctrlOrCmd('I'); state.menubarItemSelectNone.shortcutText = ctrlOrCmd('D'); state.menubarItemSelectBeforeCursor.shortcutText = shift('Home'); diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx index 4197ebdd3..b1af0ce4c 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx @@ -52,7 +52,7 @@ class ChartEditorThemeHandler // Border on the square highlighting selected notes. static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933; static final SELECTION_SQUARE_BORDER_COLOR_DARK:FlxColor = 0xFF339933; - static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1; + public static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1; // Fill on the square highlighting selected notes. // Make sure this is transparent so you can see the notes underneath. @@ -81,6 +81,8 @@ class ChartEditorThemeHandler { updateBackground(state); updateGridBitmap(state); + updateMeasureTicks(state); + updateOffsetTicks(state); updateSelectionSquare(state); updateNotePreview(state); } @@ -125,7 +127,7 @@ class ChartEditorThemeHandler // 2 * (Strumline Size) + 1 grid squares wide, by (4 * quarter notes per measure) grid squares tall. // This gets reused to fill the screen. var gridWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); - var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.stepsPerMeasure); + var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); state.gridBitmap = FlxGridOverlay.createGrid(ChartEditorState.GRID_SIZE, ChartEditorState.GRID_SIZE, gridWidth, gridHeight, true, gridColor1, gridColor2); // Selection borders @@ -142,7 +144,7 @@ class ChartEditorThemeHandler selectionBorderColor); // Selection borders horizontally along the middle. - for (i in 1...(Conductor.stepsPerMeasure)) + for (i in 1...(Conductor.instance.stepsPerMeasure)) { state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), state.gridBitmap.width, ChartEditorState.GRID_SELECTION_BORDER_WIDTH), @@ -197,9 +199,9 @@ class ChartEditorThemeHandler }; // Selection borders horizontally in the middle. - for (i in 1...(Conductor.stepsPerMeasure)) + for (i in 1...(Conductor.instance.stepsPerMeasure)) { - if ((i % Conductor.beatsPerMeasure) == 0) + if ((i % Conductor.instance.beatsPerMeasure) == 0) { state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (GRID_BEAT_DIVIDER_WIDTH / 2), state.gridBitmap.width, GRID_BEAT_DIVIDER_WIDTH), @@ -207,9 +209,6 @@ class ChartEditorThemeHandler } } - // Divider at top - state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor); - // Draw vertical dividers between the strumlines. var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme) @@ -233,6 +232,117 @@ class ChartEditorThemeHandler // Else, gridTiledSprite will be built later. } + /** + * Vertical measure ticks. + */ + static function updateMeasureTicks(state:ChartEditorState):Void + { + var measureTickWidth:Int = 6; + var beatTickWidth:Int = 4; + var stepTickWidth:Int = 2; + + // Draw the measure ticks. + var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares wide. + var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 1 measure tall. + state.measureTickBitmap = new BitmapData(ticksWidth, ticksHeight, true); + state.measureTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK); + + // Draw the measure ticks. + state.measureTickBitmap.fillRect(new Rectangle(0, 0, state.measureTickBitmap.width, measureTickWidth / 2), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + var bottomTickY:Float = state.measureTickBitmap.height - (measureTickWidth / 2); + state.measureTickBitmap.fillRect(new Rectangle(0, bottomTickY, state.measureTickBitmap.width, measureTickWidth / 2), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the beat ticks. + var beatTick2Y:Float = state.measureTickBitmap.height * 1 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2); + var beatTick3Y:Float = state.measureTickBitmap.height * 2 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2); + var beatTick4Y:Float = state.measureTickBitmap.height * 3 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2); + var beatTickLength:Float = state.measureTickBitmap.width * 2 / 3; + state.measureTickBitmap.fillRect(new Rectangle(0, beatTick2Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, beatTick3Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, beatTick4Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the step ticks. + // TODO: Make this a loop or something. + var stepTick2Y:Float = state.measureTickBitmap.height * 1 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick3Y:Float = state.measureTickBitmap.height * 2 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick4Y:Float = state.measureTickBitmap.height * 3 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick6Y:Float = state.measureTickBitmap.height * 5 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick7Y:Float = state.measureTickBitmap.height * 6 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick8Y:Float = state.measureTickBitmap.height * 7 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick10Y:Float = state.measureTickBitmap.height * 9 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick11Y:Float = state.measureTickBitmap.height * 10 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick12Y:Float = state.measureTickBitmap.height * 11 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick14Y:Float = state.measureTickBitmap.height * 13 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick15Y:Float = state.measureTickBitmap.height * 14 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTick16Y:Float = state.measureTickBitmap.height * 15 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2); + var stepTickLength:Float = state.measureTickBitmap.width * 1 / 3; + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick2Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick3Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick4Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick6Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick7Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick8Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick10Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick11Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick12Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick14Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick15Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.measureTickBitmap.fillRect(new Rectangle(0, stepTick16Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + } + + /** + * Horizontal offset ticks. + */ + static function updateOffsetTicks(state:ChartEditorState):Void + { + var majorTickWidth:Int = 6; + var minorTickWidth:Int = 3; + + var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 10 minor ticks wide. + var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares tall. + state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true); + state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK); + + // Draw the major ticks. + var leftTickX:Float = 0; + var middleTickX:Float = state.offsetTickBitmap.width / 2 - (majorTickWidth / 2); + var rightTickX:Float = state.offsetTickBitmap.width - (majorTickWidth / 2); + var majorTickLength:Float = state.offsetTickBitmap.height; + state.offsetTickBitmap.fillRect(new Rectangle(leftTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(middleTickX, 0, majorTickWidth, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the minor ticks. + var minorTick2X:Float = state.offsetTickBitmap.width * 1 / 10 - (minorTickWidth / 2); + var minorTick3X:Float = state.offsetTickBitmap.width * 2 / 10 - (minorTickWidth / 2); + var minorTick4X:Float = state.offsetTickBitmap.width * 3 / 10 - (minorTickWidth / 2); + var minorTick5X:Float = state.offsetTickBitmap.width * 4 / 10 - (minorTickWidth / 2); + var minorTick7X:Float = state.offsetTickBitmap.width * 6 / 10 - (minorTickWidth / 2); + var minorTick8X:Float = state.offsetTickBitmap.width * 7 / 10 - (minorTickWidth / 2); + var minorTick9X:Float = state.offsetTickBitmap.width * 8 / 10 - (minorTickWidth / 2); + var minorTick10X:Float = state.offsetTickBitmap.width * 9 / 10 - (minorTickWidth / 2); + var minorTickLength:Float = state.offsetTickBitmap.height * 1 / 3; + state.offsetTickBitmap.fillRect(new Rectangle(minorTick2X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick3X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick4X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick5X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick7X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick8X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick9X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + state.offsetTickBitmap.fillRect(new Rectangle(minorTick10X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + + // Draw the offset ticks. + // var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); // 1 grid squares wide. + // var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 measure tall. + // state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true); + // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK); + // + //// Draw the offset ticks. + // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + // var rightTickX:Float = state.offsetTickBitmap.width - (offsetTickWidth / 2); + // state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT); + } + static function updateSelectionSquare(state:ChartEditorState):Void { var selectionSquareBorderColor:FlxColor = switch (state.currentTheme) @@ -264,6 +374,12 @@ class ChartEditorThemeHandler ChartEditorState.GRID_SIZE - (2 * SELECTION_SQUARE_BORDER_WIDTH + 8)), 32, 32); + + state.selectionBoxSprite.scrollFactor.set(0, 0); + state.selectionBoxSprite.zIndex = 30; + state.add(state.selectionBoxSprite); + + state.setSelectionBoxBounds(); } static function updateNotePreview(state:ChartEditorState):Void @@ -289,14 +405,21 @@ class ChartEditorThemeHandler ChartEditorState.GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2), ChartEditorState.GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2)), viewportFillColor); - state.notePreviewViewport = new FlxSliceSprite(state.notePreviewViewportBitmap, - new FlxRect(SELECTION_SQUARE_BORDER_WIDTH - + 1, SELECTION_SQUARE_BORDER_WIDTH - + 1, ChartEditorState.GRID_SIZE - - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2), - ChartEditorState.GRID_SIZE - - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2)), - 32, 32); + if (state.notePreviewViewport != null) + { + state.notePreviewViewport.loadGraphic(state.notePreviewViewportBitmap); + } + else + { + state.notePreviewViewport = new FlxSliceSprite(state.notePreviewViewportBitmap, + new FlxRect(SELECTION_SQUARE_BORDER_WIDTH + + 1, SELECTION_SQUARE_BORDER_WIDTH + + 1, + ChartEditorState.GRID_SIZE + - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2), ChartEditorState.GRID_SIZE + - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2)), + 32, 32); + } } public static function buildPlayheadBlock():FlxSprite diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx index a9a9c375d..9e22ba833 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx @@ -1,7 +1,6 @@ package funkin.ui.debug.charting.handlers; -import funkin.play.stage.StageData.StageDataParser; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; import haxe.ui.components.HorizontalSlider; @@ -9,20 +8,20 @@ import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.event.SongEvent; -import funkin.data.event.SongEventData; +import funkin.data.event.SongEventSchema; import funkin.data.song.SongData.SongTimeChange; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.event.SongEvent; import funkin.play.song.SongSerializer; -import funkin.play.stage.StageData; -import funkin.play.stage.StageData.StageDataParser; +import funkin.data.stage.StageData; import haxe.ui.RuntimeComponentBuilder; import funkin.ui.debug.charting.util.ChartEditorDropdowns; import funkin.ui.haxeui.components.CharacterPlayer; import funkin.util.FileUtil; import haxe.ui.components.Button; +import haxe.ui.data.ArrayDataSource; import haxe.ui.components.CheckBox; import haxe.ui.components.DropDown; import haxe.ui.components.HorizontalSlider; @@ -36,12 +35,15 @@ import haxe.ui.containers.dialogs.Dialog.DialogButton; import haxe.ui.containers.dialogs.Dialog.DialogEvent; import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox; import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox; import haxe.ui.containers.Frame; import haxe.ui.containers.Grid; import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; import haxe.ui.core.Component; -import haxe.ui.data.ArrayDataSource; import haxe.ui.events.UIEvent; /** @@ -79,15 +81,20 @@ class ChartEditorToolboxHandler { case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT: onShowToolboxNoteData(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: - onShowToolboxEventData(state, toolbox); + case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT: + // TODO: Fix this. + cast(toolbox, ChartEditorBaseToolbox).refresh(); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT: onShowToolboxPlaytestProperties(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT: - onShowToolboxDifficulty(state, toolbox); + cast(toolbox, ChartEditorBaseToolbox).refresh(); case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: // TODO: Fix this. cast(toolbox, ChartEditorBaseToolbox).refresh(); + case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT: + cast(toolbox, ChartEditorBaseToolbox).refresh(); + case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT: + cast(toolbox, ChartEditorBaseToolbox).refresh(); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onShowToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -119,14 +126,10 @@ class ChartEditorToolboxHandler { case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT: onHideToolboxNoteData(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: + case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT: onHideToolboxEventData(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT: onHideToolboxPlaytestProperties(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT: - onHideToolboxDifficulty(state, toolbox); - case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: - onHideToolboxMetadata(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: onHideToolboxPlayerPreview(state, toolbox); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -195,7 +198,7 @@ class ChartEditorToolboxHandler { case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT: toolbox = buildToolboxNoteDataLayout(state); - case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: + case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT: toolbox = buildToolboxEventDataLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT: toolbox = buildToolboxPlaytestPropertiesLayout(state); @@ -203,6 +206,10 @@ class ChartEditorToolboxHandler toolbox = buildToolboxDifficultyLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT: toolbox = buildToolboxMetadataLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT: + toolbox = buildToolboxOffsetsLayout(state); + case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT: + toolbox = buildToolboxFreeplayLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT: toolbox = buildToolboxPlayerPreviewLayout(state); case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: @@ -283,19 +290,19 @@ class ChartEditorToolboxHandler toolboxNotesCustomKindLabel.hidden = false; toolboxNotesCustomKind.hidden = false; - state.selectedNoteKind = toolboxNotesCustomKind.text; + state.noteKindToPlace = toolboxNotesCustomKind.text; } else { toolboxNotesCustomKindLabel.hidden = true; toolboxNotesCustomKind.hidden = true; - state.selectedNoteKind = event.data.id; + state.noteKindToPlace = event.data.id; } } toolboxNotesCustomKind.onChange = function(event:UIEvent) { - state.selectedNoteKind = toolboxNotesCustomKind.text; + state.noteKindToPlace = toolboxNotesCustomKind.text; } return toolbox; @@ -305,159 +312,12 @@ class ChartEditorToolboxHandler static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function buildToolboxEventDataLayout(state:ChartEditorState):Null<CollapsibleDialog> - { - var toolbox:CollapsibleDialog = cast RuntimeComponentBuilder.fromAsset(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT); - - if (toolbox == null) return null; - - // Starting position. - toolbox.x = 100; - toolbox.y = 150; - - toolbox.onDialogClosed = function(event:DialogEvent) { - state.menubarItemToggleToolboxEvents.selected = false; - } - - var toolboxEventsEventKind:Null<DropDown> = toolbox.findComponent('toolboxEventsEventKind', DropDown); - if (toolboxEventsEventKind == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsEventKind component.'; - var toolboxEventsDataGrid:Null<Grid> = toolbox.findComponent('toolboxEventsDataGrid', Grid); - if (toolboxEventsDataGrid == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsDataGrid component.'; - - toolboxEventsEventKind.dataSource = new ArrayDataSource(); - - var songEvents:Array<SongEvent> = SongEventParser.listEvents(); - - for (event in songEvents) - { - toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id}); - } - - toolboxEventsEventKind.onChange = function(event:UIEvent) { - var eventType:String = event.data.value; - - trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType'); - - state.selectedEventKind = eventType; - - var schema:SongEventSchema = SongEventParser.getEventSchema(eventType); - - if (schema == null) - { - trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType'); - return; - } - - buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema); - } - toolboxEventsEventKind.value = state.selectedEventKind; - - return toolbox; - } - - static function onShowToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} + static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} static function onShowToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function onHideToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function buildEventDataFormFromSchema(state:ChartEditorState, target:Box, schema:SongEventSchema):Void - { - trace(schema); - // Clear the frame. - target.removeAllComponents(); - - state.selectedEventData = {}; - - for (field in schema) - { - if (field == null) continue; - - // Add a label. - var label:Label = new Label(); - label.text = field.title; - label.verticalAlign = "center"; - target.addComponent(label); - - var input:Component; - switch (field.type) - { - case INTEGER: - var numberStepper:NumberStepper = new NumberStepper(); - numberStepper.id = field.name; - numberStepper.step = field.step ?? 1.0; - numberStepper.min = field.min ?? 0.0; - numberStepper.max = field.max ?? 10.0; - if (field.defaultValue != null) numberStepper.value = field.defaultValue; - input = numberStepper; - case FLOAT: - var numberStepper:NumberStepper = new NumberStepper(); - numberStepper.id = field.name; - numberStepper.step = field.step ?? 0.1; - if (field.min != null) numberStepper.min = field.min; - if (field.max != null) numberStepper.max = field.max; - if (field.defaultValue != null) numberStepper.value = field.defaultValue; - input = numberStepper; - case BOOL: - var checkBox:CheckBox = new CheckBox(); - checkBox.id = field.name; - if (field.defaultValue != null) checkBox.selected = field.defaultValue; - input = checkBox; - case ENUM: - var dropDown:DropDown = new DropDown(); - dropDown.id = field.name; - dropDown.width = 200.0; - dropDown.dataSource = new ArrayDataSource(); - - if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.'; - - // Add entries to the dropdown. - - for (optionName in field.keys.keys()) - { - var optionValue:Null<Dynamic> = field.keys.get(optionName); - trace('$optionName : $optionValue'); - dropDown.dataSource.add({value: optionValue, text: optionName}); - } - - dropDown.value = field.defaultValue; - - input = dropDown; - case STRING: - input = new TextField(); - input.id = field.name; - if (field.defaultValue != null) input.text = field.defaultValue; - default: - // Unknown type. Display a label so we know what it is. - input = new Label(); - input.id = field.name; - input.text = field.type; - } - - target.addComponent(input); - - input.onChange = function(event:UIEvent) { - var value = event.target.value; - if (field.type == ENUM) - { - value = event.target.value.value; - } - trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}'); - - if (value == null) - { - state.selectedEventData.remove(event.target.id); - } - else - { - state.selectedEventData.set(event.target.id, value); - } - } - } - } - static function buildToolboxPlaytestPropertiesLayout(state:ChartEditorState):Null<CollapsibleDialog> { // fill with playtest properties @@ -488,96 +348,28 @@ class ChartEditorToolboxHandler state.playtestStartTime = checkboxStartTime.selected; }; + var checkboxDebugger:Null<CheckBox> = toolbox.findComponent('playtestDebuggerCheckbox', CheckBox); + + if (checkboxDebugger == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestDebuggerCheckbox component.'; + + state.enabledDebuggerPopup = checkboxDebugger.selected; + + checkboxDebugger.onClick = _ -> { + state.enabledDebuggerPopup = checkboxDebugger.selected; + }; + return toolbox; } - static function buildToolboxDifficultyLayout(state:ChartEditorState):Null<CollapsibleDialog> + static function buildToolboxDifficultyLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox> { - var toolbox:CollapsibleDialog = cast RuntimeComponentBuilder.fromAsset(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + var toolbox:ChartEditorBaseToolbox = ChartEditorDifficultyToolbox.build(state); if (toolbox == null) return null; - // Starting position. - toolbox.x = 125; - toolbox.y = 200; - - toolbox.onDialogClosed = function(event:UIEvent) { - state.menubarItemToggleToolboxDifficulty.selected = false; - } - - var difficultyToolboxAddVariation:Null<Button> = toolbox.findComponent('difficultyToolboxAddVariation', Button); - if (difficultyToolboxAddVariation == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddVariation component.'; - var difficultyToolboxAddDifficulty:Null<Button> = toolbox.findComponent('difficultyToolboxAddDifficulty', Button); - if (difficultyToolboxAddDifficulty == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddDifficulty component.'; - var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button); - if (difficultyToolboxSaveMetadata == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.'; - var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button); - if (difficultyToolboxSaveChart == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.'; - // var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button); - // if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.'; - var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button); - if (difficultyToolboxLoadMetadata == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.'; - var difficultyToolboxLoadChart:Null<Button> = toolbox.findComponent('difficultyToolboxLoadChart', Button); - if (difficultyToolboxLoadChart == null) - throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.'; - - difficultyToolboxAddVariation.onClick = function(_:UIEvent) { - state.openAddVariationDialog(true); - }; - - difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) { - state.openAddDifficultyDialog(true); - }; - - difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) { - var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : ''; - FileUtil.writeFileReference('${state.currentSongId}$vari-metadata.json', state.currentSongMetadata.serialize()); - }; - - difficultyToolboxSaveChart.onClick = function(_:UIEvent) { - var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : ''; - FileUtil.writeFileReference('${state.currentSongId}$vari-chart.json', state.currentSongChartData.serialize()); - }; - - difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) { - // Replace metadata for current variation. - SongSerializer.importSongMetadataAsync(function(songMetadata) { - state.currentSongMetadata = songMetadata; - }); - }; - - difficultyToolboxLoadChart.onClick = function(_:UIEvent) { - // Replace chart data for current variation. - SongSerializer.importSongChartDataAsync(function(songChartData) { - state.currentSongChartData = songChartData; - state.noteDisplayDirty = true; - }); - }; - - state.difficultySelectDirty = true; - return toolbox; } - static function onShowToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void - { - // Update the selected difficulty when reopening the toolbox. - var treeView:Null<TreeView> = toolbox.findComponent('difficultyToolboxTree'); - if (treeView == null) return; - - var current = state.getCurrentTreeDifficultyNode(treeView); - if (current == null) return; - treeView.selectedNode = current; - trace('selected node: ${treeView.selectedNode}'); - } - - static function onHideToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} - static function buildToolboxMetadataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox> { var toolbox:ChartEditorBaseToolbox = ChartEditorMetadataToolbox.build(state); @@ -587,7 +379,32 @@ class ChartEditorToolboxHandler return toolbox; } - static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {} + static function buildToolboxOffsetsLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox> + { + var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state); + + if (toolbox == null) return null; + + return toolbox; + } + + static function buildToolboxFreeplayLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox> + { + var toolbox:ChartEditorBaseToolbox = ChartEditorFreeplayToolbox.build(state); + + if (toolbox == null) return null; + + return toolbox; + } + + static function buildToolboxEventDataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox> + { + var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state); + + if (toolbox == null) return null; + + return toolbox; + } static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog> { diff --git a/source/funkin/ui/debug/charting/import.hx b/source/funkin/ui/debug/charting/import.hx index 933eaa3a5..b0569e3bb 100644 --- a/source/funkin/ui/debug/charting/import.hx +++ b/source/funkin/ui/debug/charting/import.hx @@ -3,6 +3,7 @@ package funkin.ui.debug.charting; #if !macro // Apply handlers so they can be called as though they were functions in ChartEditorState using funkin.ui.debug.charting.handlers.ChartEditorAudioHandler; +using funkin.ui.debug.charting.handlers.ChartEditorContextMenuHandler; using funkin.ui.debug.charting.handlers.ChartEditorDialogHandler; using funkin.ui.debug.charting.handlers.ChartEditorImportExportHandler; using funkin.ui.debug.charting.handlers.ChartEditorNotificationHandler; diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx new file mode 100644 index 000000000..1163c1b96 --- /dev/null +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx @@ -0,0 +1,239 @@ +package funkin.ui.debug.charting.toolboxes; + +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData; +import funkin.data.stage.StageData; +import funkin.data.stage.StageRegistry; +import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand; +import funkin.ui.debug.charting.util.ChartEditorDropdowns; +import haxe.ui.components.Button; +import haxe.ui.components.CheckBox; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import funkin.data.song.SongData.SongMetadata; +import haxe.ui.components.DropDown; +import haxe.ui.components.HorizontalSlider; +import funkin.util.FileUtil; +import haxe.ui.containers.dialogs.MessageBox.MessageBoxType; +import funkin.play.song.SongSerializer; +import haxe.ui.components.Label; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; +import haxe.ui.components.TextField; +import funkin.play.stage.Stage; +import haxe.ui.containers.Box; +import haxe.ui.containers.TreeView; +import haxe.ui.containers.TreeViewNode; +import haxe.ui.containers.Frame; +import haxe.ui.events.UIEvent; + +/** + * The toolbox which allows viewing the list of difficulties, switching to a specific one, + * and adding/removing variations and difficulties. + */ +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/difficulty.xml")) +class ChartEditorDifficultyToolbox extends ChartEditorBaseToolbox +{ + var difficultyToolboxTree:TreeView; + var difficultyToolboxAddVariation:Button; + var difficultyToolboxAddDifficulty:Button; + var difficultyToolboxRemoveDifficulty:Button; + var difficultyToolboxSaveMetadata:Button; + var difficultyToolboxSaveChart:Button; + var difficultyToolboxLoadMetadata:Button; + var difficultyToolboxLoadChart:Button; + + public function new(chartEditorState2:ChartEditorState) + { + super(chartEditorState2); + + initialize(); + + this.onDialogClosed = onClose; + } + + function onClose(event:UIEvent) + { + chartEditorState.menubarItemToggleToolboxDifficulty.selected = false; + } + + function initialize():Void + { + // Starting position. + // TODO: Save and load this. + this.x = 150; + this.y = 250; + + difficultyToolboxAddVariation.onClick = function(_:UIEvent) { + chartEditorState.openAddVariationDialog(true); + }; + + difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) { + chartEditorState.openAddDifficultyDialog(true); + }; + + difficultyToolboxRemoveDifficulty.onClick = function(_:UIEvent) { + var currentVariation:String = chartEditorState.selectedVariation; + var currentDifficulty:String = chartEditorState.selectedDifficulty; + + trace('Removing difficulty "$currentVariation:$currentDifficulty"'); + + var callback = (button) -> { + switch (button) + { + case DialogButton.YES: + // Remove the difficulty. + chartEditorState.removeDifficulty(currentVariation, currentDifficulty); + refresh(); + case DialogButton.NO: // Do nothing. + default: // Do nothing. + } + } + + Dialogs.messageBox("Are you sure? This cannot be undone.", "Remove Difficulty", MessageBoxType.TYPE_YESNO, callback); + }; + + difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) { + var vari:String = chartEditorState.selectedVariation != Constants.DEFAULT_VARIATION ? '-${chartEditorState.selectedVariation}' : ''; + FileUtil.writeFileReference('${chartEditorState.currentSongId}$vari-metadata.json', chartEditorState.currentSongMetadata.serialize()); + }; + + difficultyToolboxSaveChart.onClick = function(_:UIEvent) { + var vari:String = chartEditorState.selectedVariation != Constants.DEFAULT_VARIATION ? '-${chartEditorState.selectedVariation}' : ''; + FileUtil.writeFileReference('${chartEditorState.currentSongId}$vari-chart.json', chartEditorState.currentSongChartData.serialize()); + }; + + difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) { + // Replace metadata for current variation. + SongSerializer.importSongMetadataAsync(function(songMetadata) { + chartEditorState.currentSongMetadata = songMetadata; + }); + }; + + difficultyToolboxLoadChart.onClick = function(_:UIEvent) { + // Replace chart data for current variation. + SongSerializer.importSongChartDataAsync(function(songChartData) { + chartEditorState.currentSongChartData = songChartData; + chartEditorState.noteDisplayDirty = true; + }); + }; + + refresh(); + } + + /** + * Clear the tree view and rebuild it with the current song metadata (variation and difficulty list). + */ + public function updateTree():Void + { + // Clear the tree view so we can rebuild it. + difficultyToolboxTree.clearNodes(); + + // , icon: 'haxeui-core/styles/default/haxeui_tiny.png' + var treeSong:TreeViewNode = difficultyToolboxTree.addNode({id: 'stv_song', text: 'S: ${chartEditorState.currentSongName}'}); + treeSong.expanded = true; + + for (curVariation in chartEditorState.availableVariations) + { + var variationMetadata:Null<SongMetadata> = chartEditorState.songMetadata.get(curVariation); + if (variationMetadata == null) continue; + + var treeVariation:TreeViewNode = treeSong.addNode( + { + id: 'stv_variation_$curVariation', + text: 'V: ${curVariation.toTitleCase()}' + }); + treeVariation.expanded = true; + + var difficultyList:Array<String> = variationMetadata.playData.difficulties; + + for (difficulty in difficultyList) + { + var _treeDifficulty:TreeViewNode = treeVariation.addNode( + { + id: 'stv_difficulty_${curVariation}_$difficulty', + text: 'D: ${difficulty.toTitleCase()}' + }); + } + } + + difficultyToolboxTree.onChange = onTreeChange; + refreshTreeSelection(); + } + + /** + * Set the selected item in the tree to the current variation/difficulty. + * + * @param targetNode The node to select. If null, the current variation/difficulty will be used. + */ + public function refreshTreeSelection():Void + { + var targetNode = getCurrentTreeNode(); + if (targetNode != null) difficultyToolboxTree.selectedNode = targetNode; + } + + /** + * Get the node in the tree representing the current variation/difficulty. + */ + function getCurrentTreeNode():TreeViewNode + { + return + difficultyToolboxTree.findNodeByPath('stv_song/stv_variation_$chartEditorState.selectedVariation/stv_difficulty_${chartEditorState.selectedVariation}_$chartEditorState.selectedDifficulty', + 'id'); + } + + /** + * Called when an item in the tree is selected. Updates the current variation/difficulty. + */ + function onTreeChange(event:UIEvent):Void + { + // Get the newly selected node. + var treeView:TreeView = cast event.target; + var targetNode:TreeViewNode = difficultyToolboxTree.selectedNode; + + if (targetNode == null) + { + trace('No target node!'); + // Reset the user's selection. + refreshTreeSelection(); + return; + } + + switch (targetNode.data.id.split('_')[1]) + { + case 'difficulty': + var variation:String = targetNode.data.id.split('_')[2]; + var difficulty:String = targetNode.data.id.split('_')[3]; + + if (variation != null && difficulty != null) + { + trace('Changing difficulty to "$variation:$difficulty"'); + chartEditorState.selectedVariation = variation; + chartEditorState.selectedDifficulty = difficulty; + chartEditorState.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + refreshTreeSelection(); + } + // case 'song': + // case 'variation': + default: + // Reset the user's selection. + trace('Selected wrong node type, resetting selection.'); + refreshTreeSelection(); + chartEditorState.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + } + } + + public override function refresh():Void + { + super.refresh(); + + refreshTreeSelection(); + } + + public static function build(chartEditorState:ChartEditorState):ChartEditorDifficultyToolbox + { + return new ChartEditorDifficultyToolbox(chartEditorState); + } +} diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx new file mode 100644 index 000000000..7b163ad3d --- /dev/null +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -0,0 +1,273 @@ +package funkin.ui.debug.charting.toolboxes; + +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData; +import funkin.data.stage.StageData; +import funkin.play.event.SongEvent; +import funkin.data.event.SongEventSchema; +import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand; +import funkin.ui.debug.charting.util.ChartEditorDropdowns; +import haxe.ui.components.Button; +import haxe.ui.components.CheckBox; +import haxe.ui.components.DropDown; +import haxe.ui.components.HorizontalSlider; +import haxe.ui.components.Label; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; +import haxe.ui.core.Component; +import funkin.data.event.SongEventRegistry; +import haxe.ui.components.TextField; +import haxe.ui.containers.Box; +import haxe.ui.containers.HBox; +import haxe.ui.containers.Frame; +import haxe.ui.events.UIEvent; +import haxe.ui.data.ArrayDataSource; +import haxe.ui.containers.Grid; +import haxe.ui.components.DropDown; +import haxe.ui.containers.Frame; + +/** + * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM. + */ +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/event-data.xml")) +class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox +{ + var toolboxEventsEventKind:DropDown; + var toolboxEventsDataFrame:Frame; + var toolboxEventsDataGrid:Grid; + + var _initializing:Bool = true; + + public function new(chartEditorState2:ChartEditorState) + { + super(chartEditorState2); + + initialize(); + + this.onDialogClosed = onClose; + + this._initializing = false; + } + + function onClose(event:UIEvent) + { + chartEditorState.menubarItemToggleToolboxEventData.selected = false; + } + + function initialize():Void + { + toolboxEventsEventKind.dataSource = new ArrayDataSource(); + + var songEvents:Array<SongEvent> = SongEventRegistry.listEvents(); + + for (event in songEvents) + { + toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id}); + } + + toolboxEventsEventKind.onChange = function(event:UIEvent) { + var eventType:String = event.data.value; + + trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType'); + + // Edit the event data to place. + chartEditorState.eventKindToPlace = eventType; + + var schema:SongEventSchema = SongEventRegistry.getEventSchema(eventType); + + if (schema == null) + { + trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType'); + return; + } + + buildEventDataFormFromSchema(toolboxEventsDataGrid, schema); + + if (!_initializing && chartEditorState.currentEventSelection.length > 0) + { + // Edit the event data of any selected events. + for (event in chartEditorState.currentEventSelection) + { + event.event = chartEditorState.eventKindToPlace; + event.value = chartEditorState.eventDataToPlace; + } + chartEditorState.saveDataDirty = true; + chartEditorState.noteDisplayDirty = true; + chartEditorState.notePreviewDirty = true; + } + } + toolboxEventsEventKind.value = chartEditorState.eventKindToPlace; + } + + public override function refresh():Void + { + super.refresh(); + + toolboxEventsEventKind.value = chartEditorState.eventKindToPlace; + + for (pair in chartEditorState.eventDataToPlace.keyValueIterator()) + { + var fieldId:String = pair.key; + var value:Null<Dynamic> = pair.value; + + var field:Component = toolboxEventsDataGrid.findComponent(fieldId); + + if (field == null) + { + throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" does not exist in the event data form.'; + } + else + { + switch (field) + { + case Std.isOfType(_, NumberStepper) => true: + var numberStepper:NumberStepper = cast field; + numberStepper.value = value; + case Std.isOfType(_, CheckBox) => true: + var checkBox:CheckBox = cast field; + checkBox.selected = value; + case Std.isOfType(_, DropDown) => true: + var dropDown:DropDown = cast field; + dropDown.value = value; + case Std.isOfType(_, TextField) => true: + var textField:TextField = cast field; + textField.text = value; + default: + throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" is of unknown type "${Type.getClassName(Type.getClass(field))}".'; + } + } + } + } + + function buildEventDataFormFromSchema(target:Box, schema:SongEventSchema):Void + { + trace(schema); + // Clear the frame. + target.removeAllComponents(); + + chartEditorState.eventDataToPlace = {}; + + for (field in schema) + { + if (field == null) continue; + + // Add a label for the data field. + var label:Label = new Label(); + label.text = field.title; + label.verticalAlign = "center"; + target.addComponent(label); + + // Add an input field for the data field. + var input:Component; + switch (field.type) + { + case INTEGER: + var numberStepper:NumberStepper = new NumberStepper(); + numberStepper.id = field.name; + numberStepper.step = field.step ?? 1.0; + numberStepper.min = field.min ?? 0.0; + numberStepper.max = field.max ?? 10.0; + if (field.defaultValue != null) numberStepper.value = field.defaultValue; + input = numberStepper; + case FLOAT: + var numberStepper:NumberStepper = new NumberStepper(); + numberStepper.id = field.name; + numberStepper.step = field.step ?? 0.1; + if (field.min != null) numberStepper.min = field.min; + if (field.max != null) numberStepper.max = field.max; + if (field.defaultValue != null) numberStepper.value = field.defaultValue; + input = numberStepper; + case BOOL: + var checkBox:CheckBox = new CheckBox(); + checkBox.id = field.name; + if (field.defaultValue != null) checkBox.selected = field.defaultValue; + input = checkBox; + case ENUM: + var dropDown:DropDown = new DropDown(); + dropDown.id = field.name; + dropDown.width = 200.0; + dropDown.dataSource = new ArrayDataSource(); + + if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.'; + + // Add entries to the dropdown. + + for (optionName in field.keys.keys()) + { + var optionValue:Null<Dynamic> = field.keys.get(optionName); + trace('$optionName : $optionValue'); + dropDown.dataSource.add({value: optionValue, text: optionName}); + } + + dropDown.value = field.defaultValue; + + input = dropDown; + case STRING: + input = new TextField(); + input.id = field.name; + if (field.defaultValue != null) input.text = field.defaultValue; + default: + // Unknown type. Display a label that proclaims the type so we can debug it. + input = new Label(); + input.id = field.name; + input.text = field.type; + } + + // Putting in a box so we can add a unit label easily if there is one. + var inputBox:HBox = new HBox(); + inputBox.addComponent(input); + + // Add a unit label if applicable. + if (field.units != null && field.units != "") + { + var units:Label = new Label(); + units.text = field.units; + units.verticalAlign = "center"; + inputBox.addComponent(units); + } + + target.addComponent(inputBox); + + // Update the value of the event data. + input.onChange = function(event:UIEvent) { + var value = event.target.value; + if (field.type == ENUM) + { + value = event.target.value.value; + } + + trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}'); + + // Edit the event data to place. + if (value == null) + { + chartEditorState.eventDataToPlace.remove(event.target.id); + } + else + { + chartEditorState.eventDataToPlace.set(event.target.id, value); + } + + // Edit the event data of any existing events. + if (!_initializing && chartEditorState.currentEventSelection.length > 0) + { + for (event in chartEditorState.currentEventSelection) + { + event.event = chartEditorState.eventKindToPlace; + event.value = chartEditorState.eventDataToPlace; + } + chartEditorState.saveDataDirty = true; + chartEditorState.noteDisplayDirty = true; + chartEditorState.notePreviewDirty = true; + } + } + } + } + + public static function build(chartEditorState:ChartEditorState):ChartEditorEventDataToolbox + { + return new ChartEditorEventDataToolbox(chartEditorState); + } +} diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx new file mode 100644 index 000000000..8d3554a08 --- /dev/null +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx @@ -0,0 +1,693 @@ +package funkin.ui.debug.charting.toolboxes; + +import funkin.audio.SoundGroup; +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; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; + +/** + * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM. + */ +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/freeplay.xml")) +class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox +{ + var waveformContainer:Absolute; + var waveformScrollview:ScrollView; + var waveformMusic:WaveformPlayer; + var freeplayButtonZoomIn:Button; + var freeplayButtonZoomOut:Button; + var freeplayButtonPause:Button; + var freeplayButtonPlay:Button; + var freeplayButtonStop:Button; + var freeplayPreviewStart:NumberStepper; + var freeplayPreviewEnd:NumberStepper; + var freeplayTicksContainer:Absolute; + var playheadSprite:SpriteWrapper; + var previewSelectionSprite:SpriteWrapper; + + static final TICK_LABEL_X_OFFSET:Float = 4.0; + + static final PLAYHEAD_RIGHT_PAD:Float = 10.0; + + static final BASE_SCALE:Float = 64.0; + static final STARTING_SCALE:Float = 1024.0; + static final MIN_SCALE:Float = 4.0; + static final WAVEFORM_ZOOM_MULT:Float = 1.5; + + static final MAGIC_SCALE_BASE_TIME:Float = 5.0; + + var waveformScale:Float = STARTING_SCALE; + + var playheadAbsolutePos(get, set):Float; + + function get_playheadAbsolutePos():Float + { + return playheadSprite.left; + } + + function set_playheadAbsolutePos(value:Float):Float + { + return playheadSprite.left = value; + } + + var playheadRelativePos(get, set):Float; + + function get_playheadRelativePos():Float + { + return playheadSprite.left - waveformScrollview.hscrollPos; + } + + function set_playheadRelativePos(value:Float):Float + { + return playheadSprite.left = waveformScrollview.hscrollPos + value; + } + + var previewBoxStartPosAbsolute(get, set):Float; + + function get_previewBoxStartPosAbsolute():Float + { + return previewSelectionSprite.left; + } + + function set_previewBoxStartPosAbsolute(value:Float):Float + { + return previewSelectionSprite.left = value; + } + + var previewBoxEndPosAbsolute(get, set):Float; + + function get_previewBoxEndPosAbsolute():Float + { + return previewSelectionSprite.left + previewSelectionSprite.width; + } + + function set_previewBoxEndPosAbsolute(value:Float):Float + { + if (value < previewBoxStartPosAbsolute) return previewSelectionSprite.left = previewBoxStartPosAbsolute; + return previewSelectionSprite.width = value - previewBoxStartPosAbsolute; + } + + var previewBoxStartPosRelative(get, set):Float; + + function get_previewBoxStartPosRelative():Float + { + return previewSelectionSprite.left - waveformScrollview.hscrollPos; + } + + function set_previewBoxStartPosRelative(value:Float):Float + { + return previewSelectionSprite.left = waveformScrollview.hscrollPos + value; + } + + var previewBoxEndPosRelative(get, set):Float; + + function get_previewBoxEndPosRelative():Float + { + return previewSelectionSprite.left + previewSelectionSprite.width - waveformScrollview.hscrollPos; + } + + function set_previewBoxEndPosRelative(value:Float):Float + { + if (value < previewBoxStartPosRelative) return previewSelectionSprite.left = previewBoxStartPosRelative; + return previewSelectionSprite.width = value - previewBoxStartPosRelative; + } + + /** + * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds. + */ + var waveformMagicFactor:Float = 1.0; + + var audioPreviewTracks:SoundGroup; + + var tickTiledSprite:FlxTiledSprite; + + var freeplayPreviewVolume(get, null):Float; + + function get_freeplayPreviewVolume():Float + { + return freeplayMusicVolume.value * 2 / 100; + } + + var tickLabels:Array<Label> = []; + + public function new(chartEditorState2:ChartEditorState) + { + super(chartEditorState2); + + initialize(); + + this.onDialogClosed = onClose; + } + + function onClose(event:UIEvent) + { + chartEditorState.menubarItemToggleToolboxFreeplay.selected = false; + } + + function initialize():Void + { + // Starting position. + // TODO: Save and load this. + this.x = 150; + this.y = 250; + + freeplayMusicVolume.onChange = (_) -> { + setTrackVolume(freeplayPreviewVolume); + }; + freeplayMusicMute.onClick = (_) -> { + toggleMuteTrack(); + }; + freeplayButtonZoomIn.onClick = (_) -> { + zoomWaveformIn(); + }; + freeplayButtonZoomOut.onClick = (_) -> { + zoomWaveformOut(); + }; + freeplayButtonPause.onClick = (_) -> { + pauseAudioPreview(); + }; + freeplayButtonPlay.onClick = (_) -> { + playAudioPreview(); + }; + freeplayButtonStop.onClick = (_) -> { + stopAudioPreview(); + }; + testPreview.onClick = (_) -> { + performPreview(); + }; + freeplayPreviewStart.onChange = (event:UIEvent) -> { + if (event.value == chartEditorState.currentSongFreeplayPreviewStart) return; + if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview. + + chartEditorState.performCommand(new SetFreeplayPreviewCommand(event.value, null)); + refresh(); + } + freeplayPreviewEnd.onChange = (event:UIEvent) -> { + if (event.value == chartEditorState.currentSongFreeplayPreviewEnd) return; + if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview. + + chartEditorState.performCommand(new SetFreeplayPreviewCommand(null, event.value)); + refresh(); + } + waveformScrollview.onScroll = (_) -> { + if (!audioPreviewTracks.playing) + { + // Move the playhead if it would go out of view. + var prevPlayheadRelativePos = playheadRelativePos; + playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD); + trace('newPos: ${playheadRelativePos}'); + var diff = playheadRelativePos - prevPlayheadRelativePos; + + if (diff != 0) + { + // We have to change the song time to match the playhead position when we move it. + var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex); + audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC; + } + + addOffsetsToAudioPreview(); + } + else + { + // The scrollview probably changed because the song position changed. + // If we try to move the song now it will glitch. + } + + // Either way, clipRect has changed, so we need to refresh the waveforms. + refresh(); + }; + + initializeTicks(); + + refreshAudioPreview(); + refresh(); + refreshTicks(); + + waveformMusic.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragWaveform(); + }); + + freeplayTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragPlayhead(); + }); + } + + function initializeTicks():Void + { + tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false); + freeplayTicksSprite.sprite = tickTiledSprite; + tickTiledSprite.width = 5000; + } + + /** + * Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox. + * These must be DEEP CLONES or else the editor will affect the audio preview! + */ + public function refreshAudioPreview():Void + { + if (audioPreviewTracks == null) + { + audioPreviewTracks = new SoundGroup(); + // Make sure audioPreviewTracks (and all its children) receives update() calls. + chartEditorState.add(audioPreviewTracks); + } + else + { + audioPreviewTracks.stop(); + audioPreviewTracks.clear(); + } + + var instTrack = chartEditorState.audioInstTrack.clone(); + audioPreviewTracks.add(instTrack); + + var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice(); + if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone()); + + var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice(); + if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone()); + + // Build player waveform. + // waveformMusic.waveform.forceUpdate = true; + var perfStart = haxe.Timer.stamp(); + var waveformData1 = playerVoice.waveformData; + var waveformData2 = opponentVoice.waveformData; + var waveformData3 = chartEditorState.audioInstTrack.waveformData; + var waveformData = waveformData1.merge(waveformData2).merge(waveformData3); + trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds'); + + 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. + waveformMusic.waveform.duration = instTrack.length / Constants.MS_PER_SEC; + + addOffsetsToAudioPreview(); + } + + public function refreshTicks():Void + { + while (tickLabels.length > 0) + { + var label = tickLabels.pop(); + freeplayTicksContainer.removeComponent(label); + } + + var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2; + var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2; + + var numberOfTicks:Int = Math.floor(waveformMusic.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1; + + for (index in 0...numberOfTicks) + { + var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index; + var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformMusic.waveform.waveformData.pointsPerSecond(); + + var tickLabel:Label = new Label(); + tickLabel.text = formatTime(tickTime); + tickLabel.styleNames = "offset-ticks-label"; + tickLabel.height = labelHeight; + // Positioning within offsetTicksContainer is absolute (relative to the container itself). + tickLabel.top = labelYPos; + tickLabel.left = tickPos + TICK_LABEL_X_OFFSET; + + freeplayTicksContainer.addComponent(tickLabel); + tickLabels.push(tickLabel); + } + } + + function formatTime(seconds:Float):String + { + if (seconds <= 0) return "0.0"; + + var integerSeconds = Math.floor(seconds); + var decimalSeconds = Math.floor((seconds - integerSeconds) * 10); + + if (integerSeconds < 60) + { + return '${integerSeconds}.${decimalSeconds}'; + } + else + { + var integerMinutes = Math.floor(integerSeconds / 60); + var remainingSeconds = integerSeconds % 60; + var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds'; + + return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}'; + } + } + + function buildTickLabel():Void {} + + public function onStartDragPlayhead():Void + { + Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead); + Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead); + + movePlayheadToMouse(); + } + + public function onDragPlayhead(event:MouseEvent):Void + { + movePlayheadToMouse(); + } + + public function onStopDragPlayhead(event:MouseEvent):Void + { + // Stop dragging. + Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead); + Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead); + } + + function movePlayheadToMouse():Void + { + // Determine the position of the mouse relative to the + var mouseXPos = FlxG.mouse.x; + + var relativeMouseXPos = mouseXPos - waveformScrollview.screenX; + var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos; + + // Move the playhead to the mouse position. + playheadAbsolutePos = targetPlayheadPos; + + // Move the audio preview to the playhead position. + var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex); + audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC; + } + + var waveformDragStartPos:Null<Float> = null; + + var waveformDragPreviewStartPos:Float; + var waveformDragPreviewEndPos:Float; + + public function onStartDragWaveform():Void + { + waveformDragStartPos = FlxG.mouse.x; + + Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform); + Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform); + } + + public function onDragWaveform(event:MouseEvent):Void + { + // Set waveformDragPreviewStartPos and waveformDragPreviewEndPos to the position the drag started and the current mouse position. + // This only affects the visuals. + + var currentAbsMousePos = FlxG.mouse.x; + var dragDiff = currentAbsMousePos - waveformDragStartPos; + + var currentRelativeMousePos = currentAbsMousePos - waveformScrollview.screenX; + var relativeStartPos = waveformDragStartPos - waveformScrollview.screenX; + + var isDraggingRight = dragDiff > 0; + var hasDraggedEnough = Math.abs(dragDiff) > 10; + + if (hasDraggedEnough) + { + if (isDraggingRight) + { + waveformDragPreviewStartPos = relativeStartPos; + waveformDragPreviewEndPos = currentRelativeMousePos; + } + else + { + waveformDragPreviewStartPos = currentRelativeMousePos; + waveformDragPreviewEndPos = relativeStartPos; + } + } + + refresh(); + } + + public function onStopDragWaveform(event:MouseEvent):Void + { + Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform); + Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform); + + var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos; + var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC); + + var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos; + var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC); + + chartEditorState.performCommand(new SetFreeplayPreviewCommand(previewStartPosMs, previewEndPosMs)); + + waveformDragStartPos = null; + waveformDragPreviewStartPos = 0; + waveformDragPreviewEndPos = 0; + + refresh(); + addOffsetsToAudioPreview(); + } + + public function playAudioPreview():Void + { + if (isPerformingPreview) stopPerformingPreview(); + + audioPreviewTracks.volume = freeplayPreviewVolume; + audioPreviewTracks.play(false, audioPreviewTracks.time); + } + + public function addOffsetsToAudioPreview():Void + { + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + trackInst.time -= chartEditorState.currentInstrumentalOffset; + } + + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + trackPlayer.time -= chartEditorState.currentVocalOffsetPlayer; + } + + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + trackOpponent.time -= chartEditorState.currentVocalOffsetOpponent; + } + } + + public function pauseAudioPreview():Void + { + if (isPerformingPreview) stopPerformingPreview(); + + audioPreviewTracks.pause(); + } + + public function stopAudioPreview():Void + { + if (isPerformingPreview) stopPerformingPreview(); + + audioPreviewTracks.stop(); + + audioPreviewTracks.time = 0; + + waveformScrollview.hscrollPos = 0; + playheadAbsolutePos = 0 + playheadSprite.width; + refresh(); + addOffsetsToAudioPreview(); + } + + public function zoomWaveformIn():Void + { + if (isPerformingPreview) stopPerformingPreview(); + + if (waveformScale > MIN_SCALE) + { + waveformScale = waveformScale / WAVEFORM_ZOOM_MULT; + if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE; + + trace('Zooming in, scale: ${waveformScale}'); + + // Update the playhead too! + playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT; + + // Recenter the scroll view on the playhead. + var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8; + waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset; + + refresh(); + refreshTicks(); + } + else + { + waveformScale = MIN_SCALE; + } + } + + public function zoomWaveformOut():Void + { + waveformScale = waveformScale * WAVEFORM_ZOOM_MULT; + if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE; + + trace('Zooming out, scale: ${waveformScale}'); + + // Update the playhead too! + playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT; + + // Recenter the scroll view on the playhead. + var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8; + waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset; + + refresh(); + refreshTicks(); + } + + public function setTrackVolume(volume:Float):Void + { + audioPreviewTracks.volume = volume; + } + + public function muteTrack():Void + { + audioPreviewTracks.muted = true; + } + + public function unmuteTrack():Void + { + audioPreviewTracks.muted = false; + } + + public function toggleMuteTrack():Void + { + audioPreviewTracks.muted = !audioPreviewTracks.muted; + } + + var isPerformingPreview:Bool = false; + var isFadingOutPreview:Bool = false; + + public function performPreview():Void + { + isPerformingPreview = true; + isFadingOutPreview = false; + audioPreviewTracks.play(true, chartEditorState.currentSongFreeplayPreviewStart); + audioPreviewTracks.fadeIn(FreeplayState.FADE_IN_DURATION, FreeplayState.FADE_IN_START_VOLUME * freeplayPreviewVolume, + FreeplayState.FADE_IN_END_VOLUME * freeplayPreviewVolume, null); + } + + public function stopPerformingPreview():Void + { + isPerformingPreview = false; + isFadingOutPreview = false; + audioPreviewTracks.volume = freeplayPreviewVolume; + audioPreviewTracks.pause(); + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + if (isPerformingPreview && !audioPreviewTracks.playing) + { + stopPerformingPreview(); + } + + if (isPerformingPreview && audioPreviewTracks.playing) + { + var startFadeOutTime = chartEditorState.currentSongFreeplayPreviewEnd - (FreeplayState.FADE_OUT_DURATION * Constants.MS_PER_SEC); + trace('startFadeOutTime: ${audioPreviewTracks.time} >= ${startFadeOutTime}'); + if (!isFadingOutPreview && audioPreviewTracks.time >= startFadeOutTime) + { + isFadingOutPreview = true; + audioPreviewTracks.fadeOut(FreeplayState.FADE_OUT_DURATION, FreeplayState.FADE_OUT_END_VOLUME * freeplayPreviewVolume, (_) -> { + trace('Stop performing preview! ${audioPreviewTracks.time}'); + stopPerformingPreview(); + }); + } + } + + if (audioPreviewTracks.playing) + { + var targetScrollPos:Float = waveformMusic.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor); + // waveformScrollview.hscrollPos = targetScrollPos; + var prevPlayheadAbsolutePos = playheadAbsolutePos; + playheadAbsolutePos = targetScrollPos; + var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos; + + // BEHAVIOR C. + // Copy Audacity! + // If the playhead is out of view, jump forward or backward by one screen width until it's in view. + if (playheadAbsolutePos < waveformScrollview.hscrollPos) + { + waveformScrollview.hscrollPos -= waveformScrollview.width; + } + if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width) + { + waveformScrollview.hscrollPos += waveformScrollview.width; + } + } + freeplayLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC); + if (waveformDragStartPos != null && (waveformDragPreviewStartPos > 0 && waveformDragPreviewEndPos > 0)) + { + var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos; + var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC); + + var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos; + var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC); + + // Set the values in milliseconds. + freeplayPreviewStart.value = previewStartPosMs; + freeplayPreviewEnd.value = previewEndPosMs; + + previewBoxStartPosAbsolute = previewStartPosAbsolute; + previewBoxEndPosAbsolute = previewEndPosAbsolute; + } + else + { + previewBoxStartPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewStart / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor); + previewBoxEndPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewEnd / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor); + + freeplayPreviewStart.value = chartEditorState.currentSongFreeplayPreviewStart; + freeplayPreviewEnd.value = chartEditorState.currentSongFreeplayPreviewEnd; + } + } + + public override function refresh():Void + { + super.refresh(); + + waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformMusic.waveform.waveformData.pointsPerSecond()); + + var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor; + + var maxWidth:Int = -1; + + waveformMusic.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC; + waveformMusic.waveform.width = (waveformMusic.waveform.waveformData?.length ?? 1000) / currentZoomFactor; + if (waveformMusic.waveform.width > maxWidth) maxWidth = Std.int(waveformMusic.waveform.width); + waveformMusic.waveform.height = 65; + waveformMusic.waveform.markDirty(); + + waveformContainer.width = maxWidth; + tickTiledSprite.width = maxWidth; + } + + public static function build(chartEditorState:ChartEditorState):ChartEditorFreeplayToolbox + { + return new ChartEditorFreeplayToolbox(chartEditorState); + } +} diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx index 509aa5b07..5d8c25bae 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx @@ -2,7 +2,8 @@ package funkin.ui.debug.charting.toolboxes; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.character.CharacterData; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; +import funkin.data.stage.StageRegistry; import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand; import funkin.ui.debug.charting.util.ChartEditorDropdowns; import haxe.ui.components.Button; @@ -13,6 +14,7 @@ import haxe.ui.components.Label; import haxe.ui.components.NumberStepper; import haxe.ui.components.Slider; import haxe.ui.components.TextField; +import funkin.play.stage.Stage; import haxe.ui.containers.Box; import haxe.ui.containers.Frame; import haxe.ui.events.UIEvent; @@ -33,8 +35,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox var buttonCharacterGirlfriend:Button; var buttonCharacterOpponent:Button; var inputBPM:NumberStepper; - var inputOffsetInst:NumberStepper; - var inputOffsetVocal:NumberStepper; var labelScrollSpeed:Label; var inputScrollSpeed:Slider; var frameVariation:Frame; @@ -116,20 +116,26 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox } }; - inputOffsetInst.onChange = function(event:UIEvent) { - if (event.value == null) return; + inputTimeSignature.onChange = function(event:UIEvent) { + var timeSignatureStr:String = event.data.text; + var timeSignature = timeSignatureStr.split('/'); + if (timeSignature.length != 2) return; - chartEditorState.currentInstrumentalOffset = event.value; - Conductor.instrumentalOffset = event.value; - // Update song length. - chartEditorState.songLengthInMs = (chartEditorState.audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset; + var timeSignatureNum:Int = Std.parseInt(timeSignature[0]); + var timeSignatureDen:Int = Std.parseInt(timeSignature[1]); + + var previousTimeSignatureNum:Int = chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum; + var previousTimeSignatureDen:Int = chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen; + if (timeSignatureNum == previousTimeSignatureNum && timeSignatureDen == previousTimeSignatureDen) return; + + chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum = timeSignatureNum; + chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen = timeSignatureDen; + + trace('Time signature changed to ${timeSignatureNum}/${timeSignatureDen}'); + + chartEditorState.updateTimeSignature(); }; - inputOffsetVocal.onChange = function(event:UIEvent) { - if (event.value == null) return; - - chartEditorState.currentSongMetadata.offsets.setVocalOffset(chartEditorState.currentSongMetadata.playData.characters.player, event.value); - }; inputScrollSpeed.onChange = function(event:UIEvent) { var valid:Bool = event.target.value != null && event.target.value > 0; @@ -162,6 +168,8 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox public override function refresh():Void { + super.refresh(); + inputSongName.value = chartEditorState.currentSongMetadata.songName; inputSongArtist.value = chartEditorState.currentSongMetadata.artist; inputStage.value = chartEditorState.currentSongMetadata.playData.stage; @@ -172,28 +180,56 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox frameVariation.text = 'Variation: ${chartEditorState.selectedVariation.toTitleCase()}'; frameDifficulty.text = 'Difficulty: ${chartEditorState.selectedDifficulty.toTitleCase()}'; + var currentTimeSignature = '${chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum}/${chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen}'; + trace('Setting time signature to ${currentTimeSignature}'); + inputTimeSignature.value = {id: currentTimeSignature, text: currentTimeSignature}; + var stageId:String = chartEditorState.currentSongMetadata.playData.stage; - var stageData:Null<StageData> = StageDataParser.parseStageData(stageId); + var stage:Null<Stage> = StageRegistry.instance.fetchEntry(stageId); if (inputStage != null) { - inputStage.value = (stageData != null) ? - {id: stageId, text: stageData.name} : + inputStage.value = (stage != null) ? + {id: stage.id, text: stage.stageName} : {id: "mainStage", text: "Main Stage"}; } var LIMIT = 6; - var charDataOpponent:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.opponent); - buttonCharacterOpponent.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.opponent); - buttonCharacterOpponent.text = charDataOpponent.name.length > LIMIT ? '${charDataOpponent.name.substr(0, LIMIT)}.' : '${charDataOpponent.name}'; + var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.opponent); + if (charDataOpponent != null) + { + buttonCharacterOpponent.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.opponent); + buttonCharacterOpponent.text = charDataOpponent.name.length > LIMIT ? '${charDataOpponent.name.substr(0, LIMIT)}.' : '${charDataOpponent.name}'; + } + else + { + buttonCharacterOpponent.icon = null; + buttonCharacterOpponent.text = "None"; + } - var charDataGirlfriend:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.girlfriend); - buttonCharacterGirlfriend.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.girlfriend); - buttonCharacterGirlfriend.text = charDataGirlfriend.name.length > LIMIT ? '${charDataGirlfriend.name.substr(0, LIMIT)}.' : '${charDataGirlfriend.name}'; + var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.girlfriend); + if (charDataGirlfriend != null) + { + buttonCharacterGirlfriend.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.girlfriend); + buttonCharacterGirlfriend.text = charDataGirlfriend.name.length > LIMIT ? '${charDataGirlfriend.name.substr(0, LIMIT)}.' : '${charDataGirlfriend.name}'; + } + else + { + buttonCharacterGirlfriend.icon = null; + buttonCharacterGirlfriend.text = "None"; + } - var charDataPlayer:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.player); - buttonCharacterPlayer.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.player); - buttonCharacterPlayer.text = charDataPlayer.name.length > LIMIT ? '${charDataPlayer.name.substr(0, LIMIT)}.' : '${charDataPlayer.name}'; + var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.player); + if (charDataPlayer != null) + { + buttonCharacterPlayer.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.player); + buttonCharacterPlayer.text = charDataPlayer.name.length > LIMIT ? '${charDataPlayer.name.substr(0, LIMIT)}.' : '${charDataPlayer.name}'; + } + else + { + buttonCharacterPlayer.icon = null; + buttonCharacterPlayer.text = "None"; + } } public static function build(chartEditorState:ChartEditorState):ChartEditorMetadataToolbox diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx new file mode 100644 index 000000000..67ca82b1b --- /dev/null +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx @@ -0,0 +1,845 @@ +package funkin.ui.debug.charting.toolboxes; + +import funkin.audio.SoundGroup; +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.SetAudioOffsetCommand; +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 haxe.ui.containers.Frame; +import haxe.ui.core.Screen; +import haxe.ui.events.DragEvent; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; + +/** + * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM. + */ +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/offsets.xml")) +class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox +{ + var waveformContainer:Absolute; + var waveformScrollview:ScrollView; + var waveformPlayer:WaveformPlayer; + var waveformOpponent:WaveformPlayer; + var waveformInstrumental:WaveformPlayer; + var offsetButtonZoomIn:Button; + var offsetButtonZoomOut:Button; + var offsetButtonPause:Button; + var offsetButtonPlay:Button; + var offsetButtonStop:Button; + var offsetStepperPlayer:NumberStepper; + var offsetStepperOpponent:NumberStepper; + var offsetStepperInstrumental:NumberStepper; + var offsetTicksContainer:Absolute; + var playheadSprite:SpriteWrapper; + + static final TICK_LABEL_X_OFFSET:Float = 4.0; + + static final PLAYHEAD_RIGHT_PAD:Float = 10.0; + + static final BASE_SCALE:Float = 64.0; + static final MIN_SCALE:Float = 4.0; + static final WAVEFORM_ZOOM_MULT:Float = 1.5; + + static final MAGIC_SCALE_BASE_TIME:Float = 5.0; + + var waveformScale:Float = BASE_SCALE; + + var playheadAbsolutePos(get, set):Float; + + function get_playheadAbsolutePos():Float + { + return playheadSprite.left; + } + + function set_playheadAbsolutePos(value:Float):Float + { + return playheadSprite.left = value; + } + + var playheadRelativePos(get, set):Float; + + function get_playheadRelativePos():Float + { + return playheadSprite.left - waveformScrollview.hscrollPos; + } + + function set_playheadRelativePos(value:Float):Float + { + return playheadSprite.left = waveformScrollview.hscrollPos + value; + } + + /** + * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds. + */ + var waveformMagicFactor:Float = 1.0; + + var audioPreviewTracks:SoundGroup; + + var tickTiledSprite:FlxTiledSprite; + + var tickLabels:Array<Label> = []; + + // Local store of the audio offsets, so we can detect when they change. + var audioPreviewPlayerOffset:Float = 0; + var audioPreviewOpponentOffset:Float = 0; + var audioPreviewInstrumentalOffset:Float = 0; + + public function new(chartEditorState2:ChartEditorState) + { + super(chartEditorState2); + + initialize(); + + this.onDialogClosed = onClose; + } + + function onClose(event:UIEvent) + { + chartEditorState.menubarItemToggleToolboxOffsets.selected = false; + } + + function initialize():Void + { + // Starting position. + // TODO: Save and load this. + this.x = 150; + this.y = 250; + + offsetPlayerVolume.onChange = (_) -> { + var targetVolume = offsetPlayerVolume.value * 2 / 100; + setTrackVolume(PLAYER, targetVolume); + }; + offsetPlayerMute.onClick = (_) -> { + toggleMuteTrack(PLAYER); + }; + offsetPlayerSolo.onClick = (_) -> { + soloTrack(PLAYER); + }; + offsetOpponentVolume.onChange = (_) -> { + var targetVolume = offsetOpponentVolume.value * 2 / 100; + setTrackVolume(OPPONENT, targetVolume); + }; + offsetOpponentMute.onClick = (_) -> { + toggleMuteTrack(OPPONENT); + }; + offsetOpponentSolo.onClick = (_) -> { + soloTrack(OPPONENT); + }; + offsetInstrumentalVolume.onChange = (_) -> { + var targetVolume = offsetInstrumentalVolume.value * 2 / 100; + setTrackVolume(INSTRUMENTAL, targetVolume); + }; + offsetInstrumentalMute.onClick = (_) -> { + toggleMuteTrack(INSTRUMENTAL); + }; + offsetInstrumentalSolo.onClick = (_) -> { + soloTrack(INSTRUMENTAL); + }; + offsetButtonZoomIn.onClick = (_) -> { + zoomWaveformIn(); + }; + offsetButtonZoomOut.onClick = (_) -> { + zoomWaveformOut(); + }; + offsetButtonPause.onClick = (_) -> { + pauseAudioPreview(); + }; + offsetButtonPlay.onClick = (_) -> { + playAudioPreview(); + }; + offsetButtonStop.onClick = (_) -> { + stopAudioPreview(); + }; + offsetStepperPlayer.onChange = (event:UIEvent) -> { + if (event.value == chartEditorState.currentVocalOffsetPlayer) return; + if (dragWaveform != null) return; + + chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, event.value)); + refresh(); + } + offsetStepperOpponent.onChange = (event:UIEvent) -> { + if (event.value == chartEditorState.currentVocalOffsetOpponent) return; + if (dragWaveform != null) return; + + chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, event.value)); + refresh(); + } + offsetStepperInstrumental.onChange = (event:UIEvent) -> { + if (event.value == chartEditorState.currentInstrumentalOffset) return; + if (dragWaveform != null) return; + + chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, event.value)); + refresh(); + } + waveformScrollview.onScroll = (_) -> { + if (!audioPreviewTracks.playing) + { + // Move the playhead if it would go out of view. + var prevPlayheadRelativePos = playheadRelativePos; + playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD); + var diff = playheadRelativePos - prevPlayheadRelativePos; + + if (diff != 0) + { + // We have to change the song time to match the playhead position when we move it. + var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex); + audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC; + } + + addOffsetsToAudioPreview(); + } + else + { + // The scrollview probably changed because the song position changed. + // If we try to move the song now it will glitch. + } + + // Either way, clipRect has changed, so we need to refresh the waveforms. + refresh(); + }; + + initializeTicks(); + + refreshAudioPreview(); + refresh(); + refreshTicks(); + + waveformPlayer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragWaveform(PLAYER); + }); + waveformOpponent.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragWaveform(OPPONENT); + }); + waveformInstrumental.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragWaveform(INSTRUMENTAL); + }); + + offsetTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> { + onStartDragPlayhead(); + }); + } + + function initializeTicks():Void + { + tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false); + offsetTicksSprite.sprite = tickTiledSprite; + tickTiledSprite.width = 5000; + } + + /** + * Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox. + * These must be DEEP CLONES or else the editor will affect the audio preview! + */ + public function refreshAudioPreview():Void + { + if (audioPreviewTracks == null) + { + audioPreviewTracks = new SoundGroup(); + // Make sure audioPreviewTracks (and all its children) receives update() calls. + chartEditorState.add(audioPreviewTracks); + } + else + { + audioPreviewTracks.stop(); + audioPreviewTracks.clear(); + } + + var instTrack = chartEditorState.audioInstTrack.clone(); + audioPreviewTracks.add(instTrack); + + var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice(); + if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone()); + + var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice(); + if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone()); + + // Build player waveform. + // waveformPlayer.waveform.forceUpdate = true; + 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; + + // Build opponent waveform. + // waveformOpponent.waveform.forceUpdate = true; + waveformOpponent.waveform.waveformData = opponentVoice.waveformData; + waveformOpponent.waveform.duration = opponentVoice.length / 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; + + addOffsetsToAudioPreview(); + } + + public function refreshTicks():Void + { + while (tickLabels.length > 0) + { + var label = tickLabels.pop(); + offsetTicksContainer.removeComponent(label); + } + + var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2; + var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2; + + var numberOfTicks:Int = Math.floor(waveformInstrumental.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1; + + for (index in 0...numberOfTicks) + { + var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index; + var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformInstrumental.waveform.waveformData.pointsPerSecond(); + + var tickLabel:Label = new Label(); + tickLabel.text = formatTime(tickTime); + tickLabel.styleNames = "offset-ticks-label"; + tickLabel.height = labelHeight; + // Positioning within offsetTicksContainer is absolute (relative to the container itself). + tickLabel.top = labelYPos; + tickLabel.left = tickPos + TICK_LABEL_X_OFFSET; + + offsetTicksContainer.addComponent(tickLabel); + tickLabels.push(tickLabel); + } + } + + function formatTime(seconds:Float):String + { + if (seconds <= 0) return "0.0"; + + var integerSeconds = Math.floor(seconds); + var decimalSeconds = Math.floor((seconds - integerSeconds) * 10); + + if (integerSeconds < 60) + { + return '${integerSeconds}.${decimalSeconds}'; + } + else + { + var integerMinutes = Math.floor(integerSeconds / 60); + var remainingSeconds = integerSeconds % 60; + var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds'; + + return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}'; + } + } + + function buildTickLabel():Void {} + + public function onStartDragPlayhead():Void + { + Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead); + Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead); + + movePlayheadToMouse(); + } + + public function onDragPlayhead(event:MouseEvent):Void + { + movePlayheadToMouse(); + } + + public function onStopDragPlayhead(event:MouseEvent):Void + { + // Stop dragging. + Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead); + Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead); + } + + function movePlayheadToMouse():Void + { + // Determine the position of the mouse relative to the + var mouseXPos = FlxG.mouse.x; + + var relativeMouseXPos = mouseXPos - waveformScrollview.screenX; + var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos; + + // Move the playhead to the mouse position. + playheadAbsolutePos = targetPlayheadPos; + + // Move the audio preview to the playhead position. + var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor)); + var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex); + audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC; + } + + public function onStartDragWaveform(waveform:Waveform):Void + { + dragMousePosition = FlxG.mouse.x; + dragWaveform = waveform; + + Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform); + Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform); + } + + var dragMousePosition:Float = 0; + var dragWaveform:Waveform = null; + var dragOffsetMs:Float = 0; + + public function onDragWaveform(event:MouseEvent):Void + { + var newDragMousePosition = FlxG.mouse.x; + var deltaMousePosition = newDragMousePosition - dragMousePosition; + + if (deltaMousePosition == 0) return; + + var deltaPixels:Float = deltaMousePosition * (waveformScale / BASE_SCALE * waveformMagicFactor); + var deltaMilliseconds:Float = switch (dragWaveform) + { + case PLAYER: + deltaPixels / waveformPlayer.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC; + case OPPONENT: + deltaPixels / waveformOpponent.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC; + case INSTRUMENTAL: + deltaPixels / waveformInstrumental.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC; + }; + + switch (dragWaveform) + { + case PLAYER: + // chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds; + dragOffsetMs += deltaMilliseconds; + offsetStepperPlayer.value += deltaMilliseconds; + case OPPONENT: + // chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds; + dragOffsetMs += deltaMilliseconds; + offsetStepperOpponent.value += deltaMilliseconds; + case INSTRUMENTAL: + // chartEditorState.currentInstrumentalOffset += deltaMilliseconds; + dragOffsetMs += deltaMilliseconds; + offsetStepperInstrumental.value += deltaMilliseconds; + } + + dragMousePosition = newDragMousePosition; + + refresh(); + } + + public function onStopDragWaveform(event:MouseEvent):Void + { + // Stop dragging. + Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform); + Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform); + + // Apply the offset change after dragging happens. + // We only do this once per drag so we don't get 20 commands a second in the history. + if (dragOffsetMs != 0) + { + // false to not refresh this toolbox, we will manually do that later. + switch (dragWaveform) + { + case PLAYER: + chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, chartEditorState.currentVocalOffsetPlayer + dragOffsetMs, false)); + case OPPONENT: + chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, chartEditorState.currentVocalOffsetOpponent + dragOffsetMs, false)); + case INSTRUMENTAL: + chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, chartEditorState.currentInstrumentalOffset + dragOffsetMs, false)); + } + } + + dragOffsetMs = 0; + dragMousePosition = 0; + dragWaveform = null; + + refresh(); + addOffsetsToAudioPreview(); + } + + public function playAudioPreview():Void + { + audioPreviewTracks.play(false, audioPreviewTracks.time); + } + + public function addOffsetsToAudioPreview():Void + { + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset; + trackInst.time -= audioPreviewInstrumentalOffset; + } + + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer; + trackPlayer.time -= audioPreviewPlayerOffset; + } + + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent; + trackOpponent.time -= audioPreviewOpponentOffset; + } + } + + public function pauseAudioPreview():Void + { + audioPreviewTracks.pause(); + } + + public function stopAudioPreview():Void + { + audioPreviewTracks.stop(); + + audioPreviewTracks.time = 0; + + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset; + trackInst.time = -audioPreviewInstrumentalOffset; + } + + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer; + trackPlayer.time = -audioPreviewPlayerOffset; + } + + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent; + trackOpponent.time = -audioPreviewOpponentOffset; + } + + waveformScrollview.hscrollPos = 0; + playheadAbsolutePos = 0 + playheadSprite.width; + refresh(); + addOffsetsToAudioPreview(); + } + + public function zoomWaveformIn():Void + { + if (waveformScale > MIN_SCALE) + { + waveformScale = waveformScale / WAVEFORM_ZOOM_MULT; + if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE; + + // Update the playhead too! + playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT; + + // Recenter the scroll view on the playhead. + var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8; + waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset; + + refresh(); + refreshTicks(); + } + else + { + waveformScale = MIN_SCALE; + } + } + + public function zoomWaveformOut():Void + { + waveformScale = waveformScale * WAVEFORM_ZOOM_MULT; + if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE; + + // Update the playhead too! + playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT; + + // Recenter the scroll view on the playhead. + var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8; + waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset; + + refresh(); + refreshTicks(); + } + + public function setTrackVolume(target:Waveform, volume:Float):Void + { + switch (target) + { + case Waveform.INSTRUMENTAL: + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + trackInst.volume = volume; + } + case Waveform.PLAYER: + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + trackPlayer.volume = volume; + } + case Waveform.OPPONENT: + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + trackOpponent.volume = volume; + } + } + } + + public function muteTrack(target:Waveform):Void + { + switch (target) + { + case Waveform.INSTRUMENTAL: + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + trackInst.muted = true; + offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute"; + } + case Waveform.PLAYER: + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + trackPlayer.muted = true; + offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute"; + } + case Waveform.OPPONENT: + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + trackOpponent.muted = true; + offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute"; + } + } + } + + public function unmuteTrack(target:Waveform):Void + { + switch (target) + { + case Waveform.INSTRUMENTAL: + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + trackInst.muted = false; + offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute"; + } + case Waveform.PLAYER: + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + trackPlayer.muted = false; + offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute"; + } + case Waveform.OPPONENT: + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + trackOpponent.muted = false; + offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute"; + } + } + } + + public function toggleMuteTrack(target:Waveform):Void + { + switch (target) + { + case Waveform.INSTRUMENTAL: + var trackInst = audioPreviewTracks.members[0]; + if (trackInst != null) + { + trackInst.muted = !trackInst.muted; + offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute"; + } + case Waveform.PLAYER: + var trackPlayer = audioPreviewTracks.members[1]; + if (trackPlayer != null) + { + trackPlayer.muted = !trackPlayer.muted; + offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute"; + } + case Waveform.OPPONENT: + var trackOpponent = audioPreviewTracks.members[2]; + if (trackOpponent != null) + { + trackOpponent.muted = !trackOpponent.muted; + offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute"; + } + } + } + + /** + * Clicking the solo button will unmute the track and mute all other tracks. + * @param target + */ + public function soloTrack(target:Waveform):Void + { + switch (target) + { + case Waveform.PLAYER: + muteTrack(Waveform.OPPONENT); + muteTrack(Waveform.INSTRUMENTAL); + unmuteTrack(Waveform.PLAYER); + case Waveform.OPPONENT: + muteTrack(Waveform.PLAYER); + muteTrack(Waveform.INSTRUMENTAL); + unmuteTrack(Waveform.OPPONENT); + case Waveform.INSTRUMENTAL: + muteTrack(Waveform.PLAYER); + muteTrack(Waveform.OPPONENT); + unmuteTrack(Waveform.INSTRUMENTAL); + } + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + if (audioPreviewTracks.playing) + { + trace('Playback time: ${audioPreviewTracks.time}'); + + var targetScrollPos:Float = waveformInstrumental.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor); + // waveformScrollview.hscrollPos = targetScrollPos; + var prevPlayheadAbsolutePos = playheadAbsolutePos; + playheadAbsolutePos = targetScrollPos; + var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos; + + // BEHAVIOR A. + // Just move the scroll view with the playhead, constraining it so that the playhead is always visible. + // waveformScrollview.hscrollPos += playheadDiff; + // waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadSprite.width, playheadAbsolutePos); + + // BEHAVIOR B. + // Keep `playheadAbsolutePos` within the bounds of the screen. + // The scroll view will eventually move to where the playhead is 1/8th of the way from the left. This looks kinda nice! + // TODO: This causes a hard snap to scroll when the playhead is to the right of the playheadCenterPoint. + // var playheadCenterPoint = waveformScrollview.width / 8; + // waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadCenterPoint, playheadAbsolutePos); + + // playheadRelativePos = 0; + + // BEHAVIOR C. + // Copy Audacity! + // If the playhead is out of view, jump forward or backward by one screen width until it's in view. + if (playheadAbsolutePos < waveformScrollview.hscrollPos) + { + waveformScrollview.hscrollPos -= waveformScrollview.width; + } + if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width) + { + waveformScrollview.hscrollPos += waveformScrollview.width; + } + } + + if (chartEditorState.currentInstrumentalOffset != audioPreviewInstrumentalOffset) + { + var track = audioPreviewTracks.members[0]; + if (track != null) + { + track.time += audioPreviewInstrumentalOffset; + track.time -= chartEditorState.currentInstrumentalOffset; + audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset; + } + } + if (chartEditorState.currentVocalOffsetPlayer != audioPreviewPlayerOffset) + { + var track = audioPreviewTracks.members[1]; + if (track != null) + { + track.time += audioPreviewPlayerOffset; + track.time -= chartEditorState.currentVocalOffsetPlayer; + audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer; + } + } + if (chartEditorState.currentVocalOffsetOpponent != audioPreviewOpponentOffset) + { + var track = audioPreviewTracks.members[2]; + if (track != null) + { + track.time += audioPreviewOpponentOffset; + track.time -= chartEditorState.currentVocalOffsetOpponent; + audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent; + } + } + offsetLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC); + // Keep the playhead in view. + // playheadRelativePos = FlxMath.bound(playheadRelativePos, waveformScrollview.hscrollPos + 1, + // Math.min(waveformScrollview.hscrollPos + waveformScrollview.width, waveformContainer.width)); + } + + public override function refresh():Void + { + super.refresh(); + + waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformInstrumental.waveform.waveformData.pointsPerSecond()); + + var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor; + + var maxWidth:Int = -1; + + offsetStepperPlayer.value = chartEditorState.currentVocalOffsetPlayer; + offsetStepperOpponent.value = chartEditorState.currentVocalOffsetOpponent; + offsetStepperInstrumental.value = chartEditorState.currentInstrumentalOffset; + + waveformPlayer.waveform.time = -chartEditorState.currentVocalOffsetPlayer / Constants.MS_PER_SEC; // Negative offsets make the song start early. + waveformPlayer.waveform.width = (waveformPlayer.waveform.waveformData?.length ?? 1000) / currentZoomFactor; + if (waveformPlayer.waveform.width > maxWidth) maxWidth = Std.int(waveformPlayer.waveform.width); + waveformPlayer.waveform.height = 65; + + waveformOpponent.waveform.time = -chartEditorState.currentVocalOffsetOpponent / Constants.MS_PER_SEC; + waveformOpponent.waveform.width = (waveformOpponent.waveform.waveformData?.length ?? 1000) / currentZoomFactor; + if (waveformOpponent.waveform.width > maxWidth) maxWidth = Std.int(waveformOpponent.waveform.width); + waveformOpponent.waveform.height = 65; + + waveformInstrumental.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC; + waveformInstrumental.waveform.width = (waveformInstrumental.waveform.waveformData?.length ?? 1000) / currentZoomFactor; + if (waveformInstrumental.waveform.width > maxWidth) maxWidth = Std.int(waveformInstrumental.waveform.width); + waveformInstrumental.waveform.height = 65; + + // Live update the drag, but don't actually change the underlying offset until we release the mouse to finish dragging. + if (dragWaveform != null) switch (dragWaveform) + { + case PLAYER: + // chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds; + waveformPlayer.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC; + offsetStepperPlayer.value += dragOffsetMs; + case OPPONENT: + // chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds; + waveformOpponent.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC; + offsetStepperOpponent.value += dragOffsetMs; + case INSTRUMENTAL: + // chartEditorState.currentInstrumentalOffset += deltaMilliseconds; + waveformInstrumental.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC; + offsetStepperInstrumental.value += dragOffsetMs; + default: + // No drag, no + } + + waveformPlayer.waveform.markDirty(); + waveformOpponent.waveform.markDirty(); + waveformInstrumental.waveform.markDirty(); + + waveformContainer.width = maxWidth; + tickTiledSprite.width = maxWidth; + } + + public static function build(chartEditorState:ChartEditorState):ChartEditorOffsetsToolbox + { + return new ChartEditorOffsetsToolbox(chartEditorState); + } +} + +enum Waveform +{ + PLAYER; + OPPONENT; + INSTRUMENTAL; +} diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx index dfa0408d3..14c07440b 100644 --- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx +++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx @@ -2,10 +2,11 @@ package funkin.ui.debug.charting.util; import funkin.data.notestyle.NoteStyleRegistry; import funkin.play.notes.notestyle.NoteStyle; -import funkin.play.stage.StageData; -import funkin.play.stage.StageData.StageDataParser; +import funkin.data.stage.StageData; +import funkin.data.stage.StageRegistry; import funkin.play.character.CharacterData; import haxe.ui.components.DropDown; +import funkin.play.stage.Stage; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.character.CharacterData.CharacterDataParser; @@ -60,16 +61,16 @@ class ChartEditorDropdowns { dropDown.dataSource.clear(); - var stageIds:Array<String> = StageDataParser.listStageIds(); + var stageIds:Array<String> = StageRegistry.instance.listEntryIds(); var returnValue:DropDownEntry = {id: "mainStage", text: "Main Stage"}; for (stageId in stageIds) { - var stage:Null<StageData> = StageDataParser.parseStageData(stageId); + var stage:Null<Stage> = StageRegistry.instance.fetchEntry(stageId); if (stage == null) continue; - var value = {id: stageId, text: stage.name}; + var value = {id: stage.id, text: stage.stageName}; if (startingStageId == stageId) returnValue = value; dropDown.dataSource.add(value); diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx similarity index 64% rename from source/funkin/play/cutscene/dialogue/ConversationDebugState.hx rename to source/funkin/ui/debug/dialogue/ConversationDebugState.hx index 13697b9f4..33a6f365a 100644 --- a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx +++ b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx @@ -1,10 +1,19 @@ -package funkin.play.cutscene.dialogue; +package funkin.ui.debug.dialogue; import flixel.FlxState; import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.events.ScriptEvent; import flixel.util.FlxColor; import funkin.ui.MusicBeatState; +import funkin.data.dialogue.ConversationData; +import funkin.data.dialogue.ConversationRegistry; +import funkin.data.dialogue.DialogueBoxData; +import funkin.data.dialogue.DialogueBoxRegistry; +import funkin.data.dialogue.SpeakerData; +import funkin.data.dialogue.SpeakerRegistry; +import funkin.play.cutscene.dialogue.Conversation; +import funkin.play.cutscene.dialogue.DialogueBox; +import funkin.play.cutscene.dialogue.Speaker; /** * A state with displays a conversation with no background. @@ -27,7 +36,7 @@ class ConversationDebugState extends MusicBeatState public override function create():Void { - conversation = ConversationDataParser.fetchConversation(conversationId); + conversation = ConversationRegistry.instance.fetchEntry(conversationId); conversation.completeCallback = onConversationComplete; add(conversation); @@ -40,6 +49,12 @@ class ConversationDebugState extends MusicBeatState conversation = null; } + public override function dispatchEvent(event:ScriptEvent):Void + { + // Dispatch event to conversation script. + ScriptEventDispatcher.callEvent(conversation, event); + } + public override function update(elapsed:Float):Void { super.update(elapsed); diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx index 18b0010b2..70ef97fd0 100644 --- a/source/funkin/ui/debug/latency/LatencyState.hx +++ b/source/funkin/ui/debug/latency/LatencyState.hx @@ -75,7 +75,7 @@ class LatencyState extends MusicBeatSubState // funnyStatsGraph.hi - Conductor.forceBPM(60); + Conductor.instance.forceBPM(60); noteGrp = new FlxTypedGroup<NoteSprite>(); add(noteGrp); @@ -91,14 +91,14 @@ class LatencyState extends MusicBeatSubState // // musSpec.visType = FREQUENCIES; // add(musSpec); - for (beat in 0...Math.floor(FlxG.sound.music.length / Conductor.beatLengthMs)) + for (beat in 0...Math.floor(FlxG.sound.music.length / Conductor.instance.beatLengthMs)) { - var beatTick:FlxSprite = new FlxSprite(songPosToX(beat * Conductor.beatLengthMs), FlxG.height - 15); + var beatTick:FlxSprite = new FlxSprite(songPosToX(beat * Conductor.instance.beatLengthMs), FlxG.height - 15); beatTick.makeGraphic(2, 15); beatTick.alpha = 0.3; add(beatTick); - var offsetTxt:FlxText = new FlxText(songPosToX(beat * Conductor.beatLengthMs), FlxG.height - 26, 0, "swag"); + var offsetTxt:FlxText = new FlxText(songPosToX(beat * Conductor.instance.beatLengthMs), FlxG.height - 26, 0, "swag"); offsetTxt.alpha = 0.5; diffGrp.add(offsetTxt); @@ -130,7 +130,7 @@ class LatencyState extends MusicBeatSubState for (i in 0...32) { - var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), Conductor.beatLengthMs * i); + var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), Conductor.instance.beatLengthMs * i); noteGrp.add(note); } @@ -146,9 +146,9 @@ class LatencyState extends MusicBeatSubState override function stepHit():Bool { - if (Conductor.currentStep % 4 == 2) + if (Conductor.instance.currentStep % 4 == 2) { - blocks.members[((Conductor.currentBeat % 8) + 1) % 8].alpha = 0.5; + blocks.members[((Conductor.instance.currentBeat % 8) + 1) % 8].alpha = 0.5; } return super.stepHit(); @@ -156,11 +156,11 @@ class LatencyState extends MusicBeatSubState override function beatHit():Bool { - if (Conductor.currentBeat % 8 == 0) blocks.forEach(blok -> { + if (Conductor.instance.currentBeat % 8 == 0) blocks.forEach(blok -> { blok.alpha = 0; }); - blocks.members[Conductor.currentBeat % 8].alpha = 1; + blocks.members[Conductor.instance.currentBeat % 8].alpha = 1; // block.visible = !block.visible; return super.beatHit(); @@ -192,17 +192,17 @@ class LatencyState extends MusicBeatSubState if (FlxG.keys.pressed.D) FlxG.sound.music.time += 1000 * FlxG.elapsed; - Conductor.update(swagSong.getTimeWithDiff() - Conductor.inputOffset); - // Conductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp; + Conductor.instance.update(swagSong.getTimeWithDiff() - Conductor.instance.inputOffset); + // Conductor.instance.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp; - songPosVis.x = songPosToX(Conductor.songPosition); - songVisFollowAudio.x = songPosToX(Conductor.songPosition - Conductor.instrumentalOffset); - songVisFollowVideo.x = songPosToX(Conductor.songPosition - Conductor.inputOffset); + songPosVis.x = songPosToX(Conductor.instance.songPosition); + songVisFollowAudio.x = songPosToX(Conductor.instance.songPosition - Conductor.instance.instrumentalOffset); + songVisFollowVideo.x = songPosToX(Conductor.instance.songPosition - Conductor.instance.inputOffset); - offsetText.text = "INST Offset: " + Conductor.instrumentalOffset + "ms"; - offsetText.text += "\nINPUT Offset: " + Conductor.inputOffset + "ms"; - offsetText.text += "\ncurrentStep: " + Conductor.currentStep; - offsetText.text += "\ncurrentBeat: " + Conductor.currentBeat; + offsetText.text = "INST Offset: " + Conductor.instance.instrumentalOffset + "ms"; + offsetText.text += "\nINPUT Offset: " + Conductor.instance.inputOffset + "ms"; + offsetText.text += "\ncurrentStep: " + Conductor.instance.currentStep; + offsetText.text += "\ncurrentBeat: " + Conductor.instance.currentBeat; var avgOffsetInput:Float = 0; @@ -221,24 +221,24 @@ class LatencyState extends MusicBeatSubState { if (FlxG.keys.justPressed.RIGHT) { - Conductor.instrumentalOffset += 1.0 * multiply; + Conductor.instance.instrumentalOffset += 1.0 * multiply; } if (FlxG.keys.justPressed.LEFT) { - Conductor.instrumentalOffset -= 1.0 * multiply; + Conductor.instance.instrumentalOffset -= 1.0 * multiply; } } else { if (FlxG.keys.justPressed.RIGHT) { - Conductor.inputOffset += 1.0 * multiply; + Conductor.instance.inputOffset += 1.0 * multiply; } if (FlxG.keys.justPressed.LEFT) { - Conductor.inputOffset -= 1.0 * multiply; + Conductor.instance.inputOffset -= 1.0 * multiply; } } @@ -250,7 +250,7 @@ class LatencyState extends MusicBeatSubState }*/ noteGrp.forEach(function(daNote:NoteSprite) { - daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.instrumentalOffset) - daNote.noteData.time) * 0.45); + daNote.y = (strumLine.y - ((Conductor.instance.songPosition - Conductor.instance.instrumentalOffset) - daNote.noteData.time) * 0.45); daNote.x = strumLine.x + 30; if (daNote.y < strumLine.y) daNote.alpha = 0.5; @@ -258,7 +258,7 @@ class LatencyState extends MusicBeatSubState if (daNote.y < 0 - daNote.height) { daNote.alpha = 1; - // daNote.data.strumTime += Conductor.beatLengthMs * 8; + // daNote.data.strumTime += Conductor.instance.beatLengthMs * 8; } }); @@ -267,14 +267,14 @@ class LatencyState extends MusicBeatSubState function generateBeatStuff() { - Conductor.update(swagSong.getTimeWithDiff()); + Conductor.instance.update(swagSong.getTimeWithDiff()); - var closestBeat:Int = Math.round(Conductor.songPosition / Conductor.beatLengthMs) % diffGrp.members.length; - var getDiff:Float = Conductor.songPosition - (closestBeat * Conductor.beatLengthMs); - getDiff -= Conductor.inputOffset; + var closestBeat:Int = Math.round(Conductor.instance.songPosition / Conductor.instance.beatLengthMs) % diffGrp.members.length; + var getDiff:Float = Conductor.instance.songPosition - (closestBeat * Conductor.instance.beatLengthMs); + getDiff -= Conductor.instance.inputOffset; // lil fix for end of song - if (closestBeat == 0 && getDiff >= Conductor.beatLengthMs * 2) getDiff -= FlxG.sound.music.length; + if (closestBeat == 0 && getDiff >= Conductor.instance.beatLengthMs * 2) getDiff -= FlxG.sound.music.length; trace("\tDISTANCE TO CLOSEST BEAT: " + getDiff + "ms"); trace("\tCLOSEST BEAT: " + closestBeat); diff --git a/source/funkin/ui/debug/stage/StageOffsetSubState.hx b/source/funkin/ui/debug/stage/StageOffsetSubState.hx index 68546f1c7..e8a5d0a23 100644 --- a/source/funkin/ui/debug/stage/StageOffsetSubState.hx +++ b/source/funkin/ui/debug/stage/StageOffsetSubState.hx @@ -5,15 +5,17 @@ import flixel.input.mouse.FlxMouseEvent; import flixel.math.FlxPoint; import funkin.play.character.BaseCharacter; import funkin.play.PlayState; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; import funkin.play.stage.StageProp; import funkin.graphics.shaders.StrokeShader; import funkin.ui.haxeui.HaxeUISubState; import funkin.ui.debug.stage.StageEditorCommand; import funkin.util.SerializerUtil; +import funkin.data.stage.StageRegistry; import funkin.util.MouseUtil; import haxe.ui.containers.ListView; import haxe.ui.core.Component; +import funkin.graphics.FunkinSprite; import haxe.ui.events.UIEvent; import haxe.ui.RuntimeComponentBuilder; import openfl.events.Event; @@ -354,7 +356,13 @@ class StageOffsetSubState extends HaxeUISubState function prepStageStuff():String { - var stageLol:StageData = StageDataParser.parseStageData(PlayState.instance.currentStageId); + var stageLol:StageData = StageRegistry.instance.fetchEntry(PlayState.instance.currentStageId)?._data; + + if (stageLol == null) + { + FlxG.log.error("Stage not found in registry!"); + return ""; + } for (prop in stageLol.props) { @@ -378,6 +386,6 @@ class StageOffsetSubState extends HaxeUISubState stageLol.characters.gf.position[0] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.x); stageLol.characters.gf.position[1] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.y); - return SerializerUtil.toJSON(stageLol); + return stageLol.serialize(); } } diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index 7c69804d9..3c6b52c6f 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -1,6 +1,5 @@ package funkin.ui.freeplay; -import funkin.input.Controls; import flash.text.TextField; import flixel.addons.display.FlxGridOverlay; import flixel.addons.transition.FlxTransitionableState; @@ -23,38 +22,65 @@ import flixel.tweens.FlxTween; import flixel.util.FlxColor; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; -import funkin.input.Controls.Control; import funkin.data.level.LevelRegistry; import funkin.data.song.SongRegistry; import funkin.graphics.adobeanimate.FlxAtlasSprite; import funkin.graphics.shaders.AngleMask; import funkin.graphics.shaders.HSVShader; import funkin.graphics.shaders.PureColor; -import funkin.util.MathUtil; 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.mainmenu.MainMenuState; 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; class FreeplayState extends MusicBeatSubState { + /** + * For the audio preview, the duration of the fade-in effect. + */ + public static final FADE_IN_DURATION:Float = 0.5; + + /** + * For the audio preview, the duration of the fade-out effect. + */ + public static final FADE_OUT_DURATION:Float = 0.25; + + /** + * For the audio preview, the volume at which the fade-in starts. + */ + public static final FADE_IN_START_VOLUME:Float = 0.25; + + /** + * For the audio preview, the volume at which the fade-in ends. + */ + public static final FADE_IN_END_VOLUME:Float = 1.0; + + /** + * For the audio preview, the volume at which the fade-out starts. + */ + public static final FADE_OUT_END_VOLUME:Float = 0.0; + var songs:Array<Null<FreeplaySongData>> = []; var diffIdsCurrent:Array<String> = []; @@ -64,7 +90,7 @@ class FreeplayState extends MusicBeatSubState var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY; var fp:FreeplayScore; - var txtCompletion:FlxText; + var txtCompletion:AtlasText; var lerpCompletion:Float = 0; var intendedCompletion:Float = 0; var lerpScore:Float = 0; @@ -87,6 +113,8 @@ class FreeplayState extends MusicBeatSubState var grpCapsules:FlxTypedGroup<SongMenuItem>; var curCapsule:SongMenuItem; var curPlaying:Bool = false; + var ostName:FlxText; + var difficultyStars:DifficultyStars; var dj:DJBoyfriend; @@ -97,7 +125,7 @@ class FreeplayState extends MusicBeatSubState var stickerSubState:StickerSubState; // - static var rememberedDifficulty:Null<String> = "normal"; + static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY; static var rememberedSongId:Null<String> = null; public function new(?stickers:StickerSubState = null) @@ -136,9 +164,9 @@ class FreeplayState extends MusicBeatSubState isDebug = true; #end - if (FlxG.sound.music != null) + if (FlxG.sound.music == null || (FlxG.sound.music != null && !FlxG.sound.music.playing)) { - if (!FlxG.sound.music.playing) FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); + FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu')); } // Add a null entry that represents the RANDOM option @@ -150,15 +178,10 @@ class FreeplayState extends MusicBeatSubState for (songId in LevelRegistry.instance.parseEntryData(levelId).songs) { var song:Song = SongRegistry.instance.fetchEntry(songId); - var songBaseDifficulty:SongDifficulty = song.getDifficulty(Constants.DEFAULT_DIFFICULTY); - var songName = songBaseDifficulty.songName; - var songOpponent = songBaseDifficulty.characters.opponent; - var songDifficulties = song.listDifficulties(); + songs.push(new FreeplaySongData(levelId, songId, song)); - songs.push(new FreeplaySongData(songId, songName, levelId, songOpponent, songDifficulties)); - - for (difficulty in songDifficulties) + for (difficulty in song.listDifficulties()) { diffIdsTotal.pushUnique(difficulty); } @@ -334,6 +357,8 @@ 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); @@ -347,7 +372,7 @@ class FreeplayState extends MusicBeatSubState 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')); - var difficultyStars:DifficultyStars = new DifficultyStars(140, 39); + difficultyStars = new DifficultyStars(140, 39); difficultyStars.stars.visible = false; albumTitle.visible = false; @@ -382,11 +407,16 @@ class FreeplayState extends MusicBeatSubState add(overhangStuff); FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut}); - var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48); + var fnfFreeplay:FlxText = new FlxText(8, 8, 0, "FREEPLAY", 48); fnfFreeplay.font = "VCR OSD Mono"; fnfFreeplay.visible = false; - exitMovers.set([overhangStuff, fnfFreeplay], + ostName = new FlxText(8, 8, FlxG.width - 8 - 8, "OFFICIAL OST", 48); + ostName.font = "VCR OSD Mono"; + ostName.alignment = RIGHT; + ostName.visible = false; + + exitMovers.set([overhangStuff, fnfFreeplay, ostName], { y: -overhangStuff.height, x: 0, @@ -397,8 +427,9 @@ class FreeplayState extends MusicBeatSubState var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2); fnfFreeplay.shader = sillyStroke; add(fnfFreeplay); + add(ostName); - var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70); + var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70); fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false); fnfHighscoreSpr.visible = false; @@ -415,8 +446,10 @@ class FreeplayState extends MusicBeatSubState fp.visible = false; add(fp); - txtCompletion = new FlxText(1200, 77, 0, "0", 32); - txtCompletion.font = "VCR OSD Mono"; + var clearBoxSprite:FlxSprite = new FlxSprite(1165, 65).loadGraphic(Paths.image('freeplay/clearBox')); + add(clearBoxSprite); + + txtCompletion = new AtlasText(1185, 87, "69", AtlasFont.FREEPLAY_CLEAR); txtCompletion.visible = false; add(txtCompletion); @@ -485,6 +518,7 @@ class FreeplayState extends MusicBeatSubState new FlxTimer().start(1 / 24, function(handShit) { fnfHighscoreSpr.visible = true; fnfFreeplay.visible = true; + ostName.visible = true; fp.visible = true; fp.updateScore(0); @@ -674,9 +708,32 @@ class FreeplayState extends MusicBeatSubState lerpScore = MathUtil.coolLerp(lerpScore, intendedScore, 0.2); lerpCompletion = MathUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9); + if (Math.isNaN(lerpScore)) + { + lerpScore = intendedScore; + } + + if (Math.isNaN(lerpCompletion)) + { + lerpCompletion = intendedCompletion; + } + fp.updateScore(Std.int(lerpScore)); - txtCompletion.text = Math.floor(lerpCompletion * 100) + "%"; + txtCompletion.text = '${Math.floor(lerpCompletion * 100)}'; + + // Right align the completion percentage + switch (txtCompletion.text.length) + { + case 3: + txtCompletion.x = 1185 - 10; + case 2: + txtCompletion.x = 1185; + case 1: + txtCompletion.x = 1185 + 24; + default: + txtCompletion.x = 1185; + } handleInputs(elapsed); } @@ -913,6 +970,11 @@ class FreeplayState extends MusicBeatSubState intendedCompletion = 0.0; } + if (intendedCompletion == Math.POSITIVE_INFINITY || intendedCompletion == Math.NEGATIVE_INFINITY || Math.isNaN(intendedCompletion)) + { + intendedCompletion = 0; + } + grpDifficulties.group.forEach(function(diffSprite) { diffSprite.visible = false; }); @@ -938,6 +1000,27 @@ class FreeplayState extends MusicBeatSubState } } } + + if (change != 0) + { + // Update the song capsules to reflect the new difficulty info. + for (songCapsule in grpCapsules.members) + { + if (songCapsule == null) continue; + if (songCapsule.songData != null) + { + songCapsule.songData.currentDifficulty = currentDifficulty; + songCapsule.init(null, null, songCapsule.songData); + } + else + { + songCapsule.init(null, null, null); + } + } + } + + // Set the difficulty star count on the right. + difficultyStars.difficulty = daSong?.songRating ?? difficultyStars.difficulty; // yay haxe 4.3 } // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String) @@ -1046,6 +1129,10 @@ class FreeplayState extends MusicBeatSubState { currentDifficulty = rememberedDifficulty; } + + // Set the difficulty star count on the right. + var daSong = songs[curSelected]; + difficultyStars.difficulty = daSong?.songRating ?? 0; } function changeSelection(change:Int = 0) @@ -1176,19 +1263,47 @@ class FreeplaySongData { public var isFav:Bool = false; - public var songId:String = ""; - public var songName:String = ""; - public var levelId:String = ""; - public var songCharacter:String = ""; - public var songDifficulties:Array<String> = []; + var song:Song; - public function new(songId:String, songName:String, levelId:String, songCharacter:String, songDifficulties:Array<String>) + public var levelId(default, null):String = ""; + public var songId(default, null):String = ""; + + public var songDifficulties(default, null):Array<String> = []; + + public var songName(default, null):String = ""; + public var songCharacter(default, null):String = ""; + public var songRating(default, null):Int = 0; + + public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; + + function set_currentDifficulty(value:String):String + { + if (currentDifficulty == value) return value; + + currentDifficulty = value; + updateValues(); + return value; + } + + public function new(levelId:String, songId:String, song:Song) { - this.songId = songId; - this.songName = songName; this.levelId = levelId; - this.songCharacter = songCharacter; - this.songDifficulties = songDifficulties; + this.songId = songId; + this.song = song; + + updateValues(); + } + + function updateValues():Void + { + this.songDifficulties = song.listDifficulties(); + if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY; + + var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty); + if (songDifficulty == null) return; + this.songName = songDifficulty.songName; + this.songCharacter = songDifficulty.characters.opponent; + this.songRating = songDifficulty.difficultyRating; } } diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx index 4e0772dfe..06d113468 100644 --- a/source/funkin/ui/freeplay/SongMenuItem.hx +++ b/source/funkin/ui/freeplay/SongMenuItem.hx @@ -35,11 +35,6 @@ class SongMenuItem extends FlxSpriteGroup var ranks:Array<String> = ["fail", "average", "great", "excellent", "perfect"]; - // lol... - var diffRanks:Array<String> = [ - "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "14", "15" - ]; - public var targetPos:FlxPoint = new FlxPoint(); public var doLerp:Bool = false; public var doJumpIn:Bool = false; @@ -47,10 +42,12 @@ class SongMenuItem extends FlxSpriteGroup public var doJumpOut:Bool = false; public var onConfirm:Void->Void; - public var diffGrayscale:Grayscale; + public var grayscaleShader:Grayscale; public var hsvShader(default, set):HSVShader; + var diffRatingSprite:FlxSprite; + public function new(x:Float, y:Float) { super(x, y); @@ -75,26 +72,30 @@ class SongMenuItem extends FlxSpriteGroup add(ranking); grpHide.add(ranking); - diffGrayscale = new Grayscale(1); - - var diffRank = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRankings/diff" + FlxG.random.getObject(diffRanks))); - diffRank.shader = diffGrayscale; - diffRank.visible = false; - add(diffRank); - diffRank.origin.set(capsule.origin.x - diffRank.x, capsule.origin.y - diffRank.y); - grpHide.add(diffRank); - switch (rank) { case "perfect": ranking.x -= 10; } + grayscaleShader = new Grayscale(1); + + diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRatings/diff00")); + diffRatingSprite.shader = grayscaleShader; + diffRatingSprite.visible = false; + add(diffRatingSprite); + diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y); + grpHide.add(diffRatingSprite); + songText = new CapsuleText(capsule.width * 0.26, 45, 'Random', Std.int(40 * realScaled)); add(songText); grpHide.add(songText); - pixelIcon = new FlxSprite(155, 15); + // TODO: Use value from metadata instead of random. + updateDifficultyRating(FlxG.random.int(0, 15)); + + pixelIcon = new FlxSprite(160, 35); + pixelIcon.makeGraphic(32, 32, 0x00000000); pixelIcon.antialiasing = false; pixelIcon.active = false; @@ -113,6 +114,12 @@ class SongMenuItem extends FlxSpriteGroup setVisibleGrp(false); } + function updateDifficultyRating(newRating:Int) + { + var ratingPadded:String = newRating < 10 ? '0$newRating' : '$newRating'; + diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}')); + } + function set_hsvShader(value:HSVShader):HSVShader { this.hsvShader = value; @@ -149,16 +156,17 @@ class SongMenuItem extends FlxSpriteGroup updateSelected(); } - public function init(x:Float, y:Float, songData:Null<FreeplaySongData>) + public function init(?x:Float, ?y:Float, songData:Null<FreeplaySongData>) { - this.x = x; - this.y = y; + if (x != null) this.x = x; + if (y != null) this.y = y; this.songData = songData; // Update capsule text. songText.text = songData?.songName ?? 'Random'; // Update capsule character. if (songData?.songCharacter != null) setCharacter(songData.songCharacter); + updateDifficultyRating(songData?.songRating ?? 0); // Update opacity, offsets, etc. updateSelected(); } @@ -200,7 +208,14 @@ class SongMenuItem extends FlxSpriteGroup pixelIcon.loadGraphic(Paths.image(charPath)); pixelIcon.scale.x = pixelIcon.scale.y = 2; - pixelIcon.origin.x = 100; + + switch (char) + { + case "parents-christmas": + pixelIcon.origin.x = 140; + default: + pixelIcon.origin.x = 100; + } // pixelIcon.origin.x = capsule.origin.x; // pixelIcon.offset.x -= pixelIcon.origin.x; } @@ -336,7 +351,7 @@ class SongMenuItem extends FlxSpriteGroup function updateSelected():Void { - diffGrayscale.setAmount(this.selected ? 0 : 0.8); + grayscaleShader.setAmount(this.selected ? 0 : 0.8); songText.alpha = this.selected ? 1 : 0.6; songText.blurredText.visible = this.selected ? true : false; capsule.offset.x = this.selected ? 0 : -5; diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx index 66b94bfa2..c7171fac7 100644 --- a/source/funkin/ui/haxeui/components/CharacterPlayer.hx +++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx @@ -35,7 +35,7 @@ class CharacterPlayer extends Box public function new(defaultToBf:Bool = true) { super(); - _overrideSkipTransformChildren = false; + // _overrideSkipTransformChildren = false; if (defaultToBf) { diff --git a/source/funkin/ui/haxeui/components/WaveformPlayer.hx b/source/funkin/ui/haxeui/components/WaveformPlayer.hx new file mode 100644 index 000000000..da85fbfef --- /dev/null +++ b/source/funkin/ui/haxeui/components/WaveformPlayer.hx @@ -0,0 +1,17 @@ +package funkin.ui.haxeui.components; + +import funkin.audio.waveform.WaveformSprite; +import funkin.audio.waveform.WaveformData; +import haxe.ui.backend.flixel.components.SpriteWrapper; + +class WaveformPlayer extends SpriteWrapper +{ + public var waveform(default, null):WaveformSprite; + + public function new(?waveformData:WaveformData) + { + super(); + this.waveform = new WaveformSprite(waveformData); + this.sprite = waveform; + } +} diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx index b83b54152..ea04e1208 100644 --- a/source/funkin/ui/options/ControlsMenu.hx +++ b/source/funkin/ui/options/ControlsMenu.hx @@ -8,6 +8,7 @@ import flixel.group.FlxGroup; import flixel.input.actions.FlxActionInput; import flixel.input.gamepad.FlxGamepadInputID; import flixel.input.keyboard.FlxKey; +import funkin.graphics.FunkinSprite; import funkin.input.Controls; import funkin.ui.AtlasText; import funkin.ui.MenuList; @@ -61,8 +62,8 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page if (FlxG.gamepads.numActiveGamepads > 0) { - var devicesBg:FlxSprite = new FlxSprite(); - devicesBg.makeGraphic(FlxG.width, 100, 0xFFFAFD6D); + var devicesBg:FunkinSprite = new FunkinSprite(); + devicesBg.makeSolidColor(FlxG.width, 100, 0xFFFAFD6D); add(devicesBg); deviceList = new TextMenuList(Horizontal, None); add(deviceList); diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx index e86241277..1b9252fde 100644 --- a/source/funkin/ui/story/Level.hx +++ b/source/funkin/ui/story/Level.hx @@ -180,9 +180,9 @@ class Level implements IRegistryEntry<LevelData> return difficulties; } - public function buildProps():Array<LevelProp> + public function buildProps(?existingProps:Array<LevelProp>):Array<LevelProp> { - var props:Array<LevelProp> = []; + var props:Array<LevelProp> = existingProps == null ? [] : [for (x in existingProps) x]; if (_data.props.length == 0) return props; @@ -190,11 +190,22 @@ class Level implements IRegistryEntry<LevelData> { var propData = _data.props[propIndex]; - var propSprite:Null<LevelProp> = LevelProp.build(propData); - if (propSprite == null) continue; + // Attempt to reuse the `LevelProp` object. + // This prevents animations from resetting. + var existingProp:Null<LevelProp> = props[propIndex]; + if (existingProp != null) + { + existingProp.propData = propData; + existingProp.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; + } + else + { + var propSprite:Null<LevelProp> = LevelProp.build(propData); + if (propSprite == null) continue; - propSprite.x += FlxG.width * 0.25 * propIndex; - props.push(propSprite); + propSprite.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; + props.push(propSprite); + } } return props; diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx index 4dce7bfb3..5af383de9 100644 --- a/source/funkin/ui/story/LevelProp.hx +++ b/source/funkin/ui/story/LevelProp.hx @@ -6,9 +6,26 @@ import funkin.data.level.LevelData; class LevelProp extends Bopper { - public function new(danceEvery:Int) + public var propData(default, set):Null<LevelPropData> = null; + + function set_propData(value:LevelPropData):LevelPropData { - super(danceEvery); + // Only reset the prop if the asset path has changed. + if (propData == null || value.assetPath != this.propData.assetPath) + { + this.visible = (value != null); + this.propData = value; + danceEvery = this.propData.danceEvery; + applyData(); + } + + return this.propData; + } + + public function new(propData:LevelPropData) + { + super(propData.danceEvery); + this.propData = propData; } public function playConfirm():Void @@ -16,50 +33,51 @@ class LevelProp extends Bopper playAnimation('confirm', true, true); } - public static function build(propData:Null<LevelPropData>):Null<LevelProp> + function applyData():Void { - if (propData == null) return null; - var isAnimated:Bool = propData.animations.length > 0; - var prop:LevelProp = new LevelProp(propData.danceEvery); - if (isAnimated) { // Initalize sprite frames. // Sparrow atlas only LEL. - prop.frames = Paths.getSparrowAtlas(propData.assetPath); + this.frames = Paths.getSparrowAtlas(propData.assetPath); } else { // Initalize static sprite. - prop.loadGraphic(Paths.image(propData.assetPath)); + this.loadGraphic(Paths.image(propData.assetPath)); // Disables calls to update() for a performance boost. - prop.active = false; + this.active = false; } - if (prop.frames == null || prop.frames.numFrames == 0) + if (this.frames == null || this.frames.numFrames == 0) { trace('ERROR: Could not build texture for level prop (${propData.assetPath}).'); - return null; + return; } var scale:Float = propData.scale * (propData.isPixel ? 6 : 1); - prop.scale.set(scale, scale); - prop.antialiasing = !propData.isPixel; - prop.alpha = propData.alpha; - prop.x = propData.offsets[0]; - prop.y = propData.offsets[1]; + this.scale.set(scale, scale); + this.antialiasing = !propData.isPixel; + this.alpha = propData.alpha; + this.x = propData.offsets[0]; + this.y = propData.offsets[1]; - FlxAnimationUtil.addAtlasAnimations(prop, propData.animations); + FlxAnimationUtil.addAtlasAnimations(this, propData.animations); for (propAnim in propData.animations) { - prop.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + this.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); } - prop.dance(); - prop.animation.paused = true; + this.dance(); + this.animation.paused = true; + } - return prop; + public static function build(propData:Null<LevelPropData>):Null<LevelProp> + { + if (propData == null) return null; + + return new LevelProp(propData); } } diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index 456988873..112817f42 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -10,6 +10,7 @@ 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; @@ -106,7 +107,7 @@ class StoryMenuState extends MusicBeatState var stickerSubState:StickerSubState; static var rememberedLevelId:Null<String> = null; - static var rememberedDifficulty:Null<String> = "normal"; + static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY; public function new(?stickers:StickerSubState = null) { @@ -153,7 +154,7 @@ class StoryMenuState extends MusicBeatState updateBackground(); - var black:FlxSprite = new FlxSprite(levelBackground.x, 0).makeGraphic(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK); + var black:FunkinSprite = new FunkinSprite(levelBackground.x, 0).makeSolidColor(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK); black.zIndex = levelBackground.zIndex - 1; add(black); @@ -238,7 +239,7 @@ class StoryMenuState extends MusicBeatState var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu'); if (freakyMenuMetadata != null) { - Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); + Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges); } FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); @@ -317,7 +318,7 @@ class StoryMenuState extends MusicBeatState override function update(elapsed:Float) { - Conductor.update(); + Conductor.instance.update(); highScoreLerp = Std.int(MathUtil.coolLerp(highScoreLerp, highScore, 0.5)); @@ -636,8 +637,7 @@ class StoryMenuState extends MusicBeatState function updateProps():Void { - levelProps.clear(); - for (prop in currentLevel.buildProps()) + for (prop in currentLevel.buildProps(levelProps.members)) { prop.zIndex = 1000; levelProps.add(prop); diff --git a/source/funkin/ui/title/OutdatedSubState.hx b/source/funkin/ui/title/OutdatedSubState.hx index d262fc4e4..012823541 100644 --- a/source/funkin/ui/title/OutdatedSubState.hx +++ b/source/funkin/ui/title/OutdatedSubState.hx @@ -15,7 +15,7 @@ class OutdatedSubState extends MusicBeatState override function create() { super.create(); - var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); + var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK); add(bg); var ver = "v" + Application.current.meta.get('version'); var txt:FlxText = new FlxText(0, 0, FlxG.width, diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx index 7671bb336..a5dcd6def 100644 --- a/source/funkin/ui/title/TitleState.hx +++ b/source/funkin/ui/title/TitleState.hx @@ -13,6 +13,7 @@ import funkin.audio.visualize.SpectogramSprite; import funkin.graphics.shaders.ColorSwap; import funkin.graphics.shaders.LeftMaskShader; import funkin.data.song.SongRegistry; +import funkin.graphics.FunkinSprite; import funkin.ui.MusicBeatState; import funkin.data.song.SongData.SongMusicData; import funkin.graphics.shaders.TitleOutline; @@ -118,7 +119,8 @@ class TitleState extends MusicBeatState persistentUpdate = true; - var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); + var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK); + bg.screenCenter(); add(bg); logoBl = new FlxSprite(-150, -100); @@ -221,7 +223,7 @@ class TitleState extends MusicBeatState var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu'); if (freakyMenuMetadata != null) { - Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges); + Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges); } FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0); FlxG.sound.music.fadeIn(4, 0, 0.7); @@ -256,7 +258,7 @@ class TitleState extends MusicBeatState if (FlxG.keys.pressed.DOWN) FlxG.sound.music.pitch -= 0.5 * elapsed; #end - Conductor.update(); + Conductor.instance.update(); /* if (FlxG.onMobile) { @@ -280,7 +282,7 @@ class TitleState extends MusicBeatState FlxTween.tween(FlxG.stage.window, {y: FlxG.stage.window.y + 100}, 0.7, {ease: FlxEase.quadInOut, type: PINGPONG}); } - if (FlxG.sound.music != null) Conductor.update(FlxG.sound.music.time); + if (FlxG.sound.music != null) Conductor.instance.update(FlxG.sound.music.time); if (FlxG.keys.justPressed.F) FlxG.fullscreen = !FlxG.fullscreen; // do controls.PAUSE | controls.ACCEPT instead? @@ -390,7 +392,7 @@ class TitleState extends MusicBeatState var spec:SpectogramSprite = new SpectogramSprite(FlxG.sound.music); add(spec); - Conductor.forceBPM(190); + Conductor.instance.forceBPM(190); FlxG.camera.flash(FlxColor.WHITE, 1); FlxG.sound.play(Paths.sound('confirmMenu'), 0.7); } @@ -442,13 +444,13 @@ class TitleState extends MusicBeatState if (!skippedIntro) { - // FlxG.log.add(Conductor.currentBeat); + // FlxG.log.add(Conductor.instance.currentBeat); // if the user is draggin the window some beats will // be missed so this is just to compensate - if (Conductor.currentBeat > lastBeat) + if (Conductor.instance.currentBeat > lastBeat) { // TODO: Why does it perform ALL the previous steps each beat? - for (i in lastBeat...Conductor.currentBeat) + for (i in lastBeat...Conductor.instance.currentBeat) { switch (i + 1) { @@ -483,11 +485,11 @@ class TitleState extends MusicBeatState } } } - lastBeat = Conductor.currentBeat; + lastBeat = Conductor.instance.currentBeat; } if (skippedIntro) { - if (cheatActive && Conductor.currentBeat % 2 == 0) swagShader.update(0.125); + if (cheatActive && Conductor.instance.currentBeat % 2 == 0) swagShader.update(0.125); if (logoBl != null && logoBl.animation != null) logoBl.animation.play('bump', true); diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index a223a4123..da9aeb28b 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -13,6 +13,7 @@ import funkin.play.song.Song.SongDifficulty; import funkin.ui.mainmenu.MainMenuState; import funkin.ui.MusicBeatState; import haxe.io.Path; +import funkin.graphics.FunkinSprite; import lime.app.Future; import lime.app.Promise; import lime.utils.AssetLibrary; @@ -42,7 +43,7 @@ class LoadingState extends MusicBeatState override function create():Void { - var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, 0xFFcaff4d); + var bg:FlxSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d); add(bg); funkay = new FlxSprite(); @@ -53,7 +54,7 @@ class LoadingState extends MusicBeatState funkay.scrollFactor.set(); funkay.screenCenter(); - loadBar = new FlxSprite(0, FlxG.height - 20).makeGraphic(FlxG.width, 10, 0xFFff16d2); + loadBar = new FunkinSprite(0, FlxG.height - 20).makeSolidColor(FlxG.width, 10, 0xFFff16d2); loadBar.screenCenter(X); add(loadBar); diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index f8749567b..1005b312e 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -70,7 +70,7 @@ class Constants public static final URL_KICKSTARTER:String = 'https://www.kickstarter.com/projects/funkin/friday-night-funkin-the-full-ass-game/'; /** - * GIT REPO DATA + * REPOSITORY DATA */ // ============================== @@ -86,6 +86,11 @@ class Constants public static final GIT_HASH:String = funkin.util.macro.GitCommit.getGitCommitHash(); #end + /** + * The current library versions, as provided by hmm. + */ + public static final LIBRARY_VERSIONS:Array<String> = funkin.util.macro.HaxelibVersions.getLibraryVersions(); + /** * COLORS */ @@ -123,6 +128,7 @@ class Constants /** * Default list of difficulties for charts. + * Assumes no Erect mode, etc. */ public static final DEFAULT_DIFFICULTY_LIST:Array<String> = ['easy', 'normal', 'hard']; @@ -357,6 +363,12 @@ class Constants */ public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0; + public static final JUDGEMENT_KILLER_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_SICK_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_GOOD_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_BAD_COMBO_BREAK:Bool = true; + public static final JUDGEMENT_SHIT_COMBO_BREAK:Bool = true; + /** * FILE EXTENSIONS */ diff --git a/source/funkin/util/HaxeUIUtil.hx b/source/funkin/util/HaxeUIUtil.hx new file mode 100644 index 000000000..1ffd9cd40 --- /dev/null +++ b/source/funkin/util/HaxeUIUtil.hx @@ -0,0 +1,17 @@ +package funkin.util; + +import haxe.ui.tooltips.ToolTipRegionOptions; + +class HaxeUIUtil +{ + public static function buildTooltip(text:String, ?left:Float, ?top:Float, ?width:Float, ?height:Float):ToolTipRegionOptions + { + return { + tipData: {text: text}, + left: left ?? 0.0, + top: top ?? 0.0, + width: width ?? 0.0, + height: height ?? 0.0 + } + } +} diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx index ee75dd207..b38c993fe 100644 --- a/source/funkin/util/assets/DataAssets.hx +++ b/source/funkin/util/assets/DataAssets.hx @@ -9,7 +9,8 @@ class DataAssets public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String> { - var textAssets = openfl.utils.Assets.list(); + var textAssets = openfl.utils.Assets.list(TEXT); + var queryPath = buildDataPath(path); var results:Array<String> = []; diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx index a21732048..ad5983e52 100644 --- a/source/funkin/util/logging/CrashHandler.hx +++ b/source/funkin/util/logging/CrashHandler.hx @@ -3,6 +3,7 @@ package funkin.util.logging; import openfl.Lib; import openfl.events.UncaughtErrorEvent; import flixel.util.FlxSignal.FlxTypedSignal; +import flixel.FlxG.FlxRenderMethod; /** * A custom crash handler that writes to a log file and displays a message box. @@ -118,6 +119,18 @@ class CrashHandler var driverInfo = FlxG?.stage?.context3D?.driverInfo ?? 'N/A'; fullContents += 'Driver info: ${driverInfo}\n'; fullContents += 'Platform: ${Sys.systemName()}\n'; + fullContents += 'Render method: ${renderMethod()}\n'; + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += 'Haxelibs: \n'; + + for (lib in Constants.LIBRARY_VERSIONS) + { + fullContents += '- ${lib}\n'; + } fullContents += '\n'; @@ -185,4 +198,32 @@ class CrashHandler { throw "This is an example of an uncaught exception."; } + + public static function induceNullObjectReference():Void + { + var obj:Dynamic = null; + var value = obj.test; + } + + public static function induceNullObjectReference2():Void + { + var obj:Dynamic = null; + var value = obj.test(); + } + + public static function induceNullObjectReference3():Void + { + var obj:Dynamic = null; + var value = obj(); + } + + static function renderMethod():String + { + return switch (FlxG.renderMethod) + { + case FlxRenderMethod.DRAW_TILES: 'DRAW_TILES'; + case FlxRenderMethod.BLITTING: 'BLITTING'; + default: 'UNKNOWN'; + } + } } diff --git a/source/funkin/util/macro/HaxelibVersions.hx b/source/funkin/util/macro/HaxelibVersions.hx new file mode 100644 index 000000000..1a4699bba --- /dev/null +++ b/source/funkin/util/macro/HaxelibVersions.hx @@ -0,0 +1,67 @@ +package funkin.util.macro; + +import haxe.io.Path; + +class HaxelibVersions +{ + public static macro function getLibraryVersions():haxe.macro.Expr.ExprOf<Array<String>> + { + #if !display + return macro $v{formatHmmData(readHmmData())}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call functions on every hint. + var commitHash:Array<String> = []; + return macro $v{commitHash}; + #end + } + + #if (macro) + static function readHmmData():hmm.HmmConfig + { + return hmm.HmmConfig.HmmConfigs.readHmmJsonOrThrow(); + } + + static function formatHmmData(hmmData:hmm.HmmConfig):Array<String> + { + var result:Array<String> = []; + + for (library in hmmData.dependencies) + { + switch (library) + { + case Haxelib(name, version): + result.push('${name} haxelib(${o(version)})'); + case Git(name, url, ref, dir): + result.push('${name} git(${url}/${o(dir, '')}:${o(ref)})'); + case Mercurial(name, url, ref, dir): + result.push('${name} mercurial(${url}/${o(dir, '')}:${o(ref)})'); + case Dev(name, path): + result.push('${name} dev(${path})'); + } + } + + return result; + } + + static function o(option:haxe.ds.Option<String>, defaultValue:String = 'None'):String + { + switch (option) + { + case Some(value): + return value; + case None: + return defaultValue; + } + } + + static function readLibraryCurrentVersion(libraryName:String):String + { + var path = Path.join([Path.addTrailingSlash(Sys.getCwd()), '.haxelib', libraryName, '.current']); + // This is compile time so we should always have Sys available. + var result = sys.io.File.getContent(path); + + return result; + } + #end +} diff --git a/source/funkin/util/plugins/EvacuateDebugPlugin.hx b/source/funkin/util/plugins/EvacuateDebugPlugin.hx new file mode 100644 index 000000000..1803c25ba --- /dev/null +++ b/source/funkin/util/plugins/EvacuateDebugPlugin.hx @@ -0,0 +1,35 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which adds functionality to press `F4` to immediately transition to the main menu. + * This is useful for debugging or if you get softlocked or something. + */ +class EvacuateDebugPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new EvacuateDebugPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.F4) + { + FlxG.switchState(new funkin.ui.mainmenu.MainMenuState()); + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/README.md b/source/funkin/util/plugins/README.md new file mode 100644 index 000000000..fe87d36e5 --- /dev/null +++ b/source/funkin/util/plugins/README.md @@ -0,0 +1,5 @@ +# funkin.util.plugins + +Flixel plugins are objects with `update()` functions that are called from every state. + +See: https://github.com/HaxeFlixel/flixel/blob/dev/flixel/system/frontEnds/PluginFrontEnd.hx diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx new file mode 100644 index 000000000..a43317cce --- /dev/null +++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx @@ -0,0 +1,38 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which adds functionality to press `F5` to reload all game assets, then reload the current state. + * This is useful for hot reloading assets during development. + */ +class ReloadAssetsDebugPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new ReloadAssetsDebugPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.F5) + { + funkin.modding.PolymodHandler.forceReloadAssets(); + + // Create a new instance of the current state, so old data is cleared. + FlxG.resetState(); + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/WatchPlugin.hx b/source/funkin/util/plugins/WatchPlugin.hx new file mode 100644 index 000000000..17b2dd129 --- /dev/null +++ b/source/funkin/util/plugins/WatchPlugin.hx @@ -0,0 +1,38 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which adds functionality to display several universally important values + * in the Flixel variable watch window. + */ +class WatchPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new WatchPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition); + FlxG.watch.addQuick("songPositionNoOffset", Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); + 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); + FlxG.watch.addQuick("currentStepTime", Conductor.instance.currentStepTime); + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx index a88f8a861..0209cfc19 100644 --- a/source/funkin/util/tools/ArrayTools.hx +++ b/source/funkin/util/tools/ArrayTools.hx @@ -76,4 +76,72 @@ class ArrayTools while (array.length > 0) array.pop(); } + + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone<T>(array:Array<T>):Array<T> + { + return [for (element in array) element]; + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone<T, U:ICloneable<T>>(array:Array<U>):Array<T> + { + return [for (element in array) element.clone()]; + } + + /** + * Return true only if both arrays contain the same elements (possibly in a different order). + * @param a The first array to compare. + * @param b The second array to compare. + * @return Weather both arrays contain the same elements. + */ + public static function isEqualUnordered<T>(a:Array<T>, b:Array<T>):Bool + { + if (a.length != b.length) return false; + for (element in a) + { + if (!b.contains(element)) return false; + } + for (element in b) + { + if (!a.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param superset The array to query for each element. + * @param subset The array containing the elements to query for. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSuperset<T>(superset:Array<T>, subset:Array<T>):Bool + { + // Shortcuts. + if (subset.length == 0) return true; + if (subset.length > superset.length) return false; + + // Check each element. + for (element in subset) + { + if (!superset.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param subset The array containing the elements to query for. + * @param superset The array to query for each element. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSubset<T>(subset:Array<T>, superset:Array<T>):Bool + { + // Switch it around. + return isSuperset(superset, subset); + } } diff --git a/source/funkin/util/tools/ICloneable.hx b/source/funkin/util/tools/ICloneable.hx new file mode 100644 index 000000000..33f19f167 --- /dev/null +++ b/source/funkin/util/tools/ICloneable.hx @@ -0,0 +1,10 @@ +package funkin.util.tools; + +/** + * Implement this on a class to enable `Array<T>.deepClone()` to work on it. + * NOTE: T should be the type of the class that implements this interface. + */ +interface ICloneable<T> +{ + public function clone():T; +} diff --git a/source/funkin/util/tools/Int64Tools.hx b/source/funkin/util/tools/Int64Tools.hx index 75448b36f..d53fa315d 100644 --- a/source/funkin/util/tools/Int64Tools.hx +++ b/source/funkin/util/tools/Int64Tools.hx @@ -1,32 +1,42 @@ package funkin.util.tools; +import haxe.Int64; + /** - * @see https://github.com/fponticelli/thx.core/blob/master/src/thx/Int64s.hx + * Why `haxe.Int64` doesn't have a built-in `toFloat` function is beyond me. */ class Int64Tools { - static var min = haxe.Int64.make(0x80000000, 0); - static var one = haxe.Int64.make(0, 1); - static var two = haxe.Int64.ofInt(2); - static var zero = haxe.Int64.make(0, 0); - static var ten = haxe.Int64.ofInt(10); + private inline static var MAX_32_PRECISION:Float = 4294967296.0; - public static function toFloat(i:haxe.Int64):Float + public static function fromFloat(f:Float):Int64 { - var isNegative = false; - if (i < 0) + var h = Std.int(f / MAX_32_PRECISION); + var l = Std.int(f); + return Int64.make(h, l); + } + + public static function toFloat(i:Int64):Float + { + var f:Float = Int64.getLow(i); + if (f < 0) f += MAX_32_PRECISION; + return (Int64.getHigh(i) * MAX_32_PRECISION + f); + } + + public static function isToIntSafe(i:Int64):Bool + { + return i.high != i.low >> 31; + } + + public static function toIntSafe(i:Int64):Int + { + try { - if (i < min) return -9223372036854775808.0; // most -ve value can't be made +ve - isNegative = true; - i = -i; + return Int64.toInt(i); } - var multiplier = 1.0, ret = 0.0; - for (_ in 0...64) + catch (e:Dynamic) { - if (haxe.Int64.and(i, one) != zero) ret += multiplier; - multiplier *= 2.0; - i = haxe.Int64.shr(i, 1); + throw 'Could not represent value "${Int64.toStr(i)}" as an integer.'; } - return (isNegative ? -1 : 1) * ret; } } diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx index 739c5efdb..1399fb791 100644 --- a/source/funkin/util/tools/MapTools.hx +++ b/source/funkin/util/tools/MapTools.hx @@ -25,6 +25,33 @@ class MapTools return [for (i in map.iterator()) i]; } + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone<K, T>(map:Map<K, T>):Map<K, T> + { + return map.copy(); + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone<K, T, U:ICloneable<T>>(map:Map<K, U>):Map<K, T> + { + // TODO: This function does NOT work. + throw "Not implemented"; + + /* + var newMap:Map<K, T> = []; + // Replace each value with a clone of itself. + for (key in newMap.keys()) + { + newMap.set(key, newMap.get(key).clone()); + } + return newMap; + */ + } + /** * Return a list of keys from the map (as an array, rather than an iterator). * TODO: Rename this? diff --git a/tests/unit/project.xml b/tests/unit/project.xml index 2e505e015..dfbf06502 100644 --- a/tests/unit/project.xml +++ b/tests/unit/project.xml @@ -27,7 +27,6 @@ <haxelib name="hxCodec" /> <!-- Video playback --> <haxelib name="thx.semver" /> <!-- Semantic version handling --> <haxelib name="json2object" /> <!-- JSON parsing --> - <haxelib name="tink_json" /> <!-- JSON parsing --> <!-- Test dependencies --> <haxelib name="munit" /> <!-- Unit test execution --> diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx index c65f3f297..a0cfedbab 100644 --- a/tests/unit/source/funkin/ConductorTest.hx +++ b/tests/unit/source/funkin/ConductorTest.hx @@ -31,23 +31,23 @@ class ConductorTest extends FunkinTest { // NOTE: Expected value comes first. - Assert.areEqual([], Conductor.timeChanges); - Assert.areEqual(null, Conductor.currentTimeChange); + Assert.areEqual([], Conductor.instance.timeChanges); + Assert.areEqual(null, Conductor.instance.currentTimeChange); - Assert.areEqual(0, Conductor.songPosition); - Assert.areEqual(Constants.DEFAULT_BPM, Conductor.bpm); - Assert.areEqual(null, Conductor.bpmOverride); + Assert.areEqual(0, Conductor.instance.songPosition); + Assert.areEqual(Constants.DEFAULT_BPM, Conductor.instance.bpm); + Assert.areEqual(null, Conductor.instance.bpmOverride); - Assert.areEqual(600, Conductor.beatLengthMs); + Assert.areEqual(600, Conductor.instance.beatLengthMs); - Assert.areEqual(4, Conductor.timeSignatureNumerator); - Assert.areEqual(4, Conductor.timeSignatureDenominator); + Assert.areEqual(4, Conductor.instance.timeSignatureNumerator); + Assert.areEqual(4, Conductor.instance.timeSignatureDenominator); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - Assert.areEqual(0.0, Conductor.currentStepTime); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + Assert.areEqual(0.0, Conductor.instance.currentStepTime); - Assert.areEqual(150, Conductor.stepLengthMs); + Assert.areEqual(150, Conductor.instance.stepLengthMs); } /** @@ -60,23 +60,23 @@ class ConductorTest extends FunkinTest var currentConductorState:Null<ConductorState> = conductorState; Assert.isNotNull(currentConductorState); - Assert.areEqual(0, Conductor.songPosition); + Assert.areEqual(0, Conductor.instance.songPosition); step(); // 1 var BPM_100_STEP_TIME = 1 / 9; - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(1 / 9, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(1 / 9, Conductor.instance.currentStepTime); step(7); // 8 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(8 / 9, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(8 / 9, Conductor.instance.currentStepTime); Assert.areEqual(0, currentConductorState.beatsHit); Assert.areEqual(0, currentConductorState.stepsHit); @@ -88,10 +88,10 @@ class ConductorTest extends FunkinTest currentConductorState.beatsHit = 0; currentConductorState.stepsHit = 0; - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(1, Conductor.currentStep); - FunkinAssert.areNear(1.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(1, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime); step(35 - 9); // 35 @@ -100,10 +100,10 @@ class ConductorTest extends FunkinTest currentConductorState.beatsHit = 0; currentConductorState.stepsHit = 0; - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(3, Conductor.currentStep); - FunkinAssert.areNear(3.0 + 8 / 9, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(3, Conductor.instance.currentStep); + FunkinAssert.areNear(3.0 + 8 / 9, Conductor.instance.currentStepTime); step(); // 36 @@ -112,83 +112,83 @@ class ConductorTest extends FunkinTest currentConductorState.beatsHit = 0; currentConductorState.stepsHit = 0; - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(4, Conductor.currentStep); - FunkinAssert.areNear(4.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime); step(50 - 36); // 50 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(5, Conductor.currentStep); - FunkinAssert.areNear(5.555555, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(5, Conductor.instance.currentStep); + FunkinAssert.areNear(5.555555, Conductor.instance.currentStepTime); step(49); // 99 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(11, Conductor.currentStep); - FunkinAssert.areNear(11.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(11, Conductor.instance.currentStep); + FunkinAssert.areNear(11.0, Conductor.instance.currentStepTime); step(1); // 100 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(11, Conductor.currentStep); - FunkinAssert.areNear(11.111111, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(11, Conductor.instance.currentStep); + FunkinAssert.areNear(11.111111, Conductor.instance.currentStepTime); } @Test function testUpdateForcedBPM():Void { - Conductor.forceBPM(60); + Conductor.instance.forceBPM(60); - Assert.areEqual(0, Conductor.songPosition); + Assert.areEqual(0, Conductor.instance.songPosition); // 60 beats per minute = 1 beat per second // 1 beat per second = 1/60 beats per frame = 4/60 steps per frame step(); // Advances time 1/60 of 1 second. - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step step(14 - 1); // 14 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(1.0 - 4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0 - 4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step step(); // 15 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(1, Conductor.currentStep); - FunkinAssert.areNear(1.0, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(1, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step step(45 - 1); // 59 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(3, Conductor.currentStep); - FunkinAssert.areNear(4.0 - 4 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(3, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0 - 4 / 60, Conductor.instance.currentStepTime); step(); // 60 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(4, Conductor.currentStep); - FunkinAssert.areNear(4.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime); step(); // 61 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(4, Conductor.currentStep); - FunkinAssert.areNear(4.0 + 4 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0 + 4 / 60, Conductor.instance.currentStepTime); } @Test @@ -196,50 +196,50 @@ class ConductorTest extends FunkinTest { // Start the song with a BPM of 120. var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120)]; - Conductor.mapTimeChanges(songTimeChanges); + Conductor.instance.mapTimeChanges(songTimeChanges); // All should be at 0. - FunkinAssert.areNear(0, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step // 120 beats per minute = 2 beat per second // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame step(); // Advances time 1/60 of 1 second. - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step step(15 - 1); // 15 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(2, Conductor.currentStep); - FunkinAssert.areNear(2.0, Conductor.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(2, Conductor.instance.currentStep); + FunkinAssert.areNear(2.0, Conductor.instance.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step step(45 - 1); // 59 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(7, Conductor.currentStep); - FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime); step(); // 60 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); step(); // 61 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); } @Test @@ -247,57 +247,57 @@ class ConductorTest extends FunkinTest { // Start the song with a BPM of 120. var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)]; - Conductor.mapTimeChanges(songTimeChanges); + Conductor.instance.mapTimeChanges(songTimeChanges); // All should be at 0. - FunkinAssert.areNear(0, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step // 120 beats per minute = 2 beat per second // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame step(); // Advances time 1/60 of 1 second. - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step step(60 - 1 - 1); // 59 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(7, Conductor.currentStep); - FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime); step(); // 60 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); step(); // 61 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); step(179 - 61); // 179 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition); - Assert.areEqual(5, Conductor.currentBeat); - Assert.areEqual(23, Conductor.currentStep); - FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition); + Assert.areEqual(5, Conductor.instance.currentBeat); + Assert.areEqual(23, Conductor.instance.currentStep); + FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime); step(); // 180 (3 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition); - Assert.areEqual(6, Conductor.currentBeat); - Assert.areEqual(24, Conductor.currentStep); - FunkinAssert.areNear(24.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime); step(); // 181 (3 + 1/60 seconds) // BPM has switched to 90! @@ -305,24 +305,24 @@ class ConductorTest extends FunkinTest // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame // = 12/120 steps per frame - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition); - Assert.areEqual(6, Conductor.currentBeat); - Assert.areEqual(24, Conductor.currentStep); - FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime); step(59); // 240 (4 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition); - Assert.areEqual(7, Conductor.currentBeat); - Assert.areEqual(30, Conductor.currentStep); - FunkinAssert.areNear(30.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime); step(); // 241 (4 + 1/60 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition); - Assert.areEqual(7, Conductor.currentBeat); - Assert.areEqual(30, Conductor.currentStep); - FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime); } @Test @@ -334,63 +334,63 @@ class ConductorTest extends FunkinTest new SongTimeChange(3000, 90), new SongTimeChange(6000, 180) ]; - Conductor.mapTimeChanges(songTimeChanges); + Conductor.instance.mapTimeChanges(songTimeChanges); // Verify time changes. - Assert.areEqual(3, Conductor.timeChanges.length); - FunkinAssert.areNear(0, Conductor.timeChanges[0].beatTime); - FunkinAssert.areNear(6, Conductor.timeChanges[1].beatTime); - FunkinAssert.areNear(10.5, Conductor.timeChanges[2].beatTime); + Assert.areEqual(3, Conductor.instance.timeChanges.length); + FunkinAssert.areNear(0, Conductor.instance.timeChanges[0].beatTime); + FunkinAssert.areNear(6, Conductor.instance.timeChanges[1].beatTime); + FunkinAssert.areNear(10.5, Conductor.instance.timeChanges[2].beatTime); // All should be at 0. - FunkinAssert.areNear(0, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step // 120 beats per minute = 2 beat per second // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame step(); // Advances time 1/60 of 1 second. - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition); - Assert.areEqual(0, Conductor.currentBeat); - Assert.areEqual(0, Conductor.currentStep); - FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step step(60 - 1 - 1); // 59 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition); - Assert.areEqual(1, Conductor.currentBeat); - Assert.areEqual(7, Conductor.currentStep); - FunkinAssert.areNear(7 + 104 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7 + 104 / 120, Conductor.instance.currentStepTime); step(); // 60 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); step(); // 61 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition); - Assert.areEqual(2, Conductor.currentBeat); - Assert.areEqual(8, Conductor.currentStep); - FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); step(179 - 61); // 179 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition); - Assert.areEqual(5, Conductor.currentBeat); - Assert.areEqual(23, Conductor.currentStep); - FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition); + Assert.areEqual(5, Conductor.instance.currentBeat); + Assert.areEqual(23, Conductor.instance.currentStep); + FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime); step(); // 180 (3 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition); - Assert.areEqual(6, Conductor.currentBeat); - Assert.areEqual(24, Conductor.currentStep); // 23.999 => 24 - FunkinAssert.areNear(24.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); // 23.999 => 24 + FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime); step(); // 181 (3 + 1/60 seconds) // BPM has switched to 90! @@ -398,45 +398,45 @@ class ConductorTest extends FunkinTest // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame // = 12/120 steps per frame - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition); - Assert.areEqual(6, Conductor.currentBeat); - Assert.areEqual(24, Conductor.currentStep); - FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime); step(60 - 1 - 1); // 240 (4 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.songPosition); - Assert.areEqual(7, Conductor.currentBeat); - Assert.areEqual(29, Conductor.currentStep); - FunkinAssert.areNear(29.0 + 108 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(29, Conductor.instance.currentStep); + FunkinAssert.areNear(29.0 + 108 / 120, Conductor.instance.currentStepTime); step(); // 240 (4 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition); - Assert.areEqual(7, Conductor.currentBeat); - Assert.areEqual(30, Conductor.currentStep); - FunkinAssert.areNear(30.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime); step(); // 241 (4 + 1/60 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition); - Assert.areEqual(7, Conductor.currentBeat); - Assert.areEqual(30, Conductor.currentStep); - FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime); step(359 - 241); // 359 (5 + 59/60 seconds) - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.songPosition); - Assert.areEqual(10, Conductor.currentBeat); - Assert.areEqual(41, Conductor.currentStep); - FunkinAssert.areNear(41 + 108 / 120, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(41, Conductor.instance.currentStep); + FunkinAssert.areNear(41 + 108 / 120, Conductor.instance.currentStepTime); step(); // 360 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.songPosition); - Assert.areEqual(10, Conductor.currentBeat); - Assert.areEqual(42, Conductor.currentStep); // 41.999 - FunkinAssert.areNear(42.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); // 41.999 + FunkinAssert.areNear(42.0, Conductor.instance.currentStepTime); step(); // 361 // BPM has switched to 180! @@ -444,24 +444,24 @@ class ConductorTest extends FunkinTest // 3 beat per second = 3/60 beats per frame // = 12/60 steps per frame - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.songPosition); - Assert.areEqual(10, Conductor.currentBeat); - Assert.areEqual(42, Conductor.currentStep); - FunkinAssert.areNear(42.0 + 12 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); + FunkinAssert.areNear(42.0 + 12 / 60, Conductor.instance.currentStepTime); step(); // 362 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.songPosition); - Assert.areEqual(10, Conductor.currentBeat); - Assert.areEqual(42, Conductor.currentStep); - FunkinAssert.areNear(42.0 + 24 / 60, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); + FunkinAssert.areNear(42.0 + 24 / 60, Conductor.instance.currentStepTime); step(3); // 365 - FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.songPosition); - Assert.areEqual(10, Conductor.currentBeat); - Assert.areEqual(43, Conductor.currentStep); // 42.999 => 42 - FunkinAssert.areNear(43.0, Conductor.currentStepTime); + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(43, Conductor.instance.currentStep); // 42.999 => 42 + FunkinAssert.areNear(43.0, Conductor.instance.currentStepTime); } } @@ -504,6 +504,6 @@ class ConductorState extends FlxState super.update(elapsed); // On each step, increment the Conductor as though the song was playing. - Conductor.update(Conductor.songPosition + elapsed * Constants.MS_PER_SEC); + Conductor.instance.update(Conductor.instance.songPosition + elapsed * Constants.MS_PER_SEC); } } diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx index 0be932d35..5f837ba97 100644 --- a/tests/unit/source/funkin/data/BaseRegistryTest.hx +++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx @@ -156,6 +156,7 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<MyTypeData>(); + parser.ignoreUnknownVariables = false; switch (loadEntryFile(id)) { @@ -181,6 +182,7 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData> // JsonParser does not take type parameters, // otherwise this function would be in BaseRegistry. var parser = new json2object.JsonParser<MyTypeData_v0_1_x>(); + parser.ignoreUnknownVariables = false; switch (loadEntryFile(id)) {