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