diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index cdee6dec7..c60cd4083 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -6,7 +6,6 @@ body: - type: markdown attributes: value: "# PLEASE READ THE [CONTRIBUTING GUIDE](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) BEFORE OPENING ISSUES!" - - type: checkboxes attributes: label: Issue Checklist @@ -49,7 +48,7 @@ body: attributes: label: Version description: Which version are you playing on? The game version is in the bottom left corner of the main menu. - placeholder: ex. 0.5.3 + placeholder: ex. 0.6.2 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/charting.yml b/.github/ISSUE_TEMPLATE/charting.yml index e86b73c12..810a3778d 100644 --- a/.github/ISSUE_TEMPLATE/charting.yml +++ b/.github/ISSUE_TEMPLATE/charting.yml @@ -46,7 +46,7 @@ body: attributes: label: Version description: Which version are you playing on? The game version is in the bottom left corner of the main menu. - placeholder: ex. 0.5.3 + placeholder: ex. 0.6.2 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/compiling.yml b/.github/ISSUE_TEMPLATE/compiling.yml index 6dd9e4cf9..a3370e3c5 100644 --- a/.github/ISSUE_TEMPLATE/compiling.yml +++ b/.github/ISSUE_TEMPLATE/compiling.yml @@ -34,7 +34,7 @@ body: attributes: label: Version description: Which version are you compiling? The game version is in the bottom left corner of the main menu or in the project.hxp file. - placeholder: ex. 0.5.3 + placeholder: ex. 0.6.2 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml index f315fcc2c..04fea1d13 100644 --- a/.github/ISSUE_TEMPLATE/crash.yml +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -46,7 +46,7 @@ body: attributes: label: Version description: Which version are you playing on? The game version is in the bottom left corner of the main menu. - placeholder: ex. 0.5.3 + placeholder: ex. 0.6.2 validations: required: true diff --git a/.github/actions/setup-haxe/action.yml b/.github/actions/setup-haxe/action.yml index 602d22649..f6dd14f99 100644 --- a/.github/actions/setup-haxe/action.yml +++ b/.github/actions/setup-haxe/action.yml @@ -30,7 +30,7 @@ runs: echo "TIMER_HAXE=$(date +%s)" >> "$GITHUB_ENV" - name: Install Haxe - uses: funkincrew/ci-haxe@v3.2.6 + uses: funkincrew/ci-haxe@v3.2.7 with: haxe-version: ${{ inputs.haxe }} diff --git a/.github/workflows/build-game.yml b/.github/workflows/build-game.yml index 539a15efc..9d75191f1 100644 --- a/.github/workflows/build-game.yml +++ b/.github/workflows/build-game.yml @@ -118,10 +118,11 @@ jobs: build-game-on-host: strategy: + fail-fast: false matrix: include: - target: windows - runs-on: windows-latest + runs-on: windows - target: macos runs-on: macos runs-on: diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fbeebe9..0b6fecc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ The Pit Stop 2 update! - Added new stickers which appear when exiting Pico songs! - The new sticker system isn't fully available to mods yet, but we're working on it! - New option in the Preferences menu: Strumline Backgrounds! +- Options in the Preferences menu now display an on-screen description when selected. - New song event type in the Chart Editor: Set Health Icon! - This event is now used in Stress (Pico Mix). - The HOME and END keys now jump to the top and bottom of the Freeplay song list, respectively. ([bb974c2](https://github.com/FunkinCrew/Funkin/commit/bb974c264270d10ff503784063e5d77bb352b3f7)) - by @AbnormalPoof in [#4103](https://github.com/FunkinCrew/Funkin/pull/4103) diff --git a/CODESTYLE.md b/CODESTYLE.md index 2b6333258..32723531e 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -14,8 +14,8 @@ Code Quality is handled by the `vshaxe.haxe-checkstyle` extension, which include * `IndentationCharacter` checks what is used to indent, `Indentation` checks how deep the indentation is. * `CommentedOutCode` check is in place because old code should be retrieved via Git history. * TODO items: Enable these one-by-one and fix them to improve the overall code quality. - - Reconfigure `MethodLength` - - Reconfigure `CyclomaticComplexity` + - Re-configure `MethodLength` + - Re-configure `CyclomaticComplexity` - Re-enable `MagicNumber` - Re-configure `NestedControlFlow` - Re-configure `NestedIfDepth` @@ -28,5 +28,5 @@ Prettier automatically handles formatting of JSON files, and can be configured i ### Prettier Notes * Prettier will automatically attempt to place expressions on a single line if they fit, but will keep them multi-line if they are manually made multi-line. - * This means that long singleline objects are automatically expanded, and short multiline objects aren't automatically collapsed. + * This means that long single-line objects are automatically expanded, and short multi-line objects aren't automatically collapsed. * You may want to use regex replacement to manually remove the first newline in short multi-line objects to convince Prettier to collapse them. diff --git a/assets b/assets index 1a865cec8..223c5040d 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 1a865cec81e7ffa62f3df1a9065e055c1a1b7efb +Subproject commit 223c5040d3c20f014ff49f59a009385f6174e036 diff --git a/build/Dockerfile b/build/Dockerfile index 9d06a94e5..2ea9eccea 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -102,7 +102,7 @@ RUN < -Part 2: Issues +Part 2: Issues * [Requirements](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#requirements) @@ -24,8 +24,8 @@ This guide will cover best practices for each type of contribution.
- -Part 3: Pull Requests + +Part 3: Pull Requests * [Choosing a base branch](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#choosing-a-base-branch) @@ -39,6 +39,8 @@ This guide will cover best practices for each type of contribution. * [funkin.assets PRs](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#funkinassets-prs) + * [Charting PRs](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#charting-prs) +
[Closing](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#closing) @@ -54,11 +56,11 @@ This guide will cover best practices for each type of contribution. # Part 2: Issues Issues serve many purposes, from reporting bugs to suggesting new features. -This section provides guidelines to follow when [opening an issue](https://github.com/FunkinCrew/Funkin/issues). +This section provides guidelines to follow when [opening an issue](https://github.com/FunkinCrew/Funkin/issues). ## Requirements Make sure you're playing: -- the latest version of the game (currently v0.5.3) +- the latest version of the game (currently v0.6.3) - without any mods - on [Newgrounds](https://www.newgrounds.com/portal/view/770371) or downloaded from [itch.io](https://ninja-muffin24.itch.io/funkin) @@ -84,7 +86,7 @@ Here's a list of commonly suggested features and the reasons why they won't be a | "Philly Glow" Effect from Psych Engine | https://github.com/FunkinCrew/Funkin/issues/3788#issuecomment-2688966982 | | Importing Charts from Psych Engine (and other mod content) | https://github.com/FunkinCrew/Funkin/issues/2586#issuecomment-2125733327 | | Backwards Compatibility for Modding | https://github.com/FunkinCrew/Funkin/issues/3949#issuecomment-2608391329 | -| Lua Support | https://github.com/FunkinCrew/Funkin/issues/2643#issuecomment-2143718093 | +| Lua Support | https://github.com/FunkinCrew/Funkin/issues/2643#issuecomment-2143718093 | ## Issue Types @@ -122,6 +124,9 @@ Also only report one issue or enhancement at a time! If you have multiple bug re Once you're sure your issue is unique and specific, feel free to submit it. +> [!IMPORTANT] +> **DO NOT CLOSE YOUR ISSUE FOR ANY REASON!** Your issue will be taken care of by a moderator! + **Thank you for opening issues!** # Part 3: Pull Requests @@ -133,6 +138,7 @@ When creating a branch in your fork, base your branch on either the `main` or `d > [!CAUTION] > Avoid using your fork's default branch (`main` in this case) for your PR. This is considered an [anti-pattern](https://jmeridth.com/posts/do-not-issue-pull-requests-from-your-master-branch/) by GitHub themselves! +> Instead, make a separate branch for your additions (ex. `docs/fix-typo` or `minor-bugfix`). Choose the `main` branch if you modify: - Documentation (`.md` files) @@ -165,85 +171,173 @@ This process reapplies your changes on top of the updated branch and cleanly res > This guide does not cover compiling. If you have trouble compiling the game, refer to the [Compilation Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/COMPILING.md). Code-based PRs make changes such as **fixing bugs** or **implementing new features** in the game. + This involves modifying one or several of the repository’s `.hx` files, found within the `source/` folder. ### Codestyle -Before submitting your PR, check that your code follows the [Style Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/style-guide.md). +Before submitting your PR, check that your code follows the [Style Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/style-guide.md). This keeps your code consistent with the rest of the codebase! +### Code comments +Code comments help others understand your changes, so the way you write them is important! Here are some guidelines for writing comments in your code: -- Leave comments only when you believe a piece of code warrants explanation. +- Leave comments only when you believe a piece of code warrants explanation. If a piece of code is self-explanatory, it does not need a comment. - Ensure that your comments provide meaningful insight into the function or purpose of the code. - Write your comments in a clear and concise manner. - Only sign your comments with your name when your changes are complex and may require further explanation. -### Example Comments +### Example code comments #### DO NOT: +Below are examples of what you SHOULD NOT do when writing code comments. ```haxe - /** - * jumps around the song - * works with bpm changes but skipped notes still hurt - * @param sections how many sections to jump, negative = backwards - */ - function changeSection(sections:Int):Void +/** + * jumps around the song + * works with bpm changes but skipped notes still hurt + * @param sections how many sections to jump, negative = backwards + */ +function changeSection(sections:Int):Void +{ + // Pause the music, as you probably guessed + // FlxG.sound.music.pause(); + + // Set the target time in steps, I don’t really get how this works though lol - [GitHub username] + var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections); + var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); + + // Don't go back in time to before the song started, that would probably break a lot of things and cause a bunch of problems! + targetTimeMs = Math.max(0, targetTimeMs); + + if (FlxG.sound.music != null) // If the music is not null, set the time to the target time { - // Pause the music, as you probably guessed - // FlxG.sound.music.pause(); - - // Set the target time in steps, I don’t really get how this works though lol - [GitHub username] - var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections); - var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); - - // Don't go back in time to before the song started, that would probably break a lot of things and cause a whole bunch of problems! - targetTimeMs = Math.max(0, targetTimeMs); - - if (FlxG.sound.music != null) // If the music is not null, set the time to the target time - { - FlxG.sound.music.time = targetTimeMs; - } - - // Handle skipped notes and events and all that jazz - handleSkippedNotes(); - SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition); - // regenNoteData(FlxG.sound.music.time); - - Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); - - // I hate this function - [GitHub username] - resyncVocals(); + FlxG.sound.music.time = targetTimeMs; } + + // Handle skipped notes and events and all that jazz + handleSkippedNotes(); + SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition); + // regenNoteData(FlxG.sound.music.time); + + Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); + + // I hate this function - [GitHub username] + resyncVocals(); +} +``` + +```haxe +// End the song when the music is complete. +FlxG.sound.music.onComplete = function() { + endSong(skipEndingTransition); +}; +// A negative instrumental offset means the song skips the first few milliseconds of the track. +// This just gets added into the startTimestamp behavior so we don't need to do anything extra. +FlxG.sound.music.play(true, Math.max(0, startTimestamp - Conductor.instance.combinedOffset)); +FlxG.sound.music.pitch = playbackRate; + +// Prevent the volume from being wrong. +FlxG.sound.music.volume = 1.0; +// IF fadetween is not null we cancel it +if (FlxG.sound.music.fadeTween != null) FlxG.sound.music.fadeTween.cancel(); + +// Play the vocals +trace('Playing vocals...'); +// Add the vocals +add(vocals); +// Play the vocals for real this time lol +vocals.play(); +// Set the vocals volume +vocals.volume = 1.0; +// Set the vocals pitch +vocals.pitch = playbackRate; +// Set the vocals time to the music time +vocals.time = FlxG.sound.music.time; +// trace('${FlxG.sound.music.time}'); +// trace('${vocals.time}'); +// functionThatWasntHereBeforeThisPRorSomethingIdKLOL(); +/* testsprite = new FlxSprite(0, 0); +testsprite.loadGraphic(Paths.image('test')); +testsprite.screenCenter(); +add(testsprite); */ + +// Me too [GitHub username] I hate this function it gave me pain and suffering - Girlfriend +resyncVocals(); +``` + +```haxe +#if FEATURE_DEBUG_FUNCTIONS +// PAGEUP: who knows what this does +// SHIFT+PAGEUP: There will be dire consequences. +if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2); +// PAGEUP: who knows what this does +// SHIFT+PAGEUP: There will be dire consequences. +if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -20 : -2); +#end ``` #### DO: +Below are examples on what you SHOULD do when writing code comments. ```haxe - /** - * Jumps forward or backward a number of sections in the song. - * Accounts for BPM changes, does not prevent death from skipped notes. - * @param sections The number of sections to jump, negative to go backwards. - */ - function changeSection(sections:Int):Void +/** + * Jumps forward or backward a number of sections in the song. + * Accounts for BPM changes, does not prevent death from skipped notes. + * @param sections The number of sections to jump, negative to go backwards. + */ +function changeSection(sections:Int):Void +{ + var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections); + var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); + + // Don't go back in time to before the song started. + targetTimeMs = Math.max(0, targetTimeMs); + + if (FlxG.sound.music != null) { - var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections); - var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps); - - // Don't go back in time to before the song started. - targetTimeMs = Math.max(0, targetTimeMs); - - if (FlxG.sound.music != null) - { - FlxG.sound.music.time = targetTimeMs; - } - - handleSkippedNotes(); - SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition); - - Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); - - resyncVocals(); + FlxG.sound.music.time = targetTimeMs; } + + handleSkippedNotes(); + SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition); + + Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); + + resyncVocals(); +} +``` + +```haxe +FlxG.sound.music.onComplete = function() { + endSong(skipEndingTransition); +}; +// A negative instrumental offset means the song skips the first few milliseconds of the track. +// This just gets added into the startTimestamp behavior so we don't need to do anything extra. +FlxG.sound.music.play(true, Math.max(0, startTimestamp - Conductor.instance.combinedOffset)); +FlxG.sound.music.pitch = playbackRate; + +// Prevent the volume from being wrong. +FlxG.sound.music.volume = 1.0; +if (FlxG.sound.music.fadeTween != null) FlxG.sound.music.fadeTween.cancel(); + +trace('Playing vocals...'); +add(vocals); +vocals.play(); +vocals.volume = 1.0; +vocals.pitch = playbackRate; +vocals.time = FlxG.sound.music.time; +resyncVocals(); +``` + +```haxe +#if FEATURE_DEBUG_FUNCTIONS +// PAGEUP: Skip forward two sections. +// SHIFT+PAGEUP: Skip forward twenty sections. +if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2); +// PAGEDOWN: Skip backward two section. Doesn't replace notes. +// SHIFT+PAGEDOWN: Skip backward twenty sections. +if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -20 : -2); +#end ``` ## Documentation PRs -Documentation-based PRs make changes such as **fixing typos** or **adding new information** in documentation files. +Documentation-based PRs make changes such as **fixing typos** or **adding new information** in documentation files. This involves modifying one or several of the repository’s `.md` files, found throughout the repository. @@ -297,6 +391,24 @@ If you simultaneously modify files from both repositories, then open two separat Be sure to choose `main` as the base branch for `funkin.assets` PRs, as no `develop` branch exists for that repository. +### Charting PRs +Charting PRs make changes such as **adding/removing notes** or **adjusting the placement of song events**. + +This involves modifying one or several of the `funkin.assets` repository's `.json` chart files, found in the `preload/data/songs/` directory. + +These PRs should only be opened in the `funkin.assets` repository. + +> [!CAUTION] +> **No Major Recharts!** Any PR that makes major chart modifications will be rejected. +> Keep your PRs to small tweaks and fixes. + +Here are some guidelines for opening a Charting PR: +- **Explain the issue.** Which song, variation, difficulty, and section/timestamp is the problem in? Help us understand with screenshots and videos. +- **Show your changes.** How does the chart look with your changes? Provide screenshots and videos here as well. +- **Minimize the diff.** If your changes are very small (e.g. a few notes), do not re-export the chart using the Chart Editor. Instead, manually edit the `.json` chart files to help GitHub display your changes cleanly. + +If your PR is accepted, you will be credited as a GitHub contributor (but not as a charter in the Pause Menu). + # Closing Thank you for reading the Contributing Guide. diff --git a/docs/Funkin' Debug Hotkeys.md b/docs/Funkin' Debug Hotkeys.md index cf85c6c59..9c10f060c 100644 --- a/docs/Funkin' Debug Hotkeys.md +++ b/docs/Funkin' Debug Hotkeys.md @@ -3,6 +3,7 @@ Most of this functionality is only available on debug builds of the game! ## Any State +- `INSERT`: ***MEMORY GARBAGE COLLECTION***: Immediately perform memory garbage collection. - `F2`: ***OVERLAY***: Enables the Flixel debug overlay, which has partial support for scripting. - `F3`: ***SCREENSHOT***: Takes a screenshot of the game and saves it to the local `screenshots` directory. Works outside of debug builds too! - `F4`: ***EJECT***: Forcibly switch state to the Main Menu (with no extra transition). Useful if you're stuck in a level and you need to get out! @@ -11,6 +12,9 @@ Most of this functionality is only available on debug builds of the game! - `CTRL-SHIFT-L`: ***FORCE CRASH***: Immediately crash the game with a detailed crash log and a stack trace. +## Loading State +- `SPACE`: Trace callbacks. + ## **Play State** - `H`: ***HIDE UI***: Makes the user interface invisible. Works in Pause Menu, great for screenshots. - `1`: ***END SONG***: Immediately ends the song and moves to Results Screen on Freeplay, or next song on Story Mode. @@ -19,20 +23,29 @@ Most of this functionality is only available on debug builds of the game! - `9`: NEATO! - `PAGEUP` (MacOS: `Fn-Up`): ***FORWARDS TIME TRAVEL***: Move forward by 2 sections. Hold SHIFT to move forward by 20 sections instead. - `PAGEDOWN` (MacOS: `Fn-Down`): ***BACKWARDS TIME TRAVEL***: Move backward by 2 sections. Hold SHIFT to move backward by 20 sections instead. +- `CHART Hotkey`: ***OPEN CHART EDITOR***: Open the Chart Editor with the current song loaded. +- `STAGE Hotkey`: ***OPEN STAGE EDITOR***: Open the old Stage Editor overlay. ## **Freeplay State** -- `F` (Freeplay Menu) - Move to Favorites -- `Q` (Freeplay Menu) - Back one category -- `E` (Freeplay Menu) - Forward one category +- `P`: ***SWITCH TO PICO/BF***: Switch to Pico or BF (reopens Freeplay as the other character). +- `T`: ***TEST RANK ANIMATION***: Play the Gold Perfect rank animation on the currently selected song. +- `SHIFT-ACCEPT`: ***BOTPLAY MODE***: Start a song with Botplay mode enabled. +- `CTRL-Switch Song`: Play the first unlocked alternate instrumental as the song preview. +- `CTRL-LEFT`/`RIGHT`/`UP`/`DOWN`: ***OFFSET BF DJ IN CARTOON STATE***: While BF DJ's TV animation is playing, offset the DJ by 1 pixel in the opposite direction. `SHIFT` to move 10 pixels, `ALT` to move 0.1 pixels. +- `CTRL-C`: ***TRIGGER DJ CARTOON***: Play the Freeplay DJ's cartoon animation. Press again to return to its regular animation. ## **Title State** -- `Y` - WOAH +- `ESCAPE`: ***QUIT***: Quit the game. +- `D`: COOL +- `UI LEFT`/`UI RIGHT Hotkey`: 2COOL +- `Y`: WOAH ## **Main Menu** - `~`: ***DEBUG***: Opens a menu to access the Chart Editor and other work-in-progress editors. Rebindable in the options menu. +- `SHIFT-ACCEPT`: ***OPEN FREEPLAY AS PICO***: When selecting Freeplay, open the Freeplay menu with the playable character set to Pico. - `CTRL-ALT-SHIFT-W`: ***ALL ACCESS***: Unlocks all songs in Freeplay. Only available on debug builds. - `CTRL-ALT-SHIFT-M`: ***NO MORE ACCESS***: Re-locks all songs in Freeplay except those unlocked by default. Only available on debug builds. - `CTRL-ALT-SHIFT-R`: ***GREAT SCORE?***: Give the user a hypothetical overridden score, and see if we can maintain that golden P rank. Only available on debug builds. - `CTRL-ALT-SHIFT-P`: ***CHARACTER UNLOCK SCREEN***: Forces the Character Select screen to play Pico's unlocking animation. Only available on debug builds. - `CTRL-ALT-SHIFT-N`: ***CHARACTER NOT SEEN***: Marks all characters as not seen and enables BF's new character unlocked animation in Freeplay. Only available on debug builds. -- `CTRL-ALT-SHIFT-E`: ***DUMP SAVE DATA***: Prompts the user to save their save data as a JSON file, so its contents can be viewed. Only available on debug builds. \ No newline at end of file +- `CTRL-ALT-SHIFT-E`: ***DUMP SAVE DATA***: Prompts the user to save their save data as a JSON file, so its contents can be viewed. Only available on debug builds. diff --git a/docs/style-guide.md b/docs/style-guide.md index 4c4e01492..4df4a163d 100644 --- a/docs/style-guide.md +++ b/docs/style-guide.md @@ -4,7 +4,7 @@ This short document is designed to give a run-down on how code should be formatt ## Notes on IDEs and Extensions -The Visual Studio Code IDE is highly recommended, as this repo contains various configuration that works with VSCode extensions to automatically style certain things for you. +The Visual Studio Code IDE is highly recommended, as this repo contains various configurations that work with VSCode extensions to automatically style certain things for you. VSCode is also the only IDE that has any good tools for Haxe so yeah. diff --git a/hmm.json b/hmm.json index ca6b0ae36..c70c74919 100644 --- a/hmm.json +++ b/hmm.json @@ -32,8 +32,8 @@ "name": "flxanimate", "type": "git", "dir": null, - "ref": "713d3de0e566d6cd54cde13711ab3e4f60f59f4b", - "url": "https://github.com/Dot-Stuff/flxanimate" + "ref": "f8842cea9883d6112a2c854cf93fa72856171428", + "url": "https://github.com/FunkinCrew/flxanimate" }, { "name": "format", @@ -143,7 +143,7 @@ "name": "lime", "type": "git", "dir": null, - "ref": "e41e3d428ccdd13a1197b20c353f7c40acb45e96", + "ref": "be81ad7e4e1a92c3482bcc009648a4ac892cfa35", "url": "https://github.com/FunkinCrew/lime" }, { diff --git a/project.hxp b/project.hxp index a0b450736..443d0aec3 100644 --- a/project.hxp +++ b/project.hxp @@ -28,7 +28,7 @@ class Project extends HXProject { * REMEMBER TO CHANGE THIS WHEN THE GAME UPDATES! * You only have to change it here, the rest of the game will query this value. */ - static final VERSION:String = "0.6.2"; + static final VERSION:String = "0.6.3"; /** * The game's name. Used as the default window title. diff --git a/source/funkin/Assets.hx b/source/funkin/Assets.hx index 6b1131691..f77e3ea3e 100644 --- a/source/funkin/Assets.hx +++ b/source/funkin/Assets.hx @@ -6,6 +6,7 @@ import openfl.utils.Future; * A wrapper around `openfl.utils.Assets` which disallows access to the harmful functions. * Later we'll add Funkin-specific caching to this. */ +@:nullSafety class Assets { /** diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index 637a58468..47292a119 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -125,6 +125,9 @@ class InitState extends FlxState // Set the game to a lower frame rate while it is in the background. FlxG.game.focusLostFramerate = 30; + // Makes Flixel use frame times instead of locked movements per frame for things like tweens + FlxG.fixedTimestep = false; + setupFlixelDebug(); // @@ -281,8 +284,7 @@ class InitState extends FlxState title: "Cum Song Erect by Kawai Sprite", songId: "cum", characterId: "pico", - difficultyId: "nightmare", - variationId: "erect", + difficultyId: "hard", isNewHighscore: true, scoreData: { diff --git a/source/funkin/api/newgrounds/NewgroundsClient.hx b/source/funkin/api/newgrounds/NewgroundsClient.hx index 2dff97f80..36ab563ab 100644 --- a/source/funkin/api/newgrounds/NewgroundsClient.hx +++ b/source/funkin/api/newgrounds/NewgroundsClient.hx @@ -149,6 +149,9 @@ class NewgroundsClient Save.instance.ngSessionId = null; } + /** + * @return `true` if the user is logged in to Newgrounds. + */ public function isLoggedIn():Bool { #if FEATURE_NEWGROUNDS diff --git a/source/funkin/api/newgrounds/Referral.hx b/source/funkin/api/newgrounds/Referral.hx new file mode 100644 index 000000000..7fa38a518 --- /dev/null +++ b/source/funkin/api/newgrounds/Referral.hx @@ -0,0 +1,55 @@ +package funkin.api.newgrounds; + +#if FEATURE_NEWGROUNDS +import io.newgrounds.NG; +#end +import funkin.api.newgrounds.NewgroundsClient; +import funkin.util.WindowUtil; + +/** + * Handles referrals to links retrieved from the Newgrounds API. + */ +class Referral +{ + /** + * Opens the referral page with the given name. + * @param referralName The name of the referral. + * @param fallbackUrl The URL to open if the referral fails or is not available. + */ + static function doReferral(referralName:String, fallbackUrl:String):Void + { + #if FEATURE_NEWGROUNDS + if (NewgroundsClient.instance.isLoggedIn()) + { + NG.core?.calls.loader.loadReferral(false) + .addComponentParameter("referral_name", referralName) + .addResponseHandler(response -> { + trace(response); + if (response.success) + { + WindowUtil.openURL(response.result.data.url); + } + else + { + WindowUtil.openURL(fallbackUrl); + } + }) + .send(); + } + else + { + WindowUtil.openURL(fallbackUrl); + } + #else + WindowUtil.openURL(fallbackUrl); + #end + } + + /** + * Opens the merch referral page. + */ + public static function doMerchReferral():Void + { + doReferral("merch_link", Constants.URL_MERCH_FALLBACK); + } +} diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx index a6e962598..b6bf8e019 100644 --- a/source/funkin/data/freeplay/player/PlayerData.hx +++ b/source/funkin/data/freeplay/player/PlayerData.hx @@ -334,7 +334,11 @@ typedef PlayerResultsAnimationData = */ var renderType:String; - var assetPath:String; + @:optional + var assetPath:Null; + + @:optional + var scriptClass:Null; @:optional @:default([0, 0]) diff --git a/source/funkin/effects/FunkTrail.hx b/source/funkin/effects/FunkTrail.hx index 1bf3e69b8..42fe4b227 100644 --- a/source/funkin/effects/FunkTrail.hx +++ b/source/funkin/effects/FunkTrail.hx @@ -10,6 +10,7 @@ import flixel.math.FlxPoint; * An offshoot of FlxTrail, but accomodates the way Funkin * does offsets for characters. example, fixes Spirits trail */ +@:nullSafety class FunkTrail extends FlxTrail { /** diff --git a/source/funkin/effects/RetroCameraFade.hx b/source/funkin/effects/RetroCameraFade.hx index d4c1da5ef..ef65eef4c 100644 --- a/source/funkin/effects/RetroCameraFade.hx +++ b/source/funkin/effects/RetroCameraFade.hx @@ -4,6 +4,7 @@ import flixel.util.FlxTimer; import flixel.FlxCamera; import openfl.filters.ColorMatrixFilter; +@:nullSafety class RetroCameraFade { // im lazy, but we only use this for week 6 diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index a4b26140c..c9b916028 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -251,6 +251,9 @@ class PolymodHandler Polymod.addImportAlias('lime.utils.Assets', funkin.Assets); Polymod.addImportAlias('openfl.utils.Assets', funkin.Assets); + // `funkin.util.FileUtil` has unrestricted access to the file system. + Polymod.addImportAlias('funkin.util.FileUtil', funkin.util.FileUtilSandboxed); + // Add blacklisting for prohibited classes and packages. // `Sys` diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index d31ee0690..14a743abb 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -686,9 +686,6 @@ class PlayState extends MusicBeatSubState funkin.util.plugins.TouchPointerPlugin.enabled = false; #end - // Reduce physics accuracy (who cares!!!) to improve animation quality. - FlxG.fixedTimestep = false; - // This state receives update() even when a substate is active. this.persistentUpdate = true; // This state receives draw calls even when a substate is active. @@ -941,6 +938,9 @@ class PlayState extends MusicBeatSubState // Delete all notes and reset the arrays. regenNoteData(); + // so the song doesn't start too early :D + Conductor.instance.update(-5000, false); + // Reset camera zooming cameraBopIntensity = Constants.DEFAULT_BOP_INTENSITY; hudCameraZoomIntensity = (cameraBopIntensity - 1.0) * 2.0; @@ -949,11 +949,26 @@ class PlayState extends MusicBeatSubState health = Constants.HEALTH_STARTING; songScore = 0; Highscore.tallies.combo = 0; - Countdown.performCountdown(); + // timer for vwoosh + var vwooshTimer = new FlxTimer(); + vwooshTimer.start(0.5, function(t:FlxTimer) { + Conductor.instance.update(startTimestamp - Conductor.instance.combinedOffset, false); + if (playerStrumline.notes.length == 0) playerStrumline.updateNotes(); + if (opponentStrumline.notes.length == 0) opponentStrumline.updateNotes(); + playerStrumline.vwooshInNotes(); + opponentStrumline.vwooshInNotes(); + Countdown.performCountdown(); + }); // Reset the health icons. - currentStage.getBoyfriend().initHealthIcon(false); - currentStage.getDad().initHealthIcon(true); + if (currentStage.getBoyfriend() != null) + { + currentStage.getBoyfriend().initHealthIcon(false); + } + if (currentStage.getDad() != null) + { + currentStage.getDad().initHealthIcon(true); + } needsReset = false; } diff --git a/source/funkin/play/ResultScore.hx b/source/funkin/play/ResultScore.hx index c5713b7b0..31cbb0bc6 100644 --- a/source/funkin/play/ResultScore.hx +++ b/source/funkin/play/ResultScore.hx @@ -19,6 +19,8 @@ class ResultScore extends FlxTypedSpriteGroup var dumbNumb = Std.parseInt(Std.string(val)); var prevNum:ScoreNum; + dumbNumb = Std.int(Math.min(dumbNumb, Math.pow(10, group.members.length) - 1)); + while (dumbNumb > 0) { scoreStart += 1; diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx index a28c99d73..2056601a3 100644 --- a/source/funkin/play/ResultState.hx +++ b/source/funkin/play/ResultState.hx @@ -33,6 +33,7 @@ import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.ui.freeplay.FreeplayState; import funkin.ui.MusicBeatSubState; import funkin.ui.story.StoryMenuState; +import funkin.modding.base.ScriptedFlxAtlasSprite; #if FEATURE_NEWGROUNDS import funkin.api.newgrounds.Medals; #end @@ -199,14 +200,30 @@ class ResultState extends MusicBeatSubState if (Preferences.naughtyness && animData.filter != "naughty" || !Preferences.naughtyness && animData.filter != "safe") continue; } - var animPath:String = Paths.stripLibrary(animData.assetPath); - var animLibrary:String = Paths.getLibrary(animData.assetPath); + var animPath:String = ""; + var animLibrary:String = ""; + + if (animData.assetPath != null) + { + animPath = Paths.stripLibrary(animData.assetPath); + animLibrary = Paths.getLibrary(animData.assetPath); + } var offsets = animData.offsets ?? [0, 0]; switch (animData.renderType) { case 'animateatlas': - var animation:FlxAtlasSprite = new FlxAtlasSprite(offsets[0] + (FullScreenScaleMode.gameCutoutSize.x / 2), offsets[1], - Paths.animateAtlas(animPath, animLibrary)); + @:nullSafety(Off) + var animation:FlxAtlasSprite = null; + + var xPos = offsets[0] + (FullScreenScaleMode.gameCutoutSize.x / 2); + var yPos = offsets[1]; + + if (animData.scriptClass != null) animation = ScriptedFlxAtlasSprite.init(animData.scriptClass, xPos, yPos); + else + animation = new FlxAtlasSprite(xPos, yPos, Paths.animateAtlas(animPath, animLibrary)); + + if (animation == null) continue; + animation.zIndex = animData.zIndex ?? 500; animation.scale.set(animData.scale ?? 1.0, animData.scale ?? 1.0); @@ -235,7 +252,6 @@ class ResultState extends MusicBeatSubState animation.onAnimationComplete.add((_name:String) -> { if (animation != null) { - trace("AHAHAH"); animation.anim.curFrame = animData.loopFrame ?? 0; animation.anim.play(); // unpauses this anim, since it's on PlayOnce! } @@ -513,7 +529,7 @@ class ResultState extends MusicBeatSubState // Only play the tick sound if the number increased. if (clearPercentLerp != clearPercentCounter.curNumber) { - trace('$clearPercentLerp and ${clearPercentCounter.curNumber}'); + // trace('$clearPercentLerp and ${clearPercentCounter.curNumber}'); clearPercentLerp = clearPercentCounter.curNumber; FunkinSound.playOnce(Paths.sound('scrollMenu')); diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx index 777ff5fe8..417e61369 100644 --- a/source/funkin/play/components/TallyCounter.hx +++ b/source/funkin/play/components/TallyCounter.hx @@ -8,6 +8,7 @@ import funkin.util.MathUtil; /** * Numerical counters used next to each judgement in the Results screen. */ +@:nullSafety class TallyCounter extends FlxTypedSpriteGroup { public var curNumber:Float = 0; @@ -17,13 +18,13 @@ class TallyCounter extends FlxTypedSpriteGroup public var align:FlxTextAlign = FlxTextAlign.LEFT; - public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF, align:FlxTextAlign = FlxTextAlign.LEFT) + public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int, align:FlxTextAlign = FlxTextAlign.LEFT) { super(x, y); this.align = align; - this.flavour = flavour; + this.flavour = flavour ?? 0xFFFFFFFF; this.neededNumber = neededNumber; @@ -79,6 +80,7 @@ class TallyCounter extends FlxTypedSpriteGroup } } +@:nullSafety class TallyNumber extends FlxSprite { public function new(x:Float, y:Float, digit:Int) diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx index 4fa0a4052..509bfc3e6 100644 --- a/source/funkin/play/notes/NoteSprite.hx +++ b/source/funkin/play/notes/NoteSprite.hx @@ -104,6 +104,11 @@ class NoteSprite extends FunkinSprite return noteData.length > 0; } + /** + * The Y Offset of the note. + */ + public var yOffset:Float = 0.0; + /** * Set this flag to true when hitting the note to avoid scoring it multiple times. */ diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 43ad29180..3ad683ce7 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -1,5 +1,6 @@ package funkin.play.notes; +import flixel.math.FlxPoint; import flixel.util.FlxSignal.FlxTypedSignal; import flixel.FlxG; import funkin.play.notes.notestyle.NoteStyle; @@ -27,35 +28,46 @@ import flixel.FlxSprite; */ class Strumline extends FlxSpriteGroup { + /** + * The directions of the notes on the strumline, in order. + */ public static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; + + /** + * A magic number for the size of the strumline, in pixels. + */ public static final STRUMLINE_SIZE:Int = 104; + + /** + * The spacing between notes on the strumline, in pixels. + */ public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8; // Positional fixes for new strumline graphics. - static final INITIAL_OFFSET = -0.275 * STRUMLINE_SIZE; + static final INITIAL_OFFSET:Float = -0.275 * STRUMLINE_SIZE; static final NUDGE:Float = 2.0; static final KEY_COUNT:Int = 4; static final NOTE_SPLASH_CAP:Int = 6; - static var RENDER_DISTANCE_MS(get, never):Float; + var renderDistanceMs(get, never):Float; /** * The custom render distance for the strumline. * This should be in miliseconds only! Not pixels. */ - public static var CUSTOM_RENDER_DISTANCE_MS:Float = 0.0; + public var customRenderDistanceMs:Float = 0.0; /** * Whether to use the custom render distance. * If false, the render distance will be calculated based on the screen height. */ - public static var USE_CUSTOM_RENDER_DISTANCE:Bool = false; + public var useCustomRenderDistance:Bool = false; - static function get_RENDER_DISTANCE_MS():Float + function get_renderDistanceMs():Float { - if (USE_CUSTOM_RENDER_DISTANCE) return CUSTOM_RENDER_DISTANCE_MS; - return FlxG.height / Constants.PIXELS_PER_MS; + if (useCustomRenderDistance) return customRenderDistanceMs; + return FlxG.height / Constants.PIXELS_PER_MS / scrollSpeed; } /** @@ -80,6 +92,9 @@ class Strumline extends FlxSpriteGroup // Used in-game to control the scroll speed within a song public var scrollSpeed:Float = 1.0; + /** + * Reset the scroll speed to the current chart's scroll speed. + */ public function resetScrollSpeed():Void { scrollSpeed = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0; @@ -110,8 +125,16 @@ class Strumline extends FlxSpriteGroup */ public var notes:FlxTypedSpriteGroup; + /** + * The hold notes currently being rendered on the strumline. + * This group iterates over this every frame to update hold note positions. + * The PlayState also iterates over this to calculate user inputs. + */ public var holdNotes:FlxTypedSpriteGroup; + /** + * A signal that is dispatched when a note is spawned and heading towards the strumline. + */ public var onNoteIncoming:FlxTypedSignalVoid>; var background:FunkinSprite; @@ -250,6 +273,9 @@ class Strumline extends FlxSpriteGroup return value; } + /** + * Refresh the strumline, sorting its children by z-index. + */ public function refresh():Void { sort(SortUtil.byZIndex, FlxSort.ASCENDING); @@ -275,7 +301,7 @@ class Strumline extends FlxSpriteGroup #if FEATURE_GHOST_TAPPING /** - * Returns `true` if no notes are in range of the strumline and the player can spam without penalty. + * @return `true` if no notes are in range of the strumline and the player can spam without penalty. */ public function mayGhostTap():Bool { @@ -320,31 +346,41 @@ class Strumline extends FlxSpriteGroup }); } - public function getNoteSprite(noteData:SongNoteData):NoteSprite + /** + * Get a note sprite corresponding to the given note data. + * @param target The note data for the note sprite. + * @return The note sprite. + */ + public function getNoteSprite(target:SongNoteData):NoteSprite { - if (noteData == null) return null; + if (target == null) return null; for (note in notes.members) { if (note == null) continue; if (note.alive) continue; - if (note.noteData == noteData) return note; + if (note.noteData == target) return note; } return null; } - public function getHoldNoteSprite(noteData:SongNoteData):SustainTrail + /** + * Get a hold note sprite corresponding to the given note data. + * @param target The note data for the hold note. + * @return The hold note sprite. + */ + public function getHoldNoteSprite(target:SongNoteData):SustainTrail { - if (noteData == null || ((noteData.length ?? 0.0) <= 0.0)) return null; + if (target == null || ((target.length ?? 0.0) <= 0.0)) return null; for (holdNote in holdNotes.members) { if (holdNote == null) continue; if (holdNote.alive) continue; - if (holdNote.noteData == noteData) return holdNote; + if (holdNote.noteData == target) return holdNote; } return null; @@ -355,6 +391,8 @@ class Strumline extends FlxSpriteGroup */ public function vwooshNotes():Void { + var vwooshTime:Float = 0.5; + for (note in notes.members) { if (note == null) continue; @@ -365,7 +403,7 @@ class Strumline extends FlxSpriteGroup var targetY:Float = FlxG.height + note.y; if (isDownscroll) targetY = 0 - note.height; - FlxTween.tween(note, {y: targetY}, 0.5, + FlxTween.tween(note, {y: targetY}, vwooshTime, { ease: FlxEase.expoIn, onComplete: function(twn) { @@ -386,7 +424,7 @@ class Strumline extends FlxSpriteGroup var targetY:Float = FlxG.height + holdNote.y; if (isDownscroll) targetY = 0 - holdNote.height; - FlxTween.tween(holdNote, {y: targetY}, 0.5, + FlxTween.tween(holdNote, {y: targetY}, vwooshTime, { ease: FlxEase.expoIn, onComplete: function(twn) { @@ -444,19 +482,64 @@ class Strumline extends FlxSpriteGroup * NOTE: Assumes Conductor and PlayState are both initialized. * @param strumTime * @return Float + * Reverse of vwooshNotes, we bring the notes IN (by their offsets) */ - public function calculateNoteYPos(strumTime:Float, vwoosh:Bool = true):Float + public function vwooshInNotes():Void { - // Make the note move faster visually as it moves offscreen. - // var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0; - // ^^^ commented this out... do NOT make it move faster as it moves offscreen! - var vwoosh:Float = 1.0; + var vwooshTime:Float = 0.5; - return - Constants.PIXELS_PER_MS * (conductorInUse.songPosition - strumTime - Conductor.instance.inputOffset) * scrollSpeed * vwoosh * (isDownscroll ? 1 : -1); + for (note in notes.members) + { + if (note == null) continue; + if (!note.alive) continue; + + note.yOffset = 200; + if (Preferences.downscroll) + { + note.yOffset = -200; + } + FlxTween.tween(note, {yOffset: 0}, vwooshTime, + { + ease: FlxEase.expoOut, + onComplete: function(twn) { + note.yOffset = 0; + } + }); + } + + for (holdNote in holdNotes.members) + { + if (holdNote == null) continue; + if (!holdNote.alive) continue; + + holdNote.yOffset = 200; + if (Preferences.downscroll) + { + holdNote.yOffset = -200; + } + FlxTween.tween(holdNote, {yOffset: 0}, vwooshTime, + { + ease: FlxEase.expoOut, + onComplete: function(twn) { + holdNote.yOffset = 0; + } + }); + } } - function updateNotes():Void + /** + * For a note's strumTime, calculate its Y position relative to the strumline. + * NOTE: Assumes Conductor and PlayState are both initialized. + * @param strumTime The strumtime of the note. + * @return The Y position of the note. + */ + public function calculateNoteYPos(strumTime:Float):Float + { + return + Constants.PIXELS_PER_MS * (conductorInUse.songPosition - strumTime - Conductor.instance.inputOffset) * scrollSpeed * (Preferences.downscroll ? 1 : -1); + } + + public function updateNotes():Void { if (noteData.length == 0) return; @@ -467,7 +550,7 @@ class Strumline extends FlxSpriteGroup var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0; var hitWindowStart:Float = conductorInUse.songPosition - Constants.HIT_WINDOW_MS; - var renderWindowStart:Float = conductorInUse.songPosition + RENDER_DISTANCE_MS; + var renderWindowStart:Float = conductorInUse.songPosition + renderDistanceMs; for (noteIndex in nextNoteIndex...noteData.length) { @@ -482,7 +565,7 @@ class Strumline extends FlxSpriteGroup } if (note.time > renderWindowStart) break; // Note is too far ahead to render - var noteSprite = buildNoteSprite(note); + var noteSprite:NoteSprite = buildNoteSprite(note); if (note.length > 0) { @@ -499,12 +582,11 @@ class Strumline extends FlxSpriteGroup { if (note == null || !note.alive) continue; - var vwoosh:Bool = note.holdNoteSprite == null; // Set the note's position. - if (!customPositionData) note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime, vwoosh); + if (!customPositionData) note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime) + note.yOffset; // If the note is miss - var isOffscreen = isDownscroll ? note.y > FlxG.height : note.y < -note.height; + var isOffscreen:Bool = isDownscroll ? note.y > FlxG.height : note.y < -note.height; if (note.handledMiss && isOffscreen) { killNote(note); @@ -528,7 +610,8 @@ class Strumline extends FlxSpriteGroup } } - var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8; + final magicNumberIGuess:Float = 8; + var renderWindowEnd:Float = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + (renderDistanceMs / magicNumberIGuess); if (holdNote.missedNote && conductorInUse.songPosition >= renderWindowEnd) { @@ -578,17 +661,20 @@ class Strumline extends FlxSpriteGroup var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Constants.PIXELS_PER_MS; - var vwoosh:Bool = false; - if (!customPositionData) { if (isDownscroll) { - holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2; + holdNote.y = this.y + - INITIAL_OFFSET + + calculateNoteYPos(holdNote.strumTime) + - holdNote.height + + STRUMLINE_SIZE / 2 + + holdNote.yOffset; } else { - holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2; + holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + yOffset + STRUMLINE_SIZE / 2 + holdNote.yOffset; } } @@ -631,17 +717,21 @@ class Strumline extends FlxSpriteGroup { // Hold note is new, render it normally. holdNote.visible = true; - var vwoosh:Bool = false; if (!customPositionData) { if (isDownscroll) { - holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2; + holdNote.y = this.y + - INITIAL_OFFSET + + calculateNoteYPos(holdNote.strumTime) + - holdNote.height + + STRUMLINE_SIZE / 2 + + holdNote.yOffset; } else { - holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + STRUMLINE_SIZE / 2; + holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + STRUMLINE_SIZE / 2 + holdNote.yOffset; } } } @@ -696,6 +786,9 @@ class Strumline extends FlxSpriteGroup nextNoteIndex = 0; } + /** + * Called on each beat of the song. + */ public function onBeatHit():Void { if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); @@ -703,16 +796,29 @@ class Strumline extends FlxSpriteGroup if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING)); } + /** + * Called when a key is pressed. + * @param dir The direction of the key that was pressed. + */ public function pressKey(dir:NoteDirection):Void { heldKeys[dir] = true; } + /** + * Called when a key is released. + * @param dir The direction of the key that was released. + */ public function releaseKey(dir:NoteDirection):Void { heldKeys[dir] = false; } + /** + * Check if a key is held down. + * @param dir The direction of the key to check. + * @return `true` if the key is held down, `false` otherwise. + */ public function isKeyHeld(dir:NoteDirection):Bool { return heldKeys[dir]; @@ -762,6 +868,11 @@ class Strumline extends FlxSpriteGroup #end } + /** + * Apply note data from a chart to this strumline. + * Note data should be valid and apply only to this strumline. + * @param data The note data to apply. + */ public function applyNoteData(data:Array):Void { this.notes.clear(); @@ -774,6 +885,7 @@ class Strumline extends FlxSpriteGroup } /** + * Hit a note. * @param note The note to hit. * @param removeNote True to remove the note immediately, false to make it transparent and let it move offscreen. */ @@ -805,6 +917,10 @@ class Strumline extends FlxSpriteGroup #end } + /** + * Kill a note heading towards the strumline. + * @param note The note to kill. Gets recycled and reused for performance. + */ public function killNote(note:NoteSprite):Void { if (note == null) return; @@ -819,16 +935,30 @@ class Strumline extends FlxSpriteGroup } } + /** + * Get a strumline note sprite by its index. + * @param index The index of the note to get. + * @return The note. + */ public function getByIndex(index:Int):StrumlineNote { return this.strumlineNotes.members[index]; } + /** + * Get a strumline note sprite by its direction. + * @param direction The direction of the note to get. + * @return The note. + */ public function getByDirection(direction:NoteDirection):StrumlineNote { return getByIndex(DIRECTIONS.indexOf(direction)); } + /** + * Play a static animation for a given direction. + * @param direction The direction of the note to play the static animation for. + */ public function playStatic(direction:NoteDirection):Void { getByDirection(direction).playStatic(); @@ -837,26 +967,47 @@ class Strumline extends FlxSpriteGroup if (isPlayer) noteVibrations.noteStatuses[direction] = NoteStatus.isReleased; } + /** + * Play a press animation for a given direction. + * @param direction The direction of the note to play the press animation for. + */ public function playPress(direction:NoteDirection):Void { getByDirection(direction).playPress(); } + /** + * Play a confirm animation for a given direction. + * @param direction The direction of the note to play the confirm animation for. + */ public function playConfirm(direction:NoteDirection):Void { getByDirection(direction).playConfirm(); } + /** + * Play a confirm animation for a hold note. + * @param direction The direction of the note to play the confirm animation for. + */ public function holdConfirm(direction:NoteDirection):Void { getByDirection(direction).holdConfirm(); } + /** + * Check if a given direction is playing the confirm animation. + * @param direction The direction of the note to check. + * @return `true` if the note is playing the confirm animation, `false` otherwise. + */ public function isConfirm(direction:NoteDirection):Bool { return getByDirection(direction).isConfirm(); } + /** + * Play a note splash for a given direction. + * @param direction The direction of the note to play the splash animation for. + */ public function playNoteSplash(direction:NoteDirection):Void { if (!showNotesplash) return; @@ -879,6 +1030,10 @@ class Strumline extends FlxSpriteGroup } } + /** + * Play a note hold cover for a given hold note. + * @param holdNote The hold note to play the cover animation for. + */ public function playNoteHoldCover(holdNote:SustainTrail):Void { if (!showNotesplash) return; @@ -909,6 +1064,11 @@ class Strumline extends FlxSpriteGroup } } + /** + * Build a note sprite for a given note data. + * @param note The note data to build the note sprite for. + * @return The note sprite. Will recycle a note sprite from the pool if available for performance. + */ public function buildNoteSprite(note:SongNoteData):NoteSprite { var noteSprite:NoteSprite = constructNoteSprite(); @@ -938,13 +1098,17 @@ class Strumline extends FlxSpriteGroup noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); noteSprite.x -= (noteSprite.width - Strumline.STRUMLINE_SIZE) / 2; // Center it noteSprite.x -= NUDGE; - // noteSprite.x += INITIAL_OFFSET; noteSprite.y = -9999; } return noteSprite; } + /** + * Build a hold note sprite for a given note data. + * @param note The note data to build the hold note sprite for. + * @return The hold note sprite. Will recycle a hold note sprite from the pool if available for performance. + */ public function buildHoldNoteSprite(note:SongNoteData):SustainTrail { var holdNoteSprite:SustainTrail = constructHoldNoteSprite(); @@ -979,7 +1143,7 @@ class Strumline extends FlxSpriteGroup } /** - * Custom recycling behavior. + * Custom recycling behavior for note splashes. */ function constructNoteSplash():NoteSplash { @@ -1013,7 +1177,7 @@ class Strumline extends FlxSpriteGroup } /** - * Custom recycling behavior. + * Custom recycling behavior for note hold covers. */ function constructNoteHoldCover():NoteHoldCover { @@ -1047,7 +1211,7 @@ class Strumline extends FlxSpriteGroup } /** - * Custom recycling behavior. + * Custom recycling behavior for note sprites. */ function constructNoteSprite():NoteSprite { @@ -1073,7 +1237,7 @@ class Strumline extends FlxSpriteGroup } /** - * Custom recycling behavior. + * Custom recycling behavior for hold note sprites. */ function constructHoldNoteSprite():SustainTrail { @@ -1133,6 +1297,10 @@ class Strumline extends FlxSpriteGroup FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * index)}); } + /** + * Play a fade in animation on all arrows in the strumline. + * Used when starting a song in Freeplay mode. + */ public function fadeInArrows():Void { for (index => arrow in this.strumlineNotes.members.keyValueIterator()) @@ -1141,24 +1309,50 @@ class Strumline extends FlxSpriteGroup } } + /** + * Compare two note data objects by their strumtime. + * @param order The order to sort the notes in. + * @param a The first note data object. + * @param b The second note data object. + * @return The comparison result, based on the time of the notes. + */ function compareNoteData(order:Int, a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(order, a.time, b.time); } + /** + * Compare two note sprites by their strumtime. + * @param order The order to sort the notes in. + * @param a The first note sprite. + * @param b The second note sprite. + * @return The comparison result, based on the time of the notes. + */ function compareNoteSprites(order:Int, a:NoteSprite, b:NoteSprite):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } + /** + * Compare two hold note sprites by their strumtime. + * @param order The order to sort the notes in. + * @param a The first hold note sprite. + * @param b The second hold note sprite. + * @return The comparison result, based on the time of the notes. + */ function compareHoldNoteSprites(order:Int, a:SustainTrail, b:SustainTrail):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } - override function findMinYHelper() + /** + * Find the minimum Y position of the strumline. + * Ignores the background to ensure the strumline is positioned correctly. + * @return The minimum Y position of the strumline. + */ + override function findMinYHelper():Float { - var value = Math.POSITIVE_INFINITY; + var value:Float = Math.POSITIVE_INFINITY; for (member in group.members) { if (member == null) continue; @@ -1166,18 +1360,28 @@ class Strumline extends FlxSpriteGroup if (member == this.background) continue; var minY:Float; - if (member.flixelType == SPRITEGROUP) minY = (cast member : FlxSpriteGroup).findMinY(); + if (member.flixelType == SPRITEGROUP) + { + minY = (cast member : FlxSpriteGroup).findMinY(); + } else + { minY = member.y; + } if (minY < value) value = minY; } return value; } - override function findMaxYHelper() + /** + * Find the maximum Y position of the strumline. + * Ignores the background to ensure the strumline is positioned correctly. + * @return The maximum Y position of the strumline. + */ + override function findMaxYHelper():Float { - var value = Math.NEGATIVE_INFINITY; + var value:Float = Math.NEGATIVE_INFINITY; for (member in group.members) { if (member == null) continue; @@ -1185,9 +1389,14 @@ class Strumline extends FlxSpriteGroup if (member == this.background) continue; var maxY:Float; - if (member.flixelType == SPRITEGROUP) maxY = (cast member : FlxSpriteGroup).findMaxY(); + if (member.flixelType == SPRITEGROUP) + { + maxY = (cast member : FlxSpriteGroup).findMaxY(); + } else + { maxY = member.y + member.height; + } if (maxY > value) value = maxY; } diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx index 03b782485..324234cc4 100644 --- a/source/funkin/play/notes/StrumlineNote.hx +++ b/source/funkin/play/notes/StrumlineNote.hx @@ -26,6 +26,11 @@ class StrumlineNote extends FunkinSprite return this.direction; } + /** + * The Y Offset of the note. + */ + public var yOffset:Float = 0.0; + /** * Set this flag to `true` to disable performance optimizations that cause * the Strumline note sprite to ignore `velocity` and `acceleration`. diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx index 74e97e072..b37735cf6 100644 --- a/source/funkin/play/notes/SustainTrail.hx +++ b/source/funkin/play/notes/SustainTrail.hx @@ -34,6 +34,11 @@ class SustainTrail extends FlxSprite public var cover:NoteHoldCover = null; + /** + * The Y Offset of the note. + */ + public var yOffset:Float = 0.0; + /** * Set to `true` if the user hit the note and is currently holding the sustain. * Should display associated effects. diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx index ddb5079f9..b940191fc 100644 --- a/source/funkin/ui/MusicBeatState.hx +++ b/source/funkin/ui/MusicBeatState.hx @@ -25,6 +25,7 @@ import funkin.play.notes.NoteDirection; * MusicBeatState actually represents the core utility FlxState of the game. * It includes functionality for event handling, as well as maintaining BPM-based update events. */ +@:nullSafety class MusicBeatState extends FlxTransitionableState implements IEventHandler { var controls(get, never):Controls; @@ -32,8 +33,8 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler inline function get_controls():Controls return PlayerSettings.player1.controls; - public var leftWatermarkText:FlxText = null; - public var rightWatermarkText:FlxText = null; + public var leftWatermarkText:Null = null; + public var rightWatermarkText:Null = null; public var conductorInUse(get, set):Conductor; @@ -223,6 +224,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler sort(SortUtil.byZIndex, FlxSort.ASCENDING); } + @:nullSafety(Off) override function startOutro(onComplete:() -> Void):Void { var event = new StateChangeScriptEvent(STATE_CHANGE_BEGIN, null, true); diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx index c32b4976b..f17e4e1d8 100644 --- a/source/funkin/ui/MusicBeatSubState.hx +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -23,10 +23,11 @@ import funkin.play.notes.NoteDirection; /** * MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState. */ +@:nullSafety class MusicBeatSubState extends FlxSubState implements IEventHandler { - public var leftWatermarkText:FlxText = null; - public var rightWatermarkText:FlxText = null; + public var leftWatermarkText:Null = null; + public var rightWatermarkText:Null = null; public var conductorInUse(get, set):Conductor; @@ -245,6 +246,7 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler this._parentState.openSubState(substate); } + @:nullSafety(Off) override function startOutro(onComplete:() -> Void):Void { var event = new StateChangeScriptEvent(STATE_CHANGE_BEGIN, null, true); diff --git a/source/funkin/ui/PixelatedIcon.hx b/source/funkin/ui/PixelatedIcon.hx index 280ed98d0..8b580ec29 100644 --- a/source/funkin/ui/PixelatedIcon.hx +++ b/source/funkin/ui/PixelatedIcon.hx @@ -6,6 +6,7 @@ import funkin.graphics.FlxFilteredSprite; * The icon that gets used for Freeplay capsules and char select * NOT to be confused with the CharIcon class, which is for the in-game icons */ +@:nullSafety class PixelatedIcon extends FlxFilteredSprite { public function new(x:Float, y:Float) diff --git a/source/funkin/ui/TextMenuList.hx b/source/funkin/ui/TextMenuList.hx index cc1fc408c..11238fbff 100644 --- a/source/funkin/ui/TextMenuList.hx +++ b/source/funkin/ui/TextMenuList.hx @@ -3,6 +3,7 @@ package funkin.ui; import funkin.ui.AtlasText; import funkin.ui.MenuList; +@:nullSafety class TextMenuList extends MenuTypedList { public function new(navControls:NavControls = Vertical, ?wrapMode) @@ -18,6 +19,7 @@ class TextMenuList extends MenuTypedList } } +@:nullSafety class TextMenuItem extends TextTypedMenuItem { public function new(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void) @@ -27,6 +29,7 @@ class TextMenuItem extends TextTypedMenuItem } } +@:nullSafety class TextTypedMenuItem extends MenuTypedItem { public function new(x = 0.0, y = 0.0, label:T, name:String, ?callback:Void->Void) diff --git a/source/funkin/ui/charSelect/CharSelectSubState.hx b/source/funkin/ui/charSelect/CharSelectSubState.hx index a81f75d5e..1a5fd0957 100644 --- a/source/funkin/ui/charSelect/CharSelectSubState.hx +++ b/source/funkin/ui/charSelect/CharSelectSubState.hx @@ -518,7 +518,7 @@ class CharSelectSubState extends MusicBeatSubState grpIcons.scrollFactor.set(); } - function unLock() + function unLock():Void { var index = nonLocks[0]; diff --git a/source/funkin/ui/charSelect/Nametag.hx b/source/funkin/ui/charSelect/Nametag.hx index b6cedb0c7..5bdb0e7e8 100644 --- a/source/funkin/ui/charSelect/Nametag.hx +++ b/source/funkin/ui/charSelect/Nametag.hx @@ -18,9 +18,6 @@ class Nametag extends FlxSprite shader = mosaicShader; switchChar("bf"); - - FlxG.debugger.addTrackerProfile(new TrackerProfile(Nametag, ["midpointX", "midpointY"])); - FlxG.debugger.track(this, "Nametag"); } public function updatePosition():Void @@ -71,16 +68,12 @@ class Nametag extends FlxSprite } } - function setBlockTimer(frame:Int, ?forceX:Float, ?forceY:Float) + function setBlockTimer(frame:Int, ?forceX:Float, ?forceY:Float):Void { - var daX:Float = 10 * FlxG.random.int(1, 4); - var daY:Float = 10 * FlxG.random.int(1, 4); + var daX:Float = forceX ?? 10 * FlxG.random.int(1, 4); + var daY:Float = forceY ?? 10 * FlxG.random.int(1, 4); - if (forceX != null) daX = forceX; - - if (forceY != null) daY = forceY; - - new FlxTimer().start(frame / 30, _ -> { + FlxTimer.wait(frame / 30, () -> { mosaicShader.setBlockSize(daX, daY); }); } diff --git a/source/funkin/ui/debug/anim/DebugBoundingState.hx b/source/funkin/ui/debug/anim/DebugBoundingState.hx index e1f21f5b0..5b8d21cfb 100644 --- a/source/funkin/ui/debug/anim/DebugBoundingState.hx +++ b/source/funkin/ui/debug/anim/DebugBoundingState.hx @@ -528,8 +528,11 @@ class DebugBoundingState extends FlxState trace('Added ${offsetAnimationDropdown.dataSource.size} to HaxeUI dropdown'); offsetAnimationDropdown.onChange = function(event:UIEvent) { - trace('Selected animation ${event?.data?.id}'); - playCharacterAnimation(event.data.id, true); + if (event.data != null) + { + trace('Selected animation ${event.data.id}'); + playCharacterAnimation(event.data.id, true); + } } txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; diff --git a/source/funkin/ui/debug/anim/FlxAnimateTest.hx b/source/funkin/ui/debug/anim/FlxAnimateTest.hx index 44917e2a5..cbf1c4339 100644 --- a/source/funkin/ui/debug/anim/FlxAnimateTest.hx +++ b/source/funkin/ui/debug/anim/FlxAnimateTest.hx @@ -22,9 +22,9 @@ class FlxAnimateTest extends MusicBeatState { super.create(); - sprite = new FlxAtlasSprite(0, 0, 'assets/images/charSelect/maskTest'); + sprite = new FlxAtlasSprite(-100, 0, 'week3:assets/week3/images/philly/erect/picoFlowers'); add(sprite); - sprite.playAnimation(null, false, false, true); + sprite.playAnimation(sprite.anim.stageInstance.symbol.name, false, false, true); } public override function update(elapsed:Float):Void diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 22845155f..d05171f91 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -2361,7 +2361,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } } - if (!FileUtil.doesFileExist(chartPath)) + if (!FileUtil.fileExists(chartPath)) { trace('Previously loaded chart file (${chartPath.toString()}) does not exist, disabling link...'); menuItemRecentChart.disabled = true; @@ -5118,6 +5118,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (songPos < 0) songPosMinutes = '-' + songPosMinutes; var songPosString:String = '${songPosMinutes}:${songPosSeconds}.${songPosMilliseconds}'; + var roundedBeat = FlxMath.roundDecimal(Conductor.instance.currentBeatTime, 2); + var parts:Array = Std.string(roundedBeat).split('.'); + if (parts.length == 1) parts.push('00'); + else if (parts[1].length < 2) parts[1] += '0'; + songPosString += ' | Beat: ${parts.join('.')} | Step: ${Conductor.instance.currentStep}'; + if (playbarSongPos.value != songPosString) playbarSongPos.value = songPosString; var songRemaining:Float = Math.max(songLengthInMs - songPos, 0.0); diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx index 5de5fb6d2..8d5e8b620 100644 --- a/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx @@ -106,7 +106,7 @@ class ChartEditorWelcomeDialog extends ChartEditorBaseDialog } } - if (!FileUtil.doesFileExist(chartPath)) + if (!FileUtil.fileExists(chartPath)) { trace('Previously loaded chart file (${chartPath}) does not exist, disabling link...'); linkRecentChart.disabled = true; diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx index c2cfa92c9..1bdfc2272 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -55,6 +55,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox } var eventType:String = event.data.id; + var sameEvent:Bool = (eventType == chartEditorState.eventKindToPlace); trace('ChartEditorEventDataToolbox - Event type changed: $eventType'); @@ -69,6 +70,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox return; } + if (!sameEvent) chartEditorState.eventDataToPlace = {}; buildEventDataFormFromSchema(toolboxEventsDataGrid, schema, chartEditorState.eventKindToPlace); if (!_initializing && chartEditorState.currentEventSelection.length > 0) @@ -84,15 +86,21 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox chartEditorState.notePreviewDirty = true; } } + toolboxEventsEventKind.pauseEvent(UIEvent.CHANGE, true); + var startingEventValue = ChartEditorDropdowns.populateDropdownWithSongEvents(toolboxEventsEventKind, chartEditorState.eventKindToPlace); trace('ChartEditorEventDataToolbox - Starting event kind: ${startingEventValue}'); toolboxEventsEventKind.value = startingEventValue; + + toolboxEventsEventKind.resumeEvent(UIEvent.CHANGE, true, true); } public override function refresh():Void { super.refresh(); + toolboxEventsEventKind.pauseEvent(UIEvent.CHANGE, true); + var newDropdownElement = ChartEditorDropdowns.findDropdownElement(chartEditorState.eventKindToPlace, toolboxEventsEventKind); if (newDropdownElement == null) @@ -151,6 +159,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox } } } + + toolboxEventsEventKind.resumeEvent(UIEvent.CHANGE, true, true); } var lastEventKind:String = 'unknown'; @@ -165,8 +175,6 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox // Clear the frame. target.removeAllComponents(); - chartEditorState.eventDataToPlace = {}; - for (field in schema) { if (field == null) continue; @@ -259,7 +267,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox var value = event.target.value; if (field.type == ENUM) { - value = event.target.value.value; + var drp:DropDown = cast event.target; + value = drp.selectedItem?.value ?? field.defaultValue; } else if (field.type == BOOL) { diff --git a/source/funkin/ui/debug/stageeditor/StageEditorState.hx b/source/funkin/ui/debug/stageeditor/StageEditorState.hx index e66245b24..aaafc1a52 100644 --- a/source/funkin/ui/debug/stageeditor/StageEditorState.hx +++ b/source/funkin/ui/debug/stageeditor/StageEditorState.hx @@ -1073,7 +1073,7 @@ class StageEditorState extends UIState var filePath = new haxe.io.Path(file); var item = new MenuItem(); item.text = filePath.file + "." + filePath.ext; - item.disabled = !FileUtil.doesFileExist(file); + item.disabled = !FileUtil.fileExists(file); var load = function(file:String) { currentFile = file; diff --git a/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx b/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx index 6c3041e28..5ab4029ef 100644 --- a/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx +++ b/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx @@ -32,7 +32,7 @@ class BackupAvailableDialog extends Dialog { super(); - if (!FileUtil.doesFileExist(filePath)) return; + if (!FileUtil.fileExists(filePath)) return; // time text var fileDate = Path.withoutExtension(Path.withoutDirectory(filePath)); @@ -62,7 +62,7 @@ class BackupAvailableDialog extends Dialog } buttonOpenBackup.onClick = function(_) { - if (FileUtil.doesFileExist(filePath) && state.welcomeDialog != null) // doing a check in case a sleezy FUCK decides to delete the backup file AFTER dialog opens + if (FileUtil.fileExists(filePath) && state.welcomeDialog != null) // doing a check in case a sleezy FUCK decides to delete the backup file AFTER dialog opens { state.welcomeDialog.loadFromFilePath(filePath); } diff --git a/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx index 5cc07fbe9..f1ef14397 100644 --- a/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx +++ b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx @@ -35,7 +35,7 @@ class WelcomeDialog extends Dialog { trace(file); - if (!FileUtil.doesFileExist(file)) continue; // whats the point of loading something that doesnt exist + if (!FileUtil.fileExists(file)) continue; // whats the point of loading something that doesnt exist var patj = new haxe.io.Path(file); diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx index 73d4489ce..ee430b259 100644 --- a/source/funkin/ui/freeplay/AlbumRoll.hx +++ b/source/funkin/ui/freeplay/AlbumRoll.hx @@ -91,20 +91,16 @@ class AlbumRoll extends FlxSpriteGroup if (albumId == null) { this.visible = false; - difficultyStars.stars.visible = false; return; } else - { this.visible = true; - } albumData = AlbumRegistry.instance.fetchEntry(albumId); if (albumData == null) { FlxG.log.warn('Could not find album data for album ID: ${albumId}'); - return; }; @@ -113,9 +109,7 @@ class AlbumRoll extends FlxSpriteGroup newAlbumArt.replaceFrameGraphic(0, albumGraphic); buildAlbumTitle(albumData.getAlbumTitleAssetKey(), albumData.getAlbumTitleOffsets()); - applyExitMovers(); - refresh(); } diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx index 09206bf40..3f0813bef 100644 --- a/source/funkin/ui/freeplay/FreeplayScore.hx +++ b/source/funkin/ui/freeplay/FreeplayScore.hx @@ -14,6 +14,8 @@ class FreeplayScore extends FlxTypedSpriteGroup var dumbNumb = Std.parseInt(Std.string(val)); var prevNum:ScoreNum; + dumbNumb = Std.int(Math.min(dumbNumb, Math.pow(10, group.members.length) - 1)); + while (dumbNumb > 0) { group.members[loopNum].digit = dumbNumb % 10; diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index d38c8c27f..baec2a639 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -236,6 +236,7 @@ class FreeplayState extends MusicBeatSubState currentCharacter = fetchPlayableCharacter(); currentVariation = rememberedVariation; + currentDifficulty = rememberedDifficulty; styleData = FreeplayStyleRegistry.instance.fetchEntry(currentCharacter.getFreeplayStyleID()); rememberedCharacterId = currentCharacter?.id ?? Constants.DEFAULT_CHARACTER; fromCharSelect = params?.fromCharSelect ?? false; @@ -798,7 +799,6 @@ class FreeplayState extends MusicBeatSubState // Gets all available difficulties for our character, via our available variations var difficultiesAvailable:Array = song.data.listDifficulties(null, characterVariations); - return difficultiesAvailable.contains(currentDifficulty); }); diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index 8371d0df0..2f6911b67 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -24,11 +24,12 @@ import funkin.ui.story.StoryMenuState; import funkin.ui.Prompt; import funkin.util.WindowUtil; import funkin.util.TouchUtil; +import funkin.api.newgrounds.Referral; #if FEATURE_DISCORD_RPC import funkin.api.discord.DiscordClient; #end #if FEATURE_NEWGROUNDS -import io.newgrounds.NG; +import funkin.api.newgrounds.NewgroundsClient; #end class MainMenuState extends MusicBeatState @@ -181,9 +182,9 @@ class MainMenuState extends MusicBeatState this.leftWatermarkText.text = Constants.VERSION; #if FEATURE_NEWGROUNDS - if (NG.core?.loggedIn) + if (NewgroundsClient.instance.isLoggedIn()) { - this.leftWatermarkText.text += ' | Newgrounds: Logged in as ${NG.core?.user?.name}'; + this.leftWatermarkText.text += ' | Newgrounds: Logged in as ${NewgroundsClient.instance.user?.name}'; } #end @@ -252,18 +253,7 @@ class MainMenuState extends MusicBeatState function selectMerch() { - #if FEATURE_NEWGROUNDS - NG.core?.calls.loader.loadReferral(false) - .addComponentParameter("referral_name", "merch_link") - .addResponseHandler(response -> { - if (response.success) WindowUtil.openURL(response.result.data.url) - else - WindowUtil.openURL(Constants.URL_MERCH_FALLBACK); - }) - .send(); - #else - WindowUtil.openURL(Constants.URL_MERCH_FALLBACK); - #end + Referral.doMerchReferral(); } #end diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx index 5b053bb08..cf9109bcf 100644 --- a/source/funkin/util/FileUtil.hx +++ b/source/funkin/util/FileUtil.hx @@ -13,9 +13,12 @@ import haxe.ui.containers.dialogs.Dialogs; import haxe.ui.containers.dialogs.Dialogs.SelectedFileInfo; import haxe.ui.containers.dialogs.Dialogs.FileDialogExtensionInfo; +using StringTools; + /** * Utilities for reading and writing files on various platforms. */ +@:nullSafety class FileUtil { public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc"); @@ -46,6 +49,43 @@ class FileUtil label: 'Friday Night Funkin\' Stage', }; + /** + * Paths which should not be deleted or modified by scripts. + */ + private static final PROTECTED_PATHS:Array = [ + '', + 'assets', + 'manifest', + 'manifest/*', + 'plugins', + 'plugins/*', + 'Funkin.exe', + 'Funkin', + 'libvlc.dll', + 'libvlccore.dll', + 'lime.ndll' + ]; + + /** + * Invalid filesystem characters. + */ + private static final INVALID_CHARS:Array = [':', '*', '?', '"', '<', '>', '|']; + + #if sys + private static var _gameDirectory:Null = null; + public static var gameDirectory(get, never):String; + + public static function get_gameDirectory():String + { + if (_gameDirectory != null) + { + return _gameDirectory; + } + + return _gameDirectory = Path.directory(Path.normalize(sys.FileSystem.fullPath(Sys.programPath()))); + } + #end + /** * Browses for a single file, then calls `onSelect(fileInfo)` when a file is selected. * Powered by HaxeUI, so it works on all platforms. @@ -55,15 +95,15 @@ class FileUtil * @param onSelect A callback that provides a `SelectedFileInfo` object when a file is selected. * @param onCancel A callback that is called when the user closes the dialog without selecting a file. */ - public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, ?onSelect:SelectedFileInfo->Void, - ?onCancel:Void->Void) + public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo) -> Void, + ?onCancel:() -> Void) { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } - else + else if (onCancel != null) { onCancel(); } @@ -74,7 +114,7 @@ class FileUtil readContents: true, readAsBinary: true, // Binary multiple: false, - extensions: typeFilter ?? [], + extensions: typeFilter ?? new Array(), title: dialogTitle, }); } @@ -88,15 +128,15 @@ class FileUtil * @param onSelect A callback that provides a `SelectedFileInfo` object when a file is selected. * @param onCancel A callback that is called when the user closes the dialog without selecting a file. */ - public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, ?onSelect:SelectedFileInfo->Void, - ?onCancel:Void->Void) + public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo) -> Void, + ?onCancel:() -> Void):Void { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } - else + else if (onCancel != null) { onCancel(); } @@ -107,7 +147,7 @@ class FileUtil readContents: true, readAsBinary: false, // Text multiple: false, - extensions: typeFilter ?? [], + extensions: typeFilter ?? new Array(), title: dialogTitle, }); } @@ -119,21 +159,29 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function browseForDirectory(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForDirectory(?typeFilter:Array, onSelect:(String) -> Void, ?onCancel:() -> Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelect.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForDirectory not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -144,21 +192,29 @@ class FileUtil * * @return Whether the file dialog was opened successfully. */ - public static function browseForMultipleFiles(?typeFilter:Array, ?onSelect:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelectMultiple.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelectMultiple.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -170,21 +226,29 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function browseForSaveFile(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForSaveFile(?typeFilter:Array, onSelect:(String) -> Void, ?onCancel:() -> Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelect.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(SAVE, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForSaveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -195,25 +259,49 @@ class FileUtil * * @return Whether the file dialog was opened successfully. */ - public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, + public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String) -> Void, ?onCancel:() -> Void, ?defaultFileName:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSave != null) fileDialog.onSave.add(onSave); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + if (onSave != null) + { + fileDialog.onSave.add(onSave); + } + + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.save(data, filter, defaultFileName, dialogTitle); + return true; #elseif html5 - var filter:String = defaultFileName != null ? Path.extension(defaultFileName) : null; + var filter:Null = defaultFileName != null ? Path.extension(defaultFileName) : null; var fileDialog:FileDialog = new FileDialog(); - if (onSave != null) fileDialog.onSave.add(onSave); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + if (onSave != null) + { + fileDialog.onSave.add(onSave); + } + + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.save(data, filter, defaultFileName, dialogTitle); + return true; #else - onCancel(); + trace('WARNING: saveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -226,47 +314,72 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function saveMultipleFiles(resources:Array, ?onSaveAll:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function saveMultipleFiles(resources:Array, ?onSaveAll:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, force:Bool = false):Bool { #if desktop // Prompt the user for a directory, then write all of the files to there. - var onSelectDir:String->Void = function(targetPath:String):Void { - var paths:Array = []; + var onSelectDir:(String) -> Void = function(targetPath:String):Void { + var paths:Array = new Array(); for (resource in resources) { - var filePath = haxe.io.Path.join([targetPath, resource.fileName]); - try - { - if (resource.data == null) + /* + var filePath:String = Path.join([targetPath, resource.fileName]); + try { - trace('WARNING: File $filePath has no data or content. Skipping.'); - continue; + if (resource.data == null) + { + trace('WARNING: File $filePath has no data or content. Skipping.'); + continue; + } + else + { + writeBytesToPath(filePath, resource.data, force ? Force : Skip); + } } - else + catch (e:Dynamic) { - writeBytesToPath(filePath, resource.data, force ? Force : Skip); + throw 'Failed to write file (probably already exists): $filePath'; } - } - catch (_) + */ + if (resource.data == null) { - throw 'Failed to write file (probably already exists): $filePath'; + trace('WARNING: File ${resource.fileName} has no data or content. Skipping.'); + continue; } + + var filePath:String = Path.join([targetPath, resource.fileName]); + paths.push(filePath); } - onSaveAll(paths); + + if (onSaveAll != null) + { + onSaveAll(paths); + } } + trace('Browsing for directory to save individual files to...'); + #if mac defaultPath = null; #end + browseForDirectory(null, onSelectDir, onCancel, defaultPath, 'Choose directory to save all files to...'); + return true; #elseif html5 saveFilesAsZIP(resources, onSaveAll, onCancel, defaultPath, force); + return true; #else - onCancel(); + trace('WARNING: saveMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -274,14 +387,20 @@ class FileUtil /** * Takes an array of file entries and prompts the user to save them as a ZIP file. */ - public static function saveFilesAsZIP(resources:Array, ?onSave:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, force:Bool = false):Bool + public static function saveFilesAsZIP(resources:Array, ?onSave:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); - var onSave:String->Void = function(path:String) { - trace('Saved ${resources.length} files to ZIP at "$path".'); - if (onSave != null) onSave([path]); + var onSave:(String) -> Void = function(path:String) { + trace('Saved ${resources.length} files to ZIP at "$path"'); + + if (onSave != null) + { + onSave([path]); + } }; + // Prompt the user to save the ZIP file. saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...'); return true; @@ -290,14 +409,18 @@ class FileUtil /** * Takes an array of file entries and prompts the user to save them as a FNFC file. */ - public static function saveChartAsFNFC(resources:Array, ?onSave:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function saveChartAsFNFC(resources:Array, ?onSave:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); - var onSave:String->Void = function(path:String) { - trace('Saved FNF file to "$path"'); - if (onSave != null) onSave([path]); + var onSave:(String) -> Void = function(path:String) { + trace('Saved FNFC file to "$path"'); + + if (onSave != null) + { + onSave([path]); + } }; // Prompt the user to save the ZIP file. saveFile(zipBytes, [FILE_FILTER_FNFC], onSave, onCancel, defaultPath, 'Save chart as FNFC...'); @@ -335,8 +458,7 @@ class FileUtil #if sys return sys.io.File.getContent(path); #else - trace('ERROR: readStringFromPath not implemented for this platform'); - return null; + throw 'Direct file reading by path is not supported on this platform.'; #end } @@ -350,19 +472,9 @@ class FileUtil public static function readBytesFromPath(path:String):Bytes { #if sys - if (!doesFileExist(path)) return null; return sys.io.File.getBytes(path); #else - return null; - #end - } - - public static function doesFileExist(path:String):Bool - { - #if sys - return sys.FileSystem.exists(path); - #else - return false; + throw 'Direct file reading by path is not supported on this platform.'; #end } @@ -372,37 +484,45 @@ class FileUtil * * @param callback The function to call when the file is loaded. */ - public static function browseFileReference(callback:FileReference->Void) + public static function browseFileReference(callback:(FileReference) -> Void):Void { var file = new FileReference(); file.addEventListener(Event.SELECT, function(e) { var selectedFileRef:FileReference = e.target; trace('Selected file: ' + selectedFileRef.name); + selectedFileRef.addEventListener(Event.COMPLETE, function(e) { var loadedFileRef:FileReference = e.target; trace('Loaded file: ' + loadedFileRef.name); + callback(loadedFileRef); }); + selectedFileRef.load(); }); + file.browse(); } /** * Prompts the user to save a file to their computer. */ - public static function writeFileReference(path:String, data:String) + public static function writeFileReference(path:String, data:String):Void { var file = new FileReference(); + file.addEventListener(Event.COMPLETE, function(e:Event) { - trace('Successfully wrote file.'); + trace('Successfully wrote file: "$path"'); }); + file.addEventListener(Event.CANCEL, function(e:Event) { - trace('Cancelled writing file.'); + trace('Cancelled writing file: "$path"'); }); + file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { - trace('IO error writing file.'); + trace('IO error writing file: "$path"'); }); + file.save(data, path); } @@ -416,16 +536,9 @@ class FileUtil public static function readJSONFromPath(path:String):Dynamic { #if sys - try - { - return SerializerUtil.fromJSON(sys.io.File.getContent(path)); - } - catch (ex) - { - return null; - } + return SerializerUtil.fromJSON(sys.io.File.getContent(path)); #else - return null; + throw 'Direct file reading by path is not supported on this platform.'; #end } @@ -440,26 +553,27 @@ class FileUtil public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip):Void { #if sys + if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + createDirIfNotExists(Path.directory(path)); + switch (mode) { case Force: sys.io.File.saveContent(path, data); case Skip: - if (!doesFileExist(path)) + if (!pathExists(path)) { sys.io.File.saveContent(path, data); } - else - { - // Do nothing. - // throw 'File already exists: $path'; - } case Ask: - if (doesFileExist(path)) + if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. - throw 'File already exists: $path'; + throw 'Entry at path already exists: $path'; } else { @@ -467,7 +581,7 @@ class FileUtil } } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; #end } @@ -482,34 +596,42 @@ class FileUtil public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip):Void { #if sys + if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + createDirIfNotExists(Path.directory(path)); + + var shouldWrite:Bool = true; switch (mode) { case Force: - sys.io.File.saveBytes(path, data); + shouldWrite = true; case Skip: - if (!doesFileExist(path)) + if (!pathExists(path)) { - sys.io.File.saveBytes(path, data); - } - else - { - // Do nothing. - // throw 'File already exists: $path'; + shouldWrite = true; } case Ask: - if (doesFileExist(path)) + if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. - throw 'File already exists: $path'; + throw 'Entry at path already exists: "$path"'; } else { - sys.io.File.saveBytes(path, data); + shouldWrite = true; } } + + if (shouldWrite) + { + createDirIfNotExists(Path.directory(path)); + sys.io.File.saveBytes(path, data); + } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; #end } @@ -523,9 +645,141 @@ class FileUtil public static function appendStringToPath(path:String, data:String):Void { #if sys - sys.io.File.append(path, false).writeString(data); + if (!pathExists(path)) + { + writeStringToPath(path, data, Force); + return; + } + else if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + + var output:Null = null; + try + { + output = sys.io.File.append(path, false); + output.writeString(data); + output.close(); + } + catch (e:Dynamic) + { + if (output != null) + { + output.close(); + } + + throw 'Failed to append to file: "$path"'; + } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; + #end + } + + /** + * Moves a file from one location to another. + * Only works on desktop. + * + * @param path The path to the file. + * @param destination The path to move the file to. + */ + public static function moveFile(path:String, destination:String):Void + { + #if sys + if (Path.extension(destination) != '') + { + destination = Path.directory(destination); + } + + sys.FileSystem.rename(path, Path.join([destination, Path.withoutDirectory(path)])); + #else + throw 'File moving is not supported on this platform.'; + #end + } + + /** + * Delete a file at the given path. + * Only works on desktop. + * + * @param path The path to the file. + */ + public static function deleteFile(path:String):Void + { + #if sys + sys.FileSystem.deleteFile(path); + #else + throw 'File deletion is not supported on this platform.'; + #end + } + + /** + * Get a file's size in bytes. Max representable size is ~2.147 GB. + * Only works on desktop. + * + * @param path The path to the file. + * @return The size of the file in bytes. + */ + public static function getFileSize(path:String):Int + { + #if sys + return sys.FileSystem.stat(path).size; + #else + throw 'File size calculation is not supported on this platform.'; + #end + } + + /** + * Check if a path exists on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential file or directory. + * @return Whether the path exists. + */ + public static function pathExists(path:String):Bool + { + #if sys + return sys.FileSystem.exists(path); + #else + return false; + #end + } + + /** + * Check if a path is a file on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential file. + * @return Whether the path exists and is a file. + */ + public static function fileExists(path:String):Bool + { + #if sys + return pathExists(path) && !directoryExists(path); + #else + throw 'Filesystem check is not supported on this platform.'; + #end + } + + /** + * Check if a path is a directory on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential directory. + * @return Whether the path exists and is a directory. + */ + public static function directoryExists(path:String):Bool + { + #if sys + try + { + return sys.FileSystem.isDirectory(path); + } + catch (e:Dynamic) + { + return false; + } + #else + throw 'Filesystem check is not supported on this platform.'; #end } @@ -538,14 +792,182 @@ class FileUtil public static function createDirIfNotExists(dir:String):Void { #if sys - if (!doesFileExist(dir)) + if (!directoryExists(dir)) { sys.FileSystem.createDirectory(dir); } + #else + throw 'Directory creation is not supported on this platform.'; #end } - static var tempDir:String = null; + /** + * List all entries in a directory. + * Only works on desktop. + * + * @param path The path to the directory. + * @return An array of entries in the directory. + */ + public static function readDir(path:String):Array + { + #if sys + return sys.FileSystem.readDirectory(path); + #else + throw 'Directory reading is not supported on this platform.'; + #end + } + + /** + * Move a directory from one location to another, optionally ignoring some paths. + * Only works on desktop. + * + * @param path The path to the directory. + * @param destination The path to move the directory to. + * @param ignore A list of paths to ignore. + * @param strict Fails if the destination directory is not empty. + */ + public static function moveDir(path:String, destination:String, ?ignore:Array, strict:Bool = true):Void + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a directory: "$path"'; + } + + createDirIfNotExists(destination); + if (strict) + { + // Ensure the destination is empty if strict mode is enabled. + var entries:Array = readDir(destination); + if (entries.length > 0) + { + throw 'Destination directory "$destination" is not empty.'; + } + } + + var stack:Array = [path]; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + var entries:Array = readDir(currentPath); + for (entry in entries) + { + var entryPath:String = Path.join([currentPath, entry]); + if (ignore != null && ignore.contains(entryPath)) continue; + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + moveFile(entryPath, Path.join([destination, entry])); + } + } + } + + if (readDir(path)?.length == 0) + { + deleteDir(path); + } + #else + throw 'Directory moving is not supported on this platform.'; + #end + } + + /** + * Delete a directory, optionally including its contents, and optionally ignoring some paths. + * Only works on desktop. + * + * @param path The path to the directory. + * @param recursive Whether to delete all contents of the directory. + * @param ignore A list of paths to ignore. + */ + public static function deleteDir(path:String, recursive:Bool = false, ?ignore:Array):Void + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a valid directory: "$path"'; + } + + if (recursive) + { + var stack:Array = [path]; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + var entries:Array = readDir(currentPath); + for (entry in entries) + { + var entryPath:String = Path.join([currentPath, entry]); + if (ignore != null && ignore.contains(entryPath)) continue; + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + deleteFile(entryPath); + } + } + } + } + else + { + sys.FileSystem.deleteDirectory(path); + } + #else + throw 'Directory deletion is not supported on this platform.'; + #end + } + + /** + * Get a directory's total size in bytes. Max representable size is ~2.147 GB. + * Only works on desktop. + * + * @param path The path to the directory. + * @return The total size of the directory in bytes. + */ + public static function getDirSize(path:String):Int + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a valid directory path: $path'; + } + + var stack:Array = [path]; + var total:Int = 0; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + for (entry in readDir(currentPath)) + { + var entryPath:String = Path.join([currentPath, entry]); + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + total += getFileSize(entryPath); + } + } + } + + return total; + #else + throw 'Directory size calculation not supported on this platform.'; + #end + } + + static var tempDir:Null = null; static final TEMP_ENV_VARS:Array = ['TEMP', 'TMPDIR', 'TEMPDIR', 'TMP']; /** @@ -554,19 +976,19 @@ class FileUtil * * @return The path to the temporary directory. */ - public static function getTempDir():String + public static function getTempDir():Null { if (tempDir != null) return tempDir; #if sys #if windows - var path:String = null; + var path:Null = null; for (envName in TEMP_ENV_VARS) { path = Sys.getEnv(envName); if (path == '') path = null; if (path != null) break; } - tempDir = Path.join([path, 'funkin/']); + tempDir = Path.join([path ?? '', 'funkin/']); return tempDir; #else tempDir = '/tmp/funkin/'; @@ -577,6 +999,51 @@ class FileUtil #end } + /** + * Rename a file or directory. + * Only works on desktop. + * + * @param path The path to the file or directory. + * @param newName The new name of the file or directory. + * @param keepExtension Whether to keep the extension the same, if applicable. + */ + public static function rename(path:String, newName:String, keepExtension:Bool = true):Void + { + #if sys + if (!pathExists(path)) + { + throw 'Path does not exist: "$path"'; + } + + final isDirectory:Bool = directoryExists(path); + newName = Path.withoutDirectory(newName); + if (isDirectory) + { + newName = Path.withoutExtension(newName); + } + else if (keepExtension) + { + newName = Path.withExtension(newName, Path.extension(path)); + } + + newName = Path.join([Path.directory(path), newName]); + if (newName == path) + { + return; + } + + if (pathExists(newName)) + { + // Prevent overwriting something. + throw 'Destination path already exists: "$newName"'; + } + + sys.FileSystem.rename(path, newName); + #else + throw 'File renaming by path is not supported on this platform.'; + #end + } + /** * Create a Bytes object containing a ZIP file, containing the provided entries. * @@ -593,29 +1060,30 @@ class FileUtil public static function readZIPFromBytes(input:Bytes):Array { - trace('TEST: ' + input.length); - trace(input.sub(0, 30).toHex()); var bytesInput = new haxe.io.BytesInput(input); var zippedEntries = haxe.zip.Reader.readZip(bytesInput); - var results:Array = []; + var results:Array = new Array(); for (entry in zippedEntries) { if (entry.compressed) { entry.data = haxe.zip.Reader.unzip(entry); } + results.push(entry); } + return results; } public static function mapZIPEntriesByName(input:Array):Map { - var results:Map = []; + var results:Map = new Map(); for (entry in input) { results.set(entry.fileName, entry); } + return results; } @@ -653,39 +1121,333 @@ class FileUtil }; } - public static function openFolder(pathFolder:String) - { - #if windows - Sys.command('explorer', [pathFolder]); - #elseif mac - // mac could be fuckie with where the log folder is relative to the game file... - // if this comment is still here... it means it has NOT been verified on mac yet! - // - // FileUtil.hx note: this was originally used to open the logs specifically! - // thats why the above comment is there! - Sys.command('open', [pathFolder]); - #end + public static function openFolder(pathFolder:String) // what is this for?? + WindowUtil.openFolder(pathFolder); - // TODO: implement linux - // some shit with xdg-open :thinking: emoji... - } - - static function convertTypeFilter(typeFilter:Array):String + private static function convertTypeFilter(?typeFilter:Array):Null { - var filter:String = null; + var filter:Null = null; if (typeFilter != null) { - var filters:Array = []; + var filters:Array = new Array(); for (type in typeFilter) { - filters.push(StringTools.replace(StringTools.replace(type.extension, '*.', ''), ';', ',')); + filters.push(type.extension.replace('*.', '').replace(';', ',')); } + filter = filters.join(';'); } + return filter; } } +/** + * Utilities for reading and writing files on various platforms. + * Wrapper for `FileUtil` that sanitizes paths for script safety. + */ +@:nullSafety +class FileUtilSandboxed +{ + /** + * Prevent paths from exiting the root. + * + * @param path The path to sanitize. + * @return The sanitized path. + */ + public static function sanitizePath(path:String):String + { + if (path == null) + { + #if sys + return FileUtil.gameDirectory; + #else + return ''; + #end + } + + if (path.contains(':')) + { + path = path.substring(path.lastIndexOf(':') + 1); + } + + path = path.trim().replace('\\', '/'); + while (path.contains('//')) + { + path = path.replace('//', '/'); + } + + @:privateAccess for (char in FileUtil.INVALID_CHARS) + { + path = path.replace(char, ''); + } + + var parts:Array = path.split('/'); + var sanitized:Array = new Array(); + for (part in parts) + { + switch (part) + { + case '.' | '': + continue; + case '..': + sanitized.pop(); + default: + sanitized.push(part); + } + } + + var ret:String = sanitized.join('/'); + + #if sys + var realPath:String = Path.normalize(sys.FileSystem.fullPath(Path.join([FileUtil.gameDirectory, ret]))); + // ^ TODO: figure out how to get "real" path of symlinked paths + + if (!realPath.startsWith(FileUtil.gameDirectory.toString())) + { + return FileUtil.gameDirectory; + } + + ret = realPath; + #end + + return ret; + } + + /** + * Check against protected paths. + * @param path The path to check. + * @return Whether the path is protected. + */ + public static function isProtected(path:String):Bool + { + path = sanitizePath(path); + @:privateAccess for (protected in FileUtil.PROTECTED_PATHS) + { + if (path == protected || (protected.contains('*') && path.startsWith(protected.substring(0, protected.indexOf('*'))))) + { + return true; + } + } + + return false; + } + + public static final FILE_FILTER_FNFC:FileFilter = FileUtil.FILE_FILTER_FNFC; + public static final FILE_FILTER_JSON:FileFilter = FileUtil.FILE_FILTER_JSON; + public static final FILE_FILTER_ZIP:FileFilter = FileUtil.FILE_FILTER_ZIP; + public static final FILE_FILTER_PNG:FileFilter = FileUtil.FILE_FILTER_PNG; + + public static final FILE_EXTENSION_INFO_FNFC:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_FNFC; + public static final FILE_EXTENSION_INFO_ZIP:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_ZIP; + public static final FILE_EXTENSION_INFO_PNG:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_PNG; + + public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo) -> Void, + ?onCancel:() -> Void) + { + FileUtil.browseForBinaryFile(dialogTitle, typeFilter, onSelect, onCancel); + } + + public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo) -> Void, + ?onCancel:() -> Void):Void + { + FileUtil.browseForTextFile(dialogTitle, typeFilter, onSelect, onCancel); + } + + public static function browseForDirectory(?typeFilter:Array, onSelect:(String) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForDirectory(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForMultipleFiles(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function browseForSaveFile(?typeFilter:Array, onSelect:(String) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForSaveFile(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String) -> Void, ?onCancel:() -> Void, ?defaultFileName:String, + ?dialogTitle:String):Bool + { + return FileUtil.saveFile(data, typeFilter, onSave, onCancel, defaultFileName, dialogTitle); + } + + public static function saveMultipleFiles(resources:Array, ?onSaveAll:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + force:Bool = false):Bool + { + return FileUtil.saveMultipleFiles(resources, onSaveAll, onCancel, defaultPath, force); + } + + public static function saveFilesAsZIP(resources:Array, ?onSave:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + force:Bool = false):Bool + { + return FileUtil.saveFilesAsZIP(resources, onSave, onCancel, defaultPath, force); + } + + public static function saveChartAsFNFC(resources:Array, ?onSave:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + force:Bool = false):Bool + { + return FileUtil.saveChartAsFNFC(resources, onSave, onCancel, defaultPath, force); + } + + public static function saveFilesAsZIPToPath(resources:Array, path:String, mode:FileWriteMode = Skip):Bool + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + return FileUtil.saveFilesAsZIPToPath(resources, path, mode); + } + + public static function readStringFromPath(path:String):String + { + return FileUtil.readStringFromPath(sanitizePath(path)); + } + + public static function readBytesFromPath(path:String):Bytes + { + return FileUtil.readBytesFromPath(sanitizePath(path)); + } + + public static function browseFileReference(callback:(FileReference) -> Void):Void + { + FileUtil.browseFileReference(callback); + } + + public static function writeFileReference(path:String, data:String):Void + { + FileUtil.writeFileReference(path, data); + } + + public static function readJSONFromPath(path:String):Dynamic + { + return FileUtil.readJSONFromPath(sanitizePath(path)); + } + + public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.writeStringToPath(path, data, mode); + } + + public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.writeBytesToPath(path, data, mode); + } + + public static function appendStringToPath(path:String, data:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.appendStringToPath(path, data); + } + + public static function moveFile(path:String, destination:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot move protected path: $path'; + if (isProtected(destination = sanitizePath(destination))) throw 'Cannot move to protected path: $destination'; + FileUtil.moveFile(path, destination); + } + + public static function deleteFile(path:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot delete protected path: $path'; + FileUtil.deleteFile(path); + } + + public static function getFileSize(path:String):Int + { + return FileUtil.getFileSize(sanitizePath(path)); + } + + public static function pathExists(path:String):Bool + { + return FileUtil.pathExists(sanitizePath(path)); + } + + public static function fileExists(path:String):Bool + { + return FileUtil.fileExists(sanitizePath(path)); + } + + public static function directoryExists(path:String):Bool + { + return FileUtil.directoryExists(sanitizePath(path)); + } + + public static function createDirIfNotExists(dir:String):Void + { + FileUtil.createDirIfNotExists(sanitizePath(dir)); + } + + public static function readDir(path:String):Array + { + return FileUtil.readDir(sanitizePath(path)); + } + + public static function moveDir(path:String, destination:String, ?ignore:Array, strict:Bool = true):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot move protected path: "$path"'; + if (isProtected(destination = sanitizePath(destination))) throw 'Cannot move to protected path: "$destination"'; + FileUtil.moveDir(path, destination, ignore, strict); + } + + public static function deleteDir(path:String, recursive:Bool = false, ?ignore:Array):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot delete protected path: "$path"'; + FileUtil.deleteDir(path, recursive, ignore); + } + + public static function getDirSize(path:String):Int + { + return FileUtil.getDirSize(sanitizePath(path)); + } + + public static function getTempDir():Null + { + return FileUtil.getTempDir(); + } + + public static function rename(path:String, newName:String, keepExtension:Bool = true):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot rename protected path: "$path"'; + FileUtil.rename(path, sanitizePath(newName), keepExtension); + } + + public static function createZIPFromEntries(entries:Array):Bytes + { + return FileUtil.createZIPFromEntries(entries); + } + + public static function readZIPFromBytes(input:Bytes):Array + { + return FileUtil.readZIPFromBytes(input); + } + + public static function mapZIPEntriesByName(input:Array):Map + { + return FileUtil.mapZIPEntriesByName(input); + } + + public static function makeZIPEntry(name:String, content:String):Entry + { + return FileUtil.makeZIPEntry(name, content); + } + + public static function makeZIPEntryFromBytes(name:String, data:haxe.io.Bytes):Entry + { + return FileUtil.makeZIPEntryFromBytes(name, data); + } + + public static function openFolder(pathFolder:String) + { + FileUtil.openFolder(pathFolder); + } +} + enum FileWriteMode { /** diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx index e2a627dfd..ab51aeee2 100644 --- a/source/funkin/util/WindowUtil.hx +++ b/source/funkin/util/WindowUtil.hx @@ -22,6 +22,11 @@ class WindowUtil */ public static function openURL(targetUrl:String):Void { + // Ensure you can't open protocols such as steam://, file://, etc + var protocol:Array = targetUrl.split("://"); + if (protocol.length == 1) targetUrl = 'https://${targetUrl}'; + else if (protocol[0] != 'http' && protocol[0] != 'https') throw "openURL can only open http and https links."; + #if FEATURE_OPEN_URL #if linux Sys.command('/usr/bin/xdg-open $targetUrl &'); @@ -64,6 +69,10 @@ class WindowUtil */ public static function openFolder(targetPath:String):Void { + #if html5 + throw 'Cannot open URLs on this platform.'; + #else + if (!sys.FileSystem.exists(targetPath) || !sys.FileSystem.isDirectory(targetPath)) throw 'openFolder should only be used to open existing folders.'; #if FEATURE_OPEN_URL #if windows Sys.command('explorer', [targetPath.replace('/', '\\')]); @@ -75,6 +84,7 @@ class WindowUtil #else throw 'Cannot open URLs on this platform.'; #end + #end } /** @@ -83,6 +93,9 @@ class WindowUtil */ public static function openSelectFile(targetPath:String):Void { + #if html5 + throw 'Cannot open URLs on this platform.'; + #else #if FEATURE_OPEN_URL #if windows Sys.command('explorer', ['/select,' + targetPath.replace('/', '\\')]); @@ -95,6 +108,7 @@ class WindowUtil #else throw 'Cannot open URLs on this platform.'; #end + #end } /** diff --git a/source/funkin/util/macro/RegistryMacro.hx b/source/funkin/util/macro/RegistryMacro.hx index 625474025..bb9947c90 100644 --- a/source/funkin/util/macro/RegistryMacro.hx +++ b/source/funkin/util/macro/RegistryMacro.hx @@ -8,7 +8,6 @@ import haxe.macro.Expr.TypeDefKind; import haxe.macro.Expr.MetadataEntry; import haxe.macro.Type; import haxe.macro.Type.ClassType; -import sys.FileSystem; using Lambda; using haxe.macro.ExprTools; @@ -366,7 +365,7 @@ class RegistryMacro static function listBaseGameEntryIds(dataFilePath:String):Array { var result:Array = []; - var files:Array = FileSystem.readDirectory(dataFilePath); + var files:Array = sys.FileSystem.readDirectory(dataFilePath); for (file in files) { diff --git a/source/funkin/util/plugins/ScreenshotPlugin.hx b/source/funkin/util/plugins/ScreenshotPlugin.hx index 1b7a03a54..dad604211 100644 --- a/source/funkin/util/plugins/ScreenshotPlugin.hx +++ b/source/funkin/util/plugins/ScreenshotPlugin.hx @@ -3,7 +3,6 @@ package funkin.util.plugins; import flixel.FlxBasic; import flixel.FlxG; import flixel.FlxState; -import flixel.input.keyboard.FlxKey; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; @@ -12,7 +11,6 @@ import flixel.util.FlxTimer; import funkin.util.WindowUtil; import funkin.util.logging.CrashHandler; import flixel.addons.util.FlxAsyncLoop; -import funkin.graphics.FunkinSprite; import funkin.input.Cursor; import funkin.audio.FunkinSound; import openfl.display.Bitmap; @@ -23,39 +21,44 @@ import openfl.geom.Rectangle; import openfl.utils.ByteArray; import openfl.events.MouseEvent; import funkin.Preferences; -import flixel.addons.plugin.screengrab.FlxScreenGrab; +/** + * Parameters for starting the `ScreenshotPlugin`. + */ typedef ScreenshotPluginParams = { ?region:Rectangle, - flashColor:Null, }; /** - * TODO: Contribute this upstream. + * A Flixel plugin, which renders on top of the game, and provides the functionality + * and visual feedback for taking screenshots. */ +@:nullSafety class ScreenshotPlugin extends FlxBasic { /** * Current `ScreenshotPlugin` instance */ - public static var instance:ScreenshotPlugin = null; + public static var instance(get, never):ScreenshotPlugin; - public static final SCREENSHOT_FOLDER = 'screenshots'; + static var _instance:Null = null; - var region:Null; + static function get_instance():ScreenshotPlugin + { + if (_instance == null) + { + _instance = new ScreenshotPlugin({}); + } + return _instance; + } /** - * The color used for the flash + * The folder where screenshots are saved */ - public static var flashColor(default, set):Int = 0xFFFFFFFF; + public static final SCREENSHOT_FOLDER:String = 'screenshots'; - public static function set_flashColor(v:Int):Int - { - flashColor = v; - if (instance != null && instance.flashBitmap != null) instance.flashBitmap.bitmapData = new BitmapData(lastWidth, lastHeight, true, v); - return flashColor; - } + var region:Null; /** * A signal fired before the screenshot is taken. @@ -68,8 +71,8 @@ class ScreenshotPlugin extends FlxBasic */ public var onPostScreenshot(default, null):FlxTypedSignalVoid>; - private static var lastWidth:Int; - private static var lastHeight:Int; + static var lastWidth:Int = 0; + static var lastHeight:Int = 0; var flashSprite:Sprite; var flashBitmap:Bitmap; @@ -83,7 +86,7 @@ class ScreenshotPlugin extends FlxBasic var screenshotBeingSpammed:Bool = false; - var screenshotSpammedTimer:FlxTimer; + var screenshotSpammedTimer:Null = null; var screenshotBuffer:Array = []; var screenshotNameBuffer:Array = []; @@ -94,31 +97,23 @@ class ScreenshotPlugin extends FlxBasic var stateChanging:Bool = false; var noSavingScreenshots:Bool = false; - var flashTween:FlxTween; + var flashTween:Null = null; - var previewFadeInTween:FlxTween; - var previewFadeOutTween:FlxTween; + var previewFadeInTween:Null = null; + var previewFadeOutTween:Null = null; - var asyncLoop:FlxAsyncLoop; + var asyncLoop:Null = null; public function new(params:ScreenshotPluginParams) { super(); - if (instance != null) - { - destroy(); - return; - } - - instance = this; - lastWidth = FlxG.width; lastHeight = FlxG.height; flashSprite = new Sprite(); flashSprite.alpha = 0; - flashBitmap = new Bitmap(new BitmapData(lastWidth, lastHeight, true, params.flashColor)); + flashBitmap = new Bitmap(new BitmapData(lastWidth, lastHeight, true, Preferences.flashingLights ? FlxColor.WHITE : FlxColor.TRANSPARENT)); flashSprite.addChild(flashBitmap); previewSprite = new Sprite(); @@ -137,7 +132,6 @@ class ScreenshotPlugin extends FlxBasic FlxG.stage.addChild(flashSprite); region = params.region ?? null; - flashColor = params.flashColor; onPreScreenshot = new FlxTypedSignalVoid>(); onPostScreenshot = new FlxTypedSignalVoid>(); @@ -205,7 +199,7 @@ class ScreenshotPlugin extends FlxBasic sprite.alpha = 0; } // screenshot spamming timer - if (screenshotSpammedTimer == null || screenshotSpammedTimer.finished == true) + if (screenshotSpammedTimer == null || screenshotSpammedTimer.finished) { screenshotSpammedTimer = new FlxTimer().start(1, function(_) { // The player's stopped spamming shots, so we can stop the screenshot spam mode too @@ -242,12 +236,12 @@ class ScreenshotPlugin extends FlxBasic */ public static function initialize():Void { - FlxG.plugins.addPlugin(new ScreenshotPlugin( - { - flashColor: Preferences.flashingLights ? FlxColor.WHITE : null, // Was originally a black flash. - })); + FlxG.plugins.addPlugin(new ScreenshotPlugin({})); } + /** + * @return `true` if the screenshot key is currently pressed. + */ public function hasPressedScreenshot():Bool { #if FEATURE_SCREENSHOTS @@ -257,16 +251,11 @@ class ScreenshotPlugin extends FlxBasic #end } - public function updateFlashColor():Void - { - Preferences.flashingLights ? set_flashColor(FlxColor.WHITE) : null; - } - - private function resizeBitmap(width:Int, height:Int) + function resizeBitmap(width:Int, height:Int):Void { lastWidth = width; lastHeight = height; - flashBitmap.bitmapData = new BitmapData(lastWidth, lastHeight, true, flashColor); + flashBitmap.bitmapData = new BitmapData(lastWidth, lastHeight, true, Preferences.flashingLights ? FlxColor.WHITE : FlxColor.TRANSPARENT); outlineBitmap.bitmapData = new BitmapData(Std.int(lastWidth / 5) + 10, Std.int(lastHeight / 5) + 10, true, 0xFFFFFFFF); } @@ -277,8 +266,8 @@ class ScreenshotPlugin extends FlxBasic { onPreScreenshot.dispatch(); - var shot = new Bitmap(BitmapData.fromImage(FlxG.stage.window.readPixels())); - if (screenshotBeingSpammed == true) + var shot:Bitmap = new Bitmap(BitmapData.fromImage(FlxG.stage.window.readPixels())); + if (screenshotBeingSpammed) { // Save the screenshots to the buffer instead if (screenshotBuffer.length < 100) @@ -318,7 +307,7 @@ class ScreenshotPlugin extends FlxBasic onPostScreenshot.dispatch(shot); } - final CAMERA_FLASH_DURATION = 0.25; + static final CAMERA_FLASH_DURATION:Float = 0.25; /** * Visual and audio feedback when a screenshot is taken. @@ -332,10 +321,10 @@ class ScreenshotPlugin extends FlxBasic FunkinSound.playOnce(Paths.sound('screenshot'), 1.0); } - static final PREVIEW_INITIAL_DELAY = 0.25; // How long before the preview starts fading in. - static final PREVIEW_FADE_IN_DURATION = 0.3; // How long the preview takes to fade in. - static final PREVIEW_FADE_OUT_DELAY = 1.25; // How long the preview stays on screen. - static final PREVIEW_FADE_OUT_DURATION = 0.3; // How long the preview takes to fade out. + static final PREVIEW_INITIAL_DELAY:Float = 0.25; // How long before the preview starts fading in. + static final PREVIEW_FADE_IN_DURATION:Float = 0.3; // How long the preview takes to fade in. + static final PREVIEW_FADE_OUT_DELAY:Float = 1.25; // How long the preview stays on screen. + static final PREVIEW_FADE_OUT_DURATION:Float = 0.3; // How long the preview takes to fade out. /** * Show a fancy preview for the screenshot @@ -367,12 +356,12 @@ class ScreenshotPlugin extends FlxBasic // fuck it, cursed locally scoped functions, purely because im lazy // (and so we can check changingAlpha, which is locally scoped.... because I'm lazy...) - var onHover = function(e:MouseEvent) { + var onHover:MouseEvent->Void = function(e:MouseEvent) { if (!changingAlpha) e.target.alpha = 0.6; targetAlpha = 0.6; }; - var onHoverOut = function(e:MouseEvent) { + var onHoverOut:MouseEvent->Void = function(e:MouseEvent) { if (!changingAlpha) e.target.alpha = 1; targetAlpha = 1; } @@ -461,7 +450,7 @@ class ScreenshotPlugin extends FlxBasic static function getCurrentState():FlxState { - var state = FlxG.state; + var state:FlxState = FlxG.state; while (state.subState != null) { state = state.subState; @@ -484,12 +473,12 @@ class ScreenshotPlugin extends FlxBasic */ function encode(bitmap:Bitmap):ByteArray { - var compressor = returnEncoder(Preferences.saveFormat); + var compressor:PNGEncoderOptions = new PNGEncoderOptions(); return bitmap.bitmapData.encode(bitmap.bitmapData.rect, compressor); } - var previousScreenshotName:String; - var previousScreenshotCopyNum:Int; + var previousScreenshotName:Null = null; + var previousScreenshotCopyNum:Int = 0; /** * Save the generated bitmap to a file. @@ -498,7 +487,7 @@ class ScreenshotPlugin extends FlxBasic * @param screenShotNum Used for the delay save option, to space out the saving of the images. * @param delaySave If true, the image gets saved with the screenShotNum as the delay. */ - function saveScreenshot(bitmap:Bitmap, targetPath = "image", screenShotNum:Int = 0, delaySave:Bool = true) + function saveScreenshot(bitmap:Bitmap, targetPath = "image", screenShotNum:Int = 0, delaySave:Bool = true):Void { makeScreenshotPath(); // Check that we're not overriding a previous image, and keep making a unique path until we can @@ -520,13 +509,14 @@ class ScreenshotPlugin extends FlxBasic targetPath = getScreenshotPath() + newTargetPath + '.' + Std.string(Preferences.saveFormat).toLowerCase(); } - // TODO: Make this work on browser. + // TODO: Make screenshot saving work on browser. // Maybe save the images into a buffer that you can download as a zip or something? That'd work // Shouldn't be too hard to do something similar to the chart editor saving - if (delaySave) // Save the images with a delay (a timer) + if (delaySave) + { // Save the images with a delay (a timer) new FlxTimer().start(screenShotNum, function(_) { - var pngData = encode(bitmap); + var pngData:ByteArray = encode(bitmap); if (pngData == null) { @@ -547,9 +537,10 @@ class ScreenshotPlugin extends FlxBasic if (Preferences.previewOnSave) showFancyPreview(bitmap); // Only show the preview after a screenshot is saved } }); + } else // Save the screenshot immediately { - var pngData = encode(bitmap); + var pngData:ByteArray = encode(bitmap); if (pngData == null) { @@ -567,7 +558,7 @@ class ScreenshotPlugin extends FlxBasic } // I' m very happy with this code, all of it just works - function saveBufferedScreenshots(screenshots:Array, screenshotNames) + function saveBufferedScreenshots(screenshots:Array, screenshotNames:Array):Void { trace('Saving screenshot buffer'); var i:Int = 0; @@ -581,14 +572,16 @@ class ScreenshotPlugin extends FlxBasic }, 1); getCurrentState().add(asyncLoop); if (!Preferences.flashingLights && !Preferences.previewOnSave) + { showFancyPreview(screenshots[screenshots.length - 1]); // show the preview for the last screenshot + } } /** * Similar to the above function, but cancels the tweens, undos the mouse * and doesn't have the async loop because this is called before the state changes */ - function saveUnsavedBufferedScreenshots() + function saveUnsavedBufferedScreenshots():Void { stateChanging = true; // Cancel the tweens of the capture feedback if they're running @@ -627,17 +620,7 @@ class ScreenshotPlugin extends FlxBasic unsavedScreenshotNameBuffer = []; } - public function returnEncoder(saveFormat:String):Any - { - return switch (saveFormat) - { - // JPEG encoder causes the game to crash????? - // case "JPEG": new openfl.display.JPEGEncoderOptions(Preferences.jpegQuality); - default: new openfl.display.PNGEncoderOptions(); - } - } - - function postStateSwitch() + function postStateSwitch():Void { stateChanging = false; screenshotBeingSpammed = false; @@ -646,7 +629,7 @@ class ScreenshotPlugin extends FlxBasic override public function destroy():Void { - if (instance == this) instance = null; + if (instance == this) _instance = null; if (FlxG.plugins.list.contains(this)) FlxG.plugins.remove(this); @@ -663,13 +646,18 @@ class ScreenshotPlugin extends FlxBasic @:privateAccess for (parent in [flashSprite, previewSprite]) + { + if (parent == null) continue; for (child in parent.__children) + { parent.removeChild(child); + } + } - flashSprite = null; - flashBitmap = null; - previewSprite = null; - shotPreviewBitmap = null; - outlineBitmap = null; + // flashSprite = null + // flashBitmap = null + // previewSprite = null + // shotPreviewBitmap = null + // outlineBitmap = null } }