diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml index cb5e68f61..ad9c06286 100644 --- a/.github/actions/setup-haxeshit/action.yml +++ b/.github/actions/setup-haxeshit/action.yml @@ -3,7 +3,7 @@ description: "sets up haxe shit, using HMM!" runs: using: "composite" steps: - - uses: krdlab/setup-haxe@v1.5.1 + - uses: funkincrew/ci-haxe@v2 with: haxe-version: 4.3.1 - name: Config haxelib diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index 8a58596b9..301cedcef 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -13,11 +13,18 @@ jobs: steps: - name: ensure git cli is installed run: apt update && apt install sudo git -y - - 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@v5 with: submodules: 'recursive' - fetch-depth: 0 - token: ${{ secrets.GH_RO_PAT }} + token: ${{ steps.app_token.outputs.token }} - name: check whether submodules exist run: | git config --global --add safe.directory $GITHUB_WORKSPACE @@ -48,15 +55,24 @@ jobs: 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@v5 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: game build 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 @@ -69,14 +85,21 @@ jobs: if: ${{ needs.check_date.outputs.should_run != 'false'}} runs-on: windows-latest permissions: - contents: write - actions: write + 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@v5 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: | @@ -101,6 +124,50 @@ jobs: butler-key: ${{ secrets.BUTLER_API_KEY }} build-dir: export/release/windows/bin target: win + create-nightly-mac: + needs: check_date + if: ${{ needs.check_date.outputs.should_run != 'false'}} + 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@v5 + 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@v3 + 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 @@ -108,7 +175,7 @@ jobs: # contents: write # actions: write # steps: -# - uses: actions/checkout@v4 +# - uses: funkincrew/ci-checkout@v5 # with: # submodules: 'recursive' # fetch-depth: 0 diff --git a/assets b/assets index d094640f7..7e19c4cfa 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d094640f727a670a348b3579d11af5ff6a2ada3a +Subproject commit 7e19c4cfa7db57178f03ed4a58a9fd4d2b93dea7 diff --git a/hmm.json b/hmm.json index d93ea0658..7e17f105c 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" }, { 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/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 703ab406c..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; @@ -702,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); @@ -752,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 { diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index 98b46c782..b772349bc 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -127,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)) { @@ -150,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) @@ -210,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)) { @@ -232,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)) { @@ -252,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) @@ -266,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) @@ -347,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)) { @@ -370,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) 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 443f742fa..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,7 +108,7 @@ class GameOverSubState extends MusicBeatSubState // // Add a black background to the screen. - var bg = new FlxSprite().makeGraphic(FlxG.width * 2, FlxG.height * 2, 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; @@ -282,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) { @@ -305,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; @@ -328,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 f7fc6e0f4..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) { 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/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/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index e98809ce8..c9f1e4137 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,7 +58,7 @@ 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; @@ -104,6 +106,7 @@ import haxe.ui.components.Label; import haxe.ui.components.Button; import haxe.ui.components.NumberStepper; import haxe.ui.components.Slider; +import haxe.ui.components.VerticalSlider; import haxe.ui.components.TextField; import haxe.ui.containers.dialogs.CollapsibleDialog; import haxe.ui.containers.Frame; @@ -720,6 +723,34 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent; } + /** + * Sound multiplier for vocals and hitsounds on the player's side. + */ + var soundMultiplierPlayer(default, set):Float = 1.0; + + function set_soundMultiplierPlayer(value:Float):Float + { + soundMultiplierPlayer = value; + var vocalTargetVolume:Float = (menubarItemVolumeVocals.value ?? 100.0) / 100.0; + if (audioVocalTrackGroup != null) audioVocalTrackGroup.playerVolume = vocalTargetVolume * soundMultiplierPlayer; + + return soundMultiplierPlayer; + } + + /** + * Sound multiplier for vocals and hitsounds on the opponent's side. + */ + var soundMultiplierOpponent(default, set):Float = 1.0; + + function set_soundMultiplierOpponent(value:Float):Float + { + soundMultiplierOpponent = value; + var vocalTargetVolume:Float = (menubarItemVolumeVocals.value ?? 100.0) / 100.0; + if (audioVocalTrackGroup != null) audioVocalTrackGroup.opponentVolume = vocalTargetVolume * soundMultiplierOpponent; + + return soundMultiplierOpponent; + } + // Auto-save /** @@ -1749,6 +1780,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ 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 */ @@ -1958,7 +2001,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState buildGrid(); buildMeasureTicks(); buildNotePreview(); - buildSelectionBox(); buildAdditionalUI(); populateOpenRecentMenu(); @@ -2214,7 +2256,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; @@ -2230,7 +2272,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState 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:FlxSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); + var playheadSprite:FunkinSprite = new FunkinSprite().makeSolidColor(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; playheadSprite.y = 0; gridPlayhead.add(playheadSprite); @@ -2287,17 +2329,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) @@ -2319,6 +2350,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(); @@ -2557,6 +2601,37 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState performCommand(new SetItemSelectionCommand([], currentSongChartEventData)); } } + + function setupSideSlider(x, y):VerticalSlider + { + var slider = new VerticalSlider(); + slider.allowFocus = false; + slider.x = x; + slider.y = y; + slider.width = NOTE_SELECT_BUTTON_HEIGHT; + slider.height = GRID_SIZE * 4; + slider.pos = slider.max; + slider.tooltip = "Slide to set the volume of sounds on this side."; + slider.zIndex = 110; + slider.styleNames = "sideSlider"; + add(slider); + + return slider; + } + + var sliderY = GRID_INITIAL_Y_POS + 34; + sliderVolumeOpponent = setupSideSlider(GRID_X_POS - 64, sliderY); + sliderVolumePlayer = setupSideSlider(buttonSelectEvent.x + buttonSelectEvent.width, sliderY); + + sliderVolumePlayer.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + soundMultiplierPlayer = volume; + } + + sliderVolumeOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + soundMultiplierOpponent = volume; + } } /** @@ -2797,7 +2872,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemVolumeVocals.onChange = event -> { var volume:Float = event.value.toFloat() / 100.0; - if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume; + if (audioVocalTrackGroup != null) + { + audioVocalTrackGroup.playerVolume = volume * soundMultiplierPlayer; + audioVocalTrackGroup.opponentVolume = volume * soundMultiplierOpponent; + } menubarLabelVolumeVocals.text = 'Voices - ${Std.int(event.value)}%'; } @@ -3366,6 +3445,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) @@ -4653,48 +4733,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(); } } @@ -5196,6 +5238,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState autoSave(true); stopWelcomeMusic(); + stopAudioPlayback(); var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; @@ -5439,7 +5482,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; @@ -5461,6 +5504,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); @@ -5509,8 +5588,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 { @@ -5518,8 +5597,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 @@ -5537,8 +5616,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 { @@ -5546,7 +5625,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); } } @@ -5662,7 +5741,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState audioInstTrack.volume = instTargetVolume; audioInstTrack.onComplete = null; } - if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = vocalTargetVolume; + if (audioVocalTrackGroup != null) + { + audioVocalTrackGroup.playerVolume = vocalTargetVolume * soundMultiplierPlayer; + audioVocalTrackGroup.opponentVolume = vocalTargetVolume * soundMultiplierOpponent; + } } function updateTimeSignature():Void @@ -5678,92 +5761,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ // ================== - /** - * 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 */ @@ -5864,9 +5861,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState switch (noteData.getStrumlineIndex()) { case 0: // Player - if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolume); + if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolume * soundMultiplierPlayer); case 1: // Opponent - if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolume); + if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolume * soundMultiplierOpponent); } } } diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx index 36c6b1d2f..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(); @@ -142,6 +154,7 @@ class ChartEditorEventSprite extends FlxSprite // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING this.kill(); this.visible = false; + updateTooltipPosition(); return null; } else @@ -151,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; } } @@ -169,6 +184,31 @@ class ChartEditorEventSprite extends FlxSprite this.x += origin.x; this.y += origin.y; } + + this.updateTooltipPosition(); + } + + public function updateTooltipPosition():Void + { + // No tooltip for ghost sprites. + if (this.isGhost) return; + + if (this.eventData == null) + { + // Disable the tooltip. + ToolTipManager.instance.unregisterTooltipRegion(this.tooltip); + } + else + { + // Update the position. + this.tooltip.left = this.x; + this.tooltip.top = this.y; + this.tooltip.width = this.width; + this.tooltip.height = this.height; + + // Enable the tooltip. + ToolTipManager.instance.registerTooltipRegion(this.tooltip); + } } /** diff --git a/source/funkin/ui/debug/charting/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/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index 1e1a02974..970f021ac 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -13,12 +13,13 @@ import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.song.Song; -import funkin.play.stage.StageData; +import funkin.data.stage.StageData; import funkin.ui.debug.charting.dialogs.ChartEditorAboutDialog; import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; import funkin.ui.debug.charting.dialogs.ChartEditorCharacterIconSelectorMenu; import funkin.ui.debug.charting.dialogs.ChartEditorUploadChartDialog; import funkin.ui.debug.charting.dialogs.ChartEditorWelcomeDialog; +import funkin.ui.debug.charting.dialogs.ChartEditorUploadVocalsDialog; import funkin.ui.debug.charting.util.ChartEditorDropdowns; import funkin.util.Constants; import funkin.util.DateUtil; @@ -59,11 +60,8 @@ using Lambda; class ChartEditorDialogHandler { // Paths to HaxeUI layout files for each dialog. - static final CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-chart'); static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst'); static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata'); - static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals'); - static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry'); static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts'); static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts-entry'); static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart'); @@ -105,6 +103,56 @@ class ChartEditorDialogHandler return dialog; } + /** + * Builds and opens a dialog letting the user browse for a chart file to open. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null + { + var dialog = ChartEditorUploadChartDialog.build(state, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens a dialog where the user uploads vocals for the current song. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var charData:SongCharacterData = state.currentSongMetadata.playData.characters; + + var hasClearedVocals:Bool = false; + + var charIdsForVocals:Array = [charData.player, charData.opponent]; + + var dialog = ChartEditorUploadVocalsDialog.build(state, charIdsForVocals, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens the dialog for selecting a character. + */ + public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null + { + var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition); + + menu.zIndex = 1000; + + return menu; + } + /** * Builds and opens a dialog letting the user know a backup is available, and prompting them to load it. */ @@ -186,22 +234,6 @@ class ChartEditorDialogHandler return dialog; } - /** - * Builds and opens a dialog letting the user browse for a chart file to open. - * @param state The current chart editor state. - * @param closable Whether the dialog can be closed by the user. - * @return The dialog that was opened. - */ - public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null - { - var dialog = ChartEditorUploadChartDialog.build(state, closable); - - dialog.zIndex = 1000; - state.isHaxeUIDialogOpen = true; - - return dialog; - } - /** * Open the wizard for opening an existing chart from individual files. * @param state @@ -288,15 +320,6 @@ class ChartEditorDialogHandler }; } - public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null - { - var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition); - - menu.zIndex = 1000; - - return menu; - } - public static function openCreateSongWizardBasicOnly(state:ChartEditorState, closable:Bool):Void { // Step 1. Song Metadata @@ -699,150 +722,6 @@ class ChartEditorDialogHandler return dialog; } - /** - * Builds and opens a dialog where the user uploads vocals for the current song. - * @param state The current chart editor state. - * @param closable Whether the dialog can be closed by the user. - * @return The dialog that was opened. - */ - public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog - { - var instId:String = state.currentInstrumentalId; - var charIdsForVocals:Array = []; - - var charData:SongCharacterData = state.currentSongMetadata.playData.characters; - - var hasClearedVocals:Bool = false; - - charIdsForVocals.push(charData.player); - charIdsForVocals.push(charData.opponent); - - var dialog:Null = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable); - if (dialog == null) throw 'Could not locate Upload Vocals dialog'; - - var dialogContainer:Null = dialog.findComponent('vocalContainer'); - if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog'; - - var buttonCancel:Null