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/Project.xml b/Project.xml index fc6ebf085..f5d506688 100644 --- a/Project.xml +++ b/Project.xml @@ -111,6 +111,7 @@ + @@ -130,8 +131,8 @@ - - + diff --git a/hmm.json b/hmm.json index d461edd24..4b2885a87 100644 --- a/hmm.json +++ b/hmm.json @@ -11,7 +11,7 @@ "name": "flixel", "type": "git", "dir": null, - "ref": "a83738673e7edbf8acba3a1426af284dfe6719fe", + "ref": "07c6018008801972d12275690fc144fcc22e3de6", "url": "https://github.com/FunkinCrew/flixel" }, { @@ -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": "5086e59e7551d775ed4d1fb0188e31de22d1312b", + "ref": "5b2d5b8e7e470cf637953e1369c80a1f42016a75", "url": "https://github.com/haxeui/haxeui-core" }, { "name": "haxeui-flixel", "type": "git", "dir": null, - "ref": "2b9cff727999b53ed292b1675ac1c9089ac77600", + "ref": "e9f880522e27134b29df4067f82df7d7e5237b70", "url": "https://github.com/haxeui/haxeui-flixel" }, { @@ -107,7 +107,7 @@ "name": "lime", "type": "git", "dir": null, - "ref": "737b86f121cdc90358d59e2e527934f267c94a2c", + "ref": "fff39ba6fc64969cd51987ef7491d9345043dc5d", "url": "https://github.com/FunkinCrew/lime" }, { @@ -149,7 +149,7 @@ "name": "polymod", "type": "git", "dir": null, - "ref": "80d1d309803c1b111866524f9769325e3b8b0b1b", + "ref": "cb11a95d0159271eb3587428cf4b9602e46dc469", "url": "https://github.com/larsiusprime/polymod" }, { 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 02b46c88c..c9198c3d4 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -20,11 +20,11 @@ import openfl.display.BitmapData; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.event.SongEventRegistry; +import funkin.data.stage.StageRegistry; import funkin.play.cutscene.dialogue.ConversationDataParser; import funkin.play.cutscene.dialogue.DialogueBoxDataParser; import funkin.play.cutscene.dialogue.SpeakerDataParser; 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; @@ -217,8 +217,9 @@ class InitState extends FlxState ConversationDataParser.loadConversationCache(); DialogueBoxDataParser.loadDialogueBoxCache(); SpeakerDataParser.loadSpeakerCache(); - StageDataParser.loadStageCache(); + StageRegistry.instance.loadEntries(); CharacterDataParser.loadCharacterCache(); + ModuleHandler.buildModuleCallbacks(); ModuleHandler.loadModuleCache(); diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx index 40293b0ce..278578fb3 100644 --- a/source/funkin/audio/FunkinSound.hx +++ b/source/funkin/audio/FunkinSound.hx @@ -107,6 +107,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) diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx index 15c2296ca..528aaa80c 100644 --- a/source/funkin/audio/SoundGroup.hx +++ b/source/funkin/audio/SoundGroup.hx @@ -132,6 +132,12 @@ class SoundGroup extends FlxTypedGroup }); } + public override function destroy() + { + stop(); + super.destroy(); + } + /** * Remove all sounds from the group. */ 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 { 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 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/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/TODO.md b/source/funkin/data/dialogue/TODO.md new file mode 100644 index 000000000..e69de29bb diff --git a/source/funkin/data/event/SongEventSchema.hx b/source/funkin/data/event/SongEventSchema.hx index b5b2978d7..7ebaa5ae1 100644 --- a/source/funkin/data/event/SongEventSchema.hx +++ b/source/funkin/data/event/SongEventSchema.hx @@ -15,7 +15,7 @@ abstract SongEventSchema(SongEventSchemaRaw) } @:arrayAccess - public inline function getByName(name:String):SongEventSchemaField + public function getByName(name:String):SongEventSchemaField { for (field in this) { @@ -41,6 +41,32 @@ abstract SongEventSchema(SongEventSchemaRaw) { return this[k] = v; } + + public function stringifyFieldValue(name:String, value:Dynamic):String + { + var field:SongEventSchemaField = getByName(name); + if (field == null) return 'Unknown'; + + switch (field.type) + { + case SongEventFieldType.STRING: + return Std.string(value); + case SongEventFieldType.INTEGER: + return Std.string(value); + case SongEventFieldType.FLOAT: + return Std.string(value); + case SongEventFieldType.BOOL: + return Std.string(value); + case SongEventFieldType.ENUM: + for (key in field.keys.keys()) + { + if (field.keys.get(key) == value) return key; + } + return Std.string(value); + default: + return 'Unknown'; + } + } } typedef SongEventSchemaRaw = Array; diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx index b5c15de0f..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 { /** - * 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"; diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index 1a726254f..52b9c19d6 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -1,6 +1,7 @@ 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; @@ -38,10 +39,11 @@ class SongMetadata implements ICloneable 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; /** * Data relating to the song's gameplay. @@ -93,7 +95,7 @@ class SongMetadata implements ICloneable result.version = this.version; result.timeFormat = this.timeFormat; result.divisions = this.divisions; - result.offsets = this.offsets.clone(); + 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.clone(); @@ -701,6 +703,11 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR } } + public inline function getHandler():Null + { + return SongEventRegistry.getEvent(this.event); + } + public inline function getSchema():Null { return SongEventRegistry.getEventSchema(this.event); @@ -751,6 +758,39 @@ 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 = 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); + } + @:op(A == B) public function op_equals(other:SongEventData):Bool { @@ -826,7 +866,13 @@ class SongNoteDataRaw implements ICloneable @: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. @@ -883,6 +929,11 @@ class SongNoteDataRaw implements ICloneable 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 = null; @@ -907,9 +958,14 @@ class SongNoteDataRaw implements ICloneable } else { - var lengthMs:Float = Conductor.instance.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; } @@ -980,6 +1036,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; @@ -995,6 +1055,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; @@ -1010,24 +1074,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 309676884..275106f3a 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -273,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, start:Int, end:Int):Array { diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index 5a0835f57..b772349bc 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 { /** @@ -31,7 +32,7 @@ class SongRegistry extends BaseRegistry 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 { @@ -88,7 +89,7 @@ class SongRegistry extends BaseRegistry { try { - var entry:Song = createEntry(entryId); + var entry:Null = createEntry(entryId); if (entry != null) { trace(' Loaded entry data: ${entry}'); @@ -126,7 +127,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; switch (loadEntryMetadataFile(id, variation)) { @@ -149,7 +150,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -209,7 +210,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; switch (loadEntryMetadataFile(id, variation)) { @@ -231,7 +232,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; switch (loadEntryMetadataFile(id, variation)) { @@ -251,7 +252,7 @@ class SongRegistry extends BaseRegistry function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null { var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -265,7 +266,7 @@ class SongRegistry extends BaseRegistry function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null { var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -346,7 +347,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; switch (loadEntryChartFile(id, variation)) { @@ -369,7 +370,7 @@ class SongRegistry extends BaseRegistry variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var parser = new json2object.JsonParser(); - parser.ignoreUnknownVariables = false; + parser.ignoreUnknownVariables = true; parser.fromJson(contents, fileName); if (parser.errors.length > 0) @@ -455,7 +456,7 @@ class SongRegistry extends BaseRegistry { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null = loadEntryMetadataFile(id, variation)?.contents; - var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + var entryVersion:Null = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } @@ -463,7 +464,7 @@ class SongRegistry extends BaseRegistry { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryStr:Null = loadEntryChartFile(id, variation)?.contents; - var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + var entryVersion:Null = VersionUtil.getVersionFromJSON(entryStr); return entryVersion; } 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 = []; + public var characters:StageDataCharacters; + + @:default(1.0) + @:optional + public var cameraZoom:Null; + + 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(); + 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; + + /** + * 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>; + + /** + * 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; + + /** + * An optional array of animations which the prop can play. + * @default Prop has no animations. + */ + @:optional + @:default([]) + var animations:Array; + + /** + * If animations are used, this is the name of the animation to play first. + * @default Don't play an animation. + */ + @:optional + var startingAnimation:Null; + + /** + * 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; + + /** + * 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; +}; 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 +{ + /** + * 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 + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + function createScriptedEntry(clsName:String):Stage + { + return ScriptedStage.init(clsName, "unknown"); + } + + function getScriptedClassNames():Array + { + return ScriptedStage.listScriptClasses(); + } + + /** + * A list of all the stages from the base game, in order. + * TODO: Should this be hardcoded? + */ + public function listBaseGameStageIds():Array + { + 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 + { + 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/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index b7ef07be5..151e658b4 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -4,11 +4,12 @@ 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.SongEventRegistry; +import funkin.data.stage.StageRegistry; import funkin.util.FileUtil; import funkin.data.level.LevelRegistry; import funkin.data.notestyle.NoteStyleRegistry; @@ -275,7 +276,7 @@ class PolymodHandler ConversationDataParser.loadConversationCache(); DialogueBoxDataParser.loadDialogueBoxCache(); SpeakerDataParser.loadSpeakerCache(); - StageDataParser.loadStageCache(); + StageRegistry.instance.loadEntries(); CharacterDataParser.loadCharacterCache(); ModuleHandler.loadModuleCache(); } diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx index 137bf3905..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. @@ -87,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(); // @@ -94,11 +108,12 @@ class GameOverSubState extends MusicBeatSubState // // Add a black background to the screen. - var bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); + 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. @@ -220,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. @@ -280,10 +296,10 @@ 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) { @@ -303,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; @@ -326,6 +342,11 @@ class GameOverSubState extends MusicBeatSubState } }); } + + public override function toString():String + { + return "GameOverSubState"; + } } typedef GameOverParams = diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 1eaad0b06..cc9debf13 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -50,11 +50,11 @@ import funkin.play.notes.SustainTrail; 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; @@ -1353,7 +1353,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) { @@ -1791,7 +1792,64 @@ class PlayState extends MusicBeatSubState { if (playerStrumline?.notes?.members == null || opponentStrumline?.notes?.members == null) return; - opponentStrumline.processNotes(null, dispatchEvent); + // Process notes on the opponent's side. + for (note in opponentStrumline.notes.members) + { + 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.instance.songPosition > hitWindowEnd) + { + if (note.hasMissed) continue; + + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = true; + + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; + } + else if (Conductor.instance.songPosition > hitWindowCenter) + { + if (note.hasBeenHit) continue; + + // Call an event to allow canceling the note hit. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, 0, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Command the opponent to hit the note on time. + // NOTE: This is what handles the strumline and cleaning up the note itself! + opponentStrumline.hitNote(note); + + if (note.holdNoteSprite != null) + { + opponentStrumline.playNoteHoldCover(note.holdNoteSprite); + } + } + else if (Conductor.instance.songPosition > hitWindowStart) + { + if (note.hasBeenHit || note.hasMissed) continue; + + note.tooEarly = false; + note.mayHit = true; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + else + { + note.tooEarly = true; + note.mayHit = false; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + } // Process hold notes on the opponent's side. for (holdNote in opponentStrumline.holdNotes.members) @@ -1808,11 +1866,77 @@ 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); + } } - playerStrumline.processNotes(onNoteMiss, dispatchEvent); + // Process notes on the player's side. + for (note in playerStrumline.notes.members) + { + 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.instance.songPosition > hitWindowEnd) + { + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = true; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; + } + else if (Conductor.instance.songPosition > hitWindowStart) + { + note.tooEarly = false; + note.mayHit = true; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + else + { + note.tooEarly = true; + note.mayHit = false; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + + // This becomes true when the note leaves the hit window. + // It might still be on screen. + if (note.hasMissed && !note.handledMiss) + { + // Call an event to allow canceling the note miss. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, 0, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Judge the miss. + // NOTE: This is what handles the scoring. + trace('Missed note! ${note.noteData}'); + onNoteMiss(note); + + note.handledMiss = true; + } + } // Process hold notes on the player's side. // This handles scoring so we don't need it on the opponent's side. @@ -1828,8 +1952,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); + } } } @@ -1921,8 +2052,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. @@ -1954,15 +2083,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); @@ -1978,8 +2100,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! @@ -2027,8 +2147,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); } @@ -2164,8 +2287,10 @@ 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. @@ -2174,29 +2299,51 @@ class PlayState extends MusicBeatSubState 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()); } @@ -2332,7 +2479,6 @@ class PlayState extends MusicBeatSubState score: songScore, tallies: { - killer: Highscore.tallies.killer, sick: Highscore.tallies.sick, good: Highscore.tallies.good, bad: Highscore.tallies.bad, @@ -2383,7 +2529,6 @@ class PlayState extends MusicBeatSubState tallies: { // TODO: Sum up the values for the whole level! - killer: 0, sick: 0, good: 0, bad: 0, 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/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 = new Map(); - - /** - * A map between animation names and what frame collection the animation should use. - */ - var animAssetPath:Map = new Map(); - - /** - * 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/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 b0ab1ba1c..057f05acb 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -397,7 +397,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. @@ -424,7 +424,7 @@ 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. } } @@ -465,10 +465,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) @@ -612,11 +608,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 7367b97af..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,12 +156,21 @@ class SustainTrail extends FlxSprite if (sustainLength == s) return s; - height = sustainHeight(s, getScrollSpeed()); + graphicHeight = sustainHeight(s, getScrollSpeed()); this.sustainLength = s; updateClipping(); + 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); + } + /** * Sets up new vertex and UV data to clip the trail. * If flipY is true, top and bottom bounds swap places. @@ -161,7 +178,7 @@ class SustainTrail extends FlxSprite */ public function updateClipping(songTime:Float = 0):Void { - var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, height); + var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, graphicHeight); if (clipHeight <= 0.1) { visible = false; @@ -178,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 @@ -197,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=== @@ -233,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 @@ -277,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 @@ -305,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 = 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 c8cb8ce66..ac6c3705e 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -5,13 +5,16 @@ import flixel.group.FlxSpriteGroup; import flixel.math.FlxPoint; import flixel.system.FlxAssets.FlxShader; import flixel.util.FlxSort; +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; @@ -23,14 +26,25 @@ typedef StagePropGroup = FlxTypedSpriteGroup; * * 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 { - 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 namedProps:Map = new Map(); var characters:Map = new Map(); @@ -41,21 +55,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'; } } @@ -129,9 +140,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(); @@ -139,6 +148,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; @@ -162,6 +172,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. @@ -177,13 +203,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(); @@ -195,15 +224,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; @@ -731,6 +753,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass return Sprite; } + static function _fetchData(id:String):Null + { + 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 2d87dec31..000000000 --- a/source/funkin/play/stage/StageData.hx +++ /dev/null @@ -1,548 +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 = new Map(); - - 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 = 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 = DataAssets.listDataFilesInPath('stages/'); - var unscriptedStageIds:Array = 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 - { - 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 - { - var rawJson:String = loadStageFile(stageId); - - var stageData:StageData = migrateStageData(rawJson, stageId); - - return validateStageData(stageId, stageData); - } - - public static function listStageIds():Array - { - 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 - { - // 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(); - parser.ignoreUnknownVariables = false; - 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 = [0, 0]; - static final DEFAULT_CAMERA_OFFSETS_BF:Array = [-100, -100]; - static final DEFAULT_CAMERA_OFFSETS_DAD:Array = [150, -100]; - static final DEFAULT_POSITION:Array = [0, 0]; - static final DEFAULT_SCALE:Float = 1.0; - static final DEFAULT_ALPHA:Float = 1.0; - static final DEFAULT_SCROLL:Array = [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 - { - 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; - public var props:Array; - 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(); - 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; - - /** - * 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>; - - /** - * 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>; - - /** - * An optional array of animations which the prop can play. - * @default Prop has no animations. - */ - @:optional - var animations:Array; - - /** - * If animations are used, this is the name of the animation to play first. - * @default Don't play an animation. - */ - @:optional - var startingAnimation:Null; - - /** - * 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; - - /** - * 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; -}; 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 6753419b7..ce06950f2 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -14,7 +14,7 @@ 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. + // 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"; @@ -110,9 +110,7 @@ abstract Save(RawSaveData) metronomeVolume: 1.0, hitsoundsEnabledPlayer: true, hitsoundsEnabledOpponent: true, - instVolume: 1.0, - voicesVolume: 1.0, - playbackSpeed: 1.0, + themeMusic: true }, }; } @@ -349,38 +347,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; @@ -776,7 +757,6 @@ typedef SaveScoreData = typedef SaveScoreTallyData = { - var killer:Int; var sick:Int; var good:Int; var bad:Int; @@ -1041,6 +1021,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/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 1773a84fe..5f526a364 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -12,6 +12,7 @@ import flixel.FlxCamera; import flixel.FlxSprite; import flixel.FlxSubState; import flixel.group.FlxSpriteGroup; +import funkin.graphics.FunkinSprite; import flixel.input.keyboard.FlxKey; import flixel.math.FlxMath; import flixel.math.FlxPoint; @@ -34,6 +35,7 @@ import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; +import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongDataUtils; @@ -56,13 +58,14 @@ 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.data.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.CutItemsCommand; +import funkin.ui.debug.charting.commands.CopyItemsCommand; import funkin.ui.debug.charting.commands.DeselectAllItemsCommand; import funkin.ui.debug.charting.commands.DeselectItemsCommand; import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; @@ -106,6 +109,7 @@ import haxe.ui.components.Slider; import haxe.ui.components.TextField; import haxe.ui.containers.dialogs.CollapsibleDialog; import haxe.ui.containers.Frame; +import haxe.ui.containers.Box; import haxe.ui.containers.menus.Menu; import haxe.ui.containers.menus.MenuBar; import haxe.ui.containers.menus.MenuItem; @@ -193,10 +197,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. @@ -240,6 +274,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 */ @@ -341,21 +385,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { if (isViewDownscroll) { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); measureTicks.y = gridTiledSprite.y; } else { - gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); measureTicks.y = gridTiledSprite.y; if (audioVisGroup != null && audioVisGroup.playerVis != null) { - audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT); + audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD); } if (audioVisGroup != null && audioVisGroup.opponentVis != null) { - audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT); + audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD); } } } @@ -427,7 +471,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; } @@ -718,7 +762,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 = null; + var currentPlaceNoteData(default, set):Null = null; + + function set_currentPlaceNoteData(value:Null):Null + { + noteDisplayDirty = true; + + return currentPlaceNoteData = value; + } // Note Movement @@ -1315,6 +1366,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. */ @@ -1350,6 +1441,23 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return value; } + var currentVocalOffset(get, set):Float; + + function get_currentVocalOffset():Float + { + // Currently there's only one vocal offset, so we just grab the player's offset since both should be the same. + // Should probably make it so we can set offsets for player + opponent individually, though. + return currentSongOffsets.getVocalOffset(currentPlayerChar); + } + + function set_currentVocalOffset(value:Float):Float + { + // Currently there's only one vocal offset, so we just apply it to both characters. + currentSongOffsets.setVocalOffset(currentPlayerChar, value); + currentSongOffsets.setVocalOffset(currentOpponentChar, value); + return value; + } + /** * The variation ID for the difficulty which is currently being edited. */ @@ -1597,6 +1705,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. */ @@ -1618,14 +1731,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. @@ -1677,6 +1800,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 */ @@ -1864,6 +2017,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Set the z-index of the HaxeUI. this.root.zIndex = 100; + // 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(); @@ -1871,12 +2030,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState fixCamera(); - // Get rid of any music from the previous state. - if (FlxG.sound.music != null) FlxG.sound.music.stop(); - - // Play the welcome music. - setupWelcomeMusic(); - buildDefaultSongData(); buildBackground(); @@ -1886,7 +2039,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState buildGrid(); buildMeasureTicks(); buildNotePreview(); - buildSelectionBox(); buildAdditionalUI(); populateOpenRecentMenu(); @@ -1970,6 +2122,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; @@ -1999,6 +2152,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; @@ -2059,10 +2213,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); + } }); } @@ -2112,8 +2275,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; @@ -2131,7 +2294,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; @@ -2144,10 +2307,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState add(gridPlayhead); gridPlayhead.zIndex = 30; - var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH); - 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 playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); + 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); @@ -2188,10 +2351,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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 = 320; - 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().'; @@ -2203,17 +2367,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) @@ -2235,6 +2388,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(); @@ -2270,7 +2436,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState bounds.height = MIN_HEIGHT; } - trace('Note preview viewport bounds: ' + bounds.toString()); + // trace('Note preview viewport bounds: ' + bounds.toString()); return bounds; } @@ -2377,6 +2543,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; @@ -2401,6 +2568,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 = 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 = 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)); + } + } } /** @@ -2527,11 +2765,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); @@ -2618,6 +2858,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; @@ -2631,14 +2877,20 @@ 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)}%'; - } + if (audioVocalTrackGroup != null) audioVocalTrackGroup.playerVolume = volume; + menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%'; + }; + + menubarItemVolumeVocalsOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + if (audioVocalTrackGroup != null) 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; @@ -3047,8 +3299,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) @@ -3066,7 +3326,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); } } @@ -3083,7 +3345,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) { @@ -3144,7 +3406,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})'); @@ -3157,6 +3422,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState holdNoteSprite.setHeightDirectly(noteLengthPixels); holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); + + trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height); } } @@ -3187,6 +3454,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) @@ -3195,6 +3463,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; @@ -3284,7 +3555,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); } } @@ -3563,6 +3836,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; @@ -3631,7 +3908,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. @@ -3724,7 +4001,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 @@ -3737,7 +4014,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()); } } } @@ -3804,12 +4081,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null = 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)) { @@ -3832,6 +4115,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. @@ -3842,12 +4137,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 { @@ -3855,7 +4155,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()); } } } @@ -3870,7 +4170,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()); } } } @@ -4001,7 +4301,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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) { @@ -4014,8 +4314,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); @@ -4036,6 +4336,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; @@ -4068,6 +4377,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null = 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) { @@ -4094,6 +4411,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. @@ -4111,7 +4439,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) @@ -4124,9 +4452,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. @@ -4176,6 +4509,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return event.alive && FlxG.mouse.overlaps(event); }); } + var highlightedHoldNote:Null = 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) { @@ -4227,13 +4568,40 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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 { // Right clicked on nothing. } } - 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) { @@ -4324,6 +4692,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; @@ -4362,48 +4742,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { difficultySelectDirty = false; - // Manage the Select Difficulty tree view. - var difficultyToolbox:Null = 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 = 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.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 = 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(); } } @@ -4425,7 +4767,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; @@ -4461,7 +4806,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; @@ -4479,6 +4827,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. */ @@ -4587,11 +4944,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; } @@ -4599,15 +4964,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 - 75 - (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; } } @@ -4689,54 +5058,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 @@ -4797,25 +5119,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()); } } @@ -4900,6 +5247,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState autoSave(true); stopWelcomeMusic(); + stopAudioPlayback(); var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; @@ -4979,13 +5327,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 = []; } @@ -4996,6 +5347,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; } @@ -5042,7 +5395,7 @@ 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; } @@ -5138,7 +5491,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.get(variation); if (variationMetadata == null) return; @@ -5160,6 +5513,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.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); @@ -5208,8 +5597,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState Conductor.instance.mapTimeChanges(this.currentSongMetadata.timeChanges); updateTimeSignature(); - refreshDifficultyTreeSelection(); this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); } else { @@ -5217,8 +5606,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 @@ -5236,8 +5625,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 { @@ -5245,7 +5634,7 @@ 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); } } @@ -5333,6 +5722,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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}.', [ @@ -5342,22 +5732,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } ]); }); + #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 @@ -5371,98 +5769,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState /** * 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 = 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 - { - if (treeView == null) - { - var difficultyToolbox:Null = 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 { @@ -5582,7 +5894,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 { @@ -5644,6 +5956,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState @:privateAccess ChartEditorNoteSprite.noteFrameCollection = null; + + // Stop the music. + if (welcomeMusic != null) welcomeMusic.destroy(); + if (audioInstTrack != null) audioInstTrack.destroy(); + if (audioVocalTrackGroup != null) 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 ea821afa9..bd832fab3 100644 --- a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx @@ -64,6 +64,12 @@ class ChangeStartingBPMCommand implements ChartEditorCommand 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 { return 'Change Starting BPM to ${targetBPM}'; 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..4361f867f --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx @@ -0,0 +1,144 @@ +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; + var events:Array; + + public function new(notes:Array, events:Array) + { + 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 = 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 + { + if (state.currentNoteSelection.length > 0) + { + // Display the "Copied Notes" text. + if (state.txtCopyNotif != null) + { + state.txtCopyNotif.visible = true; + state.txtCopyNotif.text = "Copied " + state.currentNoteSelection.length + " notes 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; + } + }); + } + + // Wiggle the notes. + 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 the events. + 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(); + } + }); + } + } + } + } + + 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; - var previousEventSelection:Array; + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; - public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) - { - 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; - var previousEventSelection:Array; + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; - public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) - { - 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 8331ed397..ed50ad33e 100644 --- a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx @@ -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 9fac8a0c4..f44cb973a 100644 --- a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx @@ -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 0308d8fc8..51aeb5bbc 100644 --- a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx @@ -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 7e40bc49b..257db94b4 100644 --- a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx @@ -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; - var previousEventSelection:Array; + var shouldSelectNotes:Bool; + var shouldSelectEvents:Bool; - public function new(?previousNoteSelection:Array, ?previousEventSelection:Array) + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; + + 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 49b2ba585..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; var events:Array; - public function new(notes:Array, events:Array) + public function new(?notes:Array, ?events:Array) { - this.notes = notes; - this.events = events; + this.notes = notes ?? []; + this.events = events ?? []; } public function execute(state:ChartEditorState):Void @@ -72,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/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx index 4725fd275..35a00e562 100644 --- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx +++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx @@ -13,20 +13,20 @@ class SetItemSelectionCommand implements ChartEditorCommand { var notes:Array; var events:Array; - var previousNoteSelection:Array; - var previousEventSelection:Array; + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; - public function new(notes:Array, events:Array, previousNoteSelection:Array, - previousEventSelection:Array) + public function new(notes:Array, events:Array) { 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; @@ -67,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 79bcd59af..e3dae37cf 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx @@ -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 = 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):Null { 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(); @@ -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,6 +154,7 @@ class ChartEditorEventSprite extends FlxSprite // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING this.kill(); this.visible = false; + updateTooltipPosition(); return null; } else @@ -149,6 +164,8 @@ class ChartEditorEventSprite extends FlxSprite 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; } } @@ -167,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/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx index 598cbb544..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); } /** @@ -114,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, 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; public var eventData:Null; - 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/ChartEditorEventContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx index a79125b21..d848f1435 100644 --- a/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx @@ -25,6 +25,10 @@ class ChartEditorEventContextMenu extends ChartEditorBaseContextMenu 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 index 4bfab27e8..66bf6f3ee 100644 --- a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx @@ -6,6 +6,7 @@ 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")) @@ -31,6 +32,10 @@ class ChartEditorNoteContextMenu extends ChartEditorBaseContextMenu 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/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 = []; 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 = []; + + var vocalContainer:Component; + var dialogCancel:Button; + var dialogNoVocals:Button; + var dialogContinue:Button; + + var charIds:Array; + var instId:String; + var hasClearedVocals:Bool = false; + + public function new(state2:ChartEditorState, charIds:Array, 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 = 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, ?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> = 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> = 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 4df53663c..e1fcd1cb0 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -188,8 +188,9 @@ class ChartEditorAudioHandler state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195; state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; state.audioVisGroup.playerVis.detail = 1; + state.audioVisGroup.playerVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); - state.audioVocalTrackGroup.playerVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffset; return true; case DAD: state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); @@ -199,8 +200,9 @@ class ChartEditorAudioHandler state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195; state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16; state.audioVisGroup.opponentVis.detail = 1; + state.audioVisGroup.opponentVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); - state.audioVocalTrackGroup.opponentVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffset; return true; case OTHER: @@ -221,6 +223,10 @@ class ChartEditorAudioHandler { state.audioVocalTrackGroup.clear(); } + if (state.audioVisGroup != null) + { + state.audioVisGroup.clearAllVis(); + } } /** diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx index b914f4149..c1eea5379 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx @@ -2,6 +2,7 @@ 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; @@ -23,16 +24,33 @@ class ChartEditorContextMenuHandler 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)); diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index 1e1a02974..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 + { + 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 = [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 + { + 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 - { - 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 - { - 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 @@ -699,150 +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 = []; - - var charData:SongCharacterData = state.currentSongMetadata.playData.characters; - - var hasClearedVocals:Bool = false; - - charIdsForVocals.push(charData.player); - charIdsForVocals.push(charData.opponent); - - var dialog:Null = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable); - if (dialog == null) throw 'Could not locate Upload Vocals dialog'; - - var dialogContainer:Null = dialog.findComponent('vocalContainer'); - if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog'; - - var buttonCancel:Null