diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..118201cd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e7a218c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.hxc linguist-language=Haxe +*.hxp linguist-language=Haxe diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..08264d48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,72 @@ +name: Bug Report +description: Report a bug or an issue in the game. +labels: ["type: minor bug", "status: pending triage"] +title: "Bug Report: " +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 + description: Be sure to complete these steps to increase the chances of your issue being addressed! + options: + - label: I have read the [Contributing Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) + - label: I have checked the Issues/Discussions pages to see if my issue has already been reported + - label: I have properly titled my issue + + - type: dropdown + attributes: + label: Platform + description: Which platform are you playing on? + options: + - Newgrounds (Web/HTML5) + - Itch.io (Web/HTML5) + - Itch.io (Downloadable Build) - Windows + - Itch.io (Downloadable Build) - MacOS + - Itch.io (Downloadable Build) - Linux + - Compiled from GitHub Source Code + validations: + required: true + + - type: dropdown + attributes: + label: Browser + description: (Web/HTML5 users only) Which browser are you playing on? + options: + - Google Chrome + - Microsoft Edge + - Firefox + - Opera + - Safari + - Other (Specify in Description field) + + - type: input + 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.6.4 + validations: + required: true + + - type: markdown + attributes: + value: "## Describe your bug." + + - type: markdown + attributes: + value: "### Please do not report issues from other engines. These must be reported in their respective repositories." + + - type: textarea + attributes: + label: Description (include any images, videos, errors, or crash logs) + description: Provide as much detail as you can. The better others understand your issue, the more they can help you! + placeholder: Describe your issue here... + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: What steps can other people take to reliably encounter this issue? + placeholder: 1. Open the game... diff --git a/.github/ISSUE_TEMPLATE/charting.yml b/.github/ISSUE_TEMPLATE/charting.yml new file mode 100644 index 00000000..383bdafe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/charting.yml @@ -0,0 +1,75 @@ +name: Charting Issue +description: Report an issue with the placement of notes in the game. +labels: ["type: charting issue", "status: pending triage"] +title: "Charting Issue: " +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 + description: Be sure to complete these steps to increase the chances of your issue being addressed! + options: + - label: I have read the [Contributing Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) + - label: I have checked the Issues/Discussions pages to see if my issue has already been reported + - label: I have properly titled my issue + + - type: dropdown + attributes: + label: Platform + description: Which platform are you playing on? + options: + - Newgrounds (Web/HTML5) + - Itch.io (Web/HTML5) + - Itch.io (Downloadable Build) - Windows + - Itch.io (Downloadable Build) - MacOS + - Itch.io (Downloadable Build) - Linux + - Compiled from GitHub Source Code + validations: + required: true + + - type: dropdown + attributes: + label: Browser + description: (Web/HTML5 users only) Which browser are you playing on? + options: + - Google Chrome + - Microsoft Edge + - Firefox + - Opera + - Safari + - Other (Specify in Description field) + + - type: input + 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.6.4 + validations: + required: true + + - type: markdown + attributes: + value: "## Describe the charting issue(s)." + + - type: markdown + attributes: + value: "### Please do not report issues from other engines. These must be reported in their respective repositories." + + - type: textarea + attributes: + label: Location + description: Where did you find the issue(s)? Include the name of the song, the variation, the difficulty, the time/section of the song, and any images or videos. + placeholder: ex. Cocoa Erect on Erect/Nightmare difficulties at Section 30 + validations: + required: true + + - type: textarea + attributes: + label: Description + description: Why do you believe this is an issue? Is a note at the wrong time, a hold note too short/long, or something else? + placeholder: Describe the charting issue(s) here... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/compiling.yml b/.github/ISSUE_TEMPLATE/compiling.yml new file mode 100644 index 00000000..58c8b526 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compiling.yml @@ -0,0 +1,63 @@ +name: Compiling Help +description: Report an issue with compiling the game. +labels: ["type: compilation help", "status: pending triage"] +title: "Compiling Help: " +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 + description: Be sure to complete these steps to increase the chances of your issue being addressed! + options: + - label: I have followed the [Compiling Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/COMPILING.md) and the [Troubleshooting Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/TROUBLESHOOTING.md) + - label: I have read the [Contributing Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) + - label: I have checked the Issues/Discussions pages to see if my issue has already been reported + - label: I have properly titled my issue + + - type: dropdown + attributes: + label: Platform + description: Which platform are you compiling for? + options: + - Web/HTML5 + - Desktop (Windows) + - Desktop (Mac) + - Desktop (Linux) + - Other + validations: + required: true + + - type: input + 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.6.4 + validations: + required: true + + - type: markdown + attributes: + value: "## Describe your compiling issue." + + - type: markdown + attributes: + value: "### Please do not report issues from other engines. These must be reported in their respective repositories." + + - type: textarea + attributes: + label: Console Output + description: Paste any errors or warnings from the console here. + placeholder: Paste output here... + validations: + required: true + + - type: textarea + attributes: + label: Context + description: Provide as much detail as you can. Did you modify any code? Provide any relevant images or videos. + placeholder: Describe the context here... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/crash.yml b/.github/ISSUE_TEMPLATE/crash.yml new file mode 100644 index 00000000..073465f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash.yml @@ -0,0 +1,83 @@ +name: Crash Report +description: Report a crash that occurred while playing the game. +labels: ["type: major bug", "status: pending triage"] +title: "Crash Report: " +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 + description: Be sure to complete these steps to increase the chances of your issue being addressed! + options: + - label: I have read the [Contributing Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) + - label: I have checked the Issues/Discussions pages to see if my issue has already been reported + - label: I have properly titled my issue + + - type: dropdown + attributes: + label: Platform + description: Which platform are you playing on? + options: + - Newgrounds (Web/HTML5) + - Itch.io (Web/HTML5) + - Itch.io (Downloadable Build) - Windows + - Itch.io (Downloadable Build) - MacOS + - Itch.io (Downloadable Build) - Linux + - Compiled from GitHub Source Code + validations: + required: true + + - type: dropdown + attributes: + label: Browser + description: (Web/HTML5 users only) Which browser are you playing on? + options: + - Google Chrome + - Microsoft Edge + - Firefox + - Opera + - Safari + - Other (Specify in Description field) + + - type: input + 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.6.4 + validations: + required: true + + - type: markdown + attributes: + value: "## Describe the crash." + + - type: markdown + attributes: + value: "### Please do not report issues from other engines. These must be reported in their respective repositories." + + - type: textarea + attributes: + label: Description + description: Include screenshots or videos of the crash happening. Provide as much detail as you can. + placeholder: Describe the crash here... + validations: + required: true + + - type: textarea + attributes: + label: Steps to Reproduce + description: What steps can other people take to reliably trigger this crash? + placeholder: 1. Open the game... + validations: + required: true + + - type: textarea + attributes: + label: Crash logs + description: These can be found in the logs folder where Funkin.exe is. + placeholder: Upload your logs here... + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 00000000..ac0e758c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,23 @@ +name: Enhancement +description: Suggest a new feature. +labels: ["type: enhancement", "status: pending triage"] +title: "Enhancement: " +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 + description: Be sure to complete these steps to increase the chances of your suggestion being considered! + options: + - label: I have read the [Contributing Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md) + - label: I have checked the Issues/Discussions pages to see if my enhancement has already been suggested + - label: I have properly titled my enhancement + + - type: textarea + attributes: + label: What is your suggestion, and why should it be implemented? + validations: + required: true diff --git a/.github/actions/setup-haxe/action.yml b/.github/actions/setup-haxe/action.yml new file mode 100644 index 00000000..f6dd14f9 --- /dev/null +++ b/.github/actions/setup-haxe/action.yml @@ -0,0 +1,154 @@ +name: setup-haxeshit +description: "sets up haxe shit, using HMM!" + +inputs: + haxe: + description: 'Version of haxe to install' + required: true + default: '4.3.6' + hxcpp-cache: + description: 'Whether to use a shared hxcpp compile cache' + required: true + default: 'true' + hxcpp-cache-path: + description: 'Path to create hxcpp cache in' + required: true + default: ${{ runner.temp }}/hxcpp_cache + targets: + description: 'Targets we plan to compile to. Installs native dependencies needed.' + required: true + gh-token: + description: 'GitHub secret for private repos as dependencies' + +runs: + using: "composite" + steps: + + - name: Setup timers + shell: bash + run: | + echo "TIMER_HAXE=$(date +%s)" >> "$GITHUB_ENV" + + - name: Install Haxe + uses: funkincrew/ci-haxe@v3.2.7 + with: + haxe-version: ${{ inputs.haxe }} + + - name: Install native dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + ls -lah /usr/lib/x86_64-linux-gnu/ + apt-get update + apt-get install -y \ + g++ \ + libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \ + libgl-dev libgl1-mesa-dev \ + libasound2-dev libpulse-dev + ln -s /usr/lib/x86_64-linux-gnu/libffi.so.8 /usr/lib/x86_64-linux-gnu/libffi.so.6 || true + - name: Install linux-specific dependencies + if: ${{ runner.os == 'Linux' && contains(inputs.targets, 'linux') }} + shell: bash + run: | + apt-get install -y libvlc-dev libvlccore-dev + + - name: Config haxelib + shell: bash + run: | + echo "TIMER_HAXELIB=$(date +%s)" >> "$GITHUB_ENV" + haxelib fixrepo --global || true + haxelib --debug --never --global install haxelib 4.1.0 + haxelib --debug --global set haxelib 4.1.0 + haxelib --global remove haxelib git || true + haxelib --global remove hmm || true + rm -rf .haxelib + haxelib --debug --never --global git haxelib https://github.com/FunkinCrew/haxelib.git funkin-patches --skip-dependencies + haxelib --debug --never --global git hmm https://github.com/FunkinCrew/hmm funkin-patches + haxelib --debug --never newrepo + haxelib version + echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV" + echo "TIMER_DEPS=$(date +%s)" >> "$GITHUB_ENV" + + - name: Restore cached dependencies + id: cache-hmm + uses: actions/cache@v4 + with: + path: .haxelib + key: haxe-hmm-${{ runner.os }}-${{ hashFiles('**/hmm.json') }} + + - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }} + name: Prep git for dependency install + uses: gacts/run-and-post-run@v1 + with: + run: | + git config --global --name-only --get-regexp 'url\.https\:\/\/x-access-token:.+@github\.com\/\.insteadOf' \ + | xargs -I {} git config --global --unset {} + + git config -l --show-scope --show-origin + git config --global 'url.https://x-access-token:${{ inputs.gh-token }}@github.com/.insteadOf' https://github.com/ + post: git config --global --unset 'url.https://x-access-token:${{ inputs.gh-token }}@github.com/.insteadOf' + + - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }} + name: Install dependencies + shell: bash + run: | + haxelib --global run hmm install -q + echo "TIMER_DONE=$(date +%s)" >> "$GITHUB_ENV" + + # by default use a shared hxcpp cache + - if: ${{ inputs.hxcpp-cache == 'true' }} + name: Restore hxcpp cache + uses: actions/cache@v4 + with: + path: ${{ inputs.hxcpp-cache-path }} + key: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }} + # export env for it to reuse in builds + - if: ${{ inputs.hxcpp-cache == 'true' }} + name: Persist env for hxcpp cache + shell: bash + run: | + echo "HXCPP_COMPILE_CACHE=${{ inputs.hxcpp-cache-path }}" >> "$GITHUB_ENV" + echo 'HXCPP_CACHE_MB="4096"' >> "$GITHUB_ENV" + + # if it's explicitly disabled, still cache export/ since that then contains the builds + - if: ${{ inputs.hxcpp-cache != 'true' }} + name: Restore export cache + uses: actions/cache@v4 + with: + path: ${{ inputs.hxcpp-cache-path }} + key: haxe-export-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: haxe-export-${{ runner.os }}-${{ github.ref_name }} + + - name: Print debug info + shell: bash + run: | + cat << EOF + runner: + kernel: $(uname -a) + haxe: + version: $(haxe -version) + which: $(which haxe) + haxepath: $HAXEPATH + took: $((TIMER_HAXELIB - TIMER_HAXE))s + haxelib: + version: $(haxelib version) + which: $(which haxelib) + local: + config: $(haxelib config) + path: $(haxelib path haxelib || true) + global + config: $(haxelib config --global) + path: $(haxelib path haxelib --global || true) + system + version: $(haxelib --system version) + local: + config: $(haxelib --system config) + global: + config: $(haxelib --system config --global) + took: $((TIMER_DEPS - TIMER_HAXELIB))s + deps: + took: $((TIMER_DONE - TIMER_DEPS))s + hxcpp_cache: | + $(haxelib run hxcpp cache list || true) + EOF diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml new file mode 100644 index 00000000..fb049efc --- /dev/null +++ b/.github/actions/upload-itch/action.yml @@ -0,0 +1,124 @@ +name: upload-itch +description: "installs Butler, and uploads to itch.io!" + +inputs: + butler-key: + description: "Butler API secret key" + required: true + itch-repo: + description: "Where to upload the game to" + required: true + default: "ninja-muffin24/funkin-secret" + build-dir: + description: "Directory of the game build" + required: false + target: + description: "Target (html5, windows, linux, macos)" + required: true + +runs: + using: "composite" + steps: + + # RUNNER_OS = Windows | macOS | Linux + # TARGET_BUILD = windows | macos | linux + # TARGET_ITCH = win | macos | linux + # TARGET_BUTLER_DOWNLOAD = windows-amd64 | darwin-amd64 | linux-amd64 + - name: Setup variables + shell: bash + run: | + TARGET_OS=${{ inputs.target }} + RUNNER=${RUNNER_OS@L} + TARGET=${TARGET_OS@L} + case "$TARGET" in + "windows") + TARGET_ITCH=win + ;; + *) + TARGET_ITCH=$TARGET + ;; + esac + case "$RUNNER" in + "macos") + OS_NODE=darwin + ;; + *) + OS_NODE=$RUNNER + ;; + esac + BUTLER_PATH=$RUNNER_TEMP/butler + + echo BUILD_DIR="export/release/$TARGET/bin" >> "$GITHUB_ENV" + echo ITCH_TAG=${{ inputs.itch-repo }}:$TARGET_ITCH-$GITHUB_REF_NAME >> "$GITHUB_ENV" + echo OS_AND_ARCH=$OS_NODE-amd64 >> "$GITHUB_ENV" + echo BUTLER_API_KEY=${{ inputs.butler-key }} >> "$GITHUB_ENV" + echo BUTLER_INSTALL_PATH=$BUTLER_PATH >> "$GITHUB_ENV" + echo TIMER_BUTLER=$(date +%s) >> "$GITHUB_ENV" + echo TARGET_ITCH=$TARGET_ITCH >> "$GITHUB_ENV" + + echo "$BUTLER_PATH" >> "$GITHUB_PATH" + + - name: Get latest butler version + shell: bash + run: | + LATEST=$(curl -sfL https://broth.itch.ovh/butler/$OS_AND_ARCH/LATEST) + echo BUTLER_LATEST=$LATEST >> "$GITHUB_ENV" + + command -v butler \ + && echo BUTLER_CURRENT=$(butler -V 2>&1 | cut -d ',' -f 1) >> "$GITHUB_ENV" \ + || echo BUTLER_CURRENT=none >> "$GITHUB_ENV" + + - name: Try to get butler from cache + id: cache-butler + uses: actions/cache@v4 + with: + path: ${{ env.BUTLER_INSTALL_PATH }} + key: butler-${{ runner.os }}-${{ env.BUTLER_LATEST }} + + - if: steps.cache-butler.outputs.cache-hit == 'true' + name: Make sure butler is executable + shell: bash + run: | + chmod +x $BUTLER_INSTALL_PATH/butler + + - if: steps.cache-butler.outputs.cache-hit != 'true' + name: Install butler + shell: bash + run: | + mkdir -p $BUTLER_INSTALL_PATH + cd $BUTLER_INSTALL_PATH + + curl -L -o butler.zip https://broth.itch.ovh/butler/$OS_AND_ARCH/LATEST/archive/default + unzip butler.zip + chmod +x butler + + - name: Upload game to itch.io + shell: bash + run: | + echo "TIMER_UPLOAD=$(date +%s)" >> "$GITHUB_ENV" + butler login + butler push $BUILD_DIR $ITCH_TAG + echo "TIMER_DONE=$(date +%s)" >> "$GITHUB_ENV" + + - name: Print debug info + shell: bash + run: | + cat << EOF + butler: + version: $( + if [[ "$BUTLER_CURRENT" == "$BUTLER_LATEST" ]] + then + echo $BUTLER_CURRENT + else + echo $BUTLER_CURRENT -> $BUTLER_LATEST + fi + ) + install: + took: $(($TIMER_UPLOAD-$TIMER_BUTLER))s + upload: + tag: $TARGET_ITCH/$GITHUB_REF_NAME + took: $(($TIMER_DONE-$TIMER_UPLOAD))s + EOF + cat << EOF >> "$GITHUB_STEP_SUMMARY" + ### open in launcher: [$TARGET_ITCH/$GITHUB_REF_NAME](https://run.funkin.me/$TARGET_ITCH/$GITHUB_REF_NAME) + EOF diff --git a/.github/changed-lines-count-labeler.yml b/.github/changed-lines-count-labeler.yml new file mode 100644 index 00000000..9637d9d1 --- /dev/null +++ b/.github/changed-lines-count-labeler.yml @@ -0,0 +1,22 @@ +# Add 'size: tiny' to any changes of at most 4 lines +'size: tiny': + max: 4 + +# Add 'size: small' to any changes between 5 and 10 lines +'size: small': + min: 5 + max: 10 + +# Add 'size: medium' to any changes between 11 and 100 lines +'size: medium': + min: 11 + max: 100 + +# Add 'size: large' to any changes between 101 and 500 lines +'size: large': + min: 101 + max: 500 + +# Add 'size: huge' to any changes of more than 500 lines +'size: huge': + min: 501 diff --git a/.github/hooks/README.md b/.github/hooks/README.md new file mode 100644 index 00000000..544fbf36 --- /dev/null +++ b/.github/hooks/README.md @@ -0,0 +1,5 @@ +# Git Hooks +These work even on Windows because of Git Bash. + +## Setup +`git config core.hooksPath .github/hooks` diff --git a/.github/hooks/post-checkout b/.github/hooks/post-checkout new file mode 100644 index 00000000..12358c99 --- /dev/null +++ b/.github/hooks/post-checkout @@ -0,0 +1,2 @@ +#!/bin/sh +git submodule update --init --recursive diff --git a/.github/hooks/post-merge b/.github/hooks/post-merge new file mode 100644 index 00000000..12358c99 --- /dev/null +++ b/.github/hooks/post-merge @@ -0,0 +1,2 @@ +#!/bin/sh +git submodule update --init --recursive diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push new file mode 100644 index 00000000..ec4c820a --- /dev/null +++ b/.github/hooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +if git diff --cached --submodule | grep -q "^+"; then + echo "WARNING: You have unpushed changes in submodules." + exit 1 +fi diff --git a/.github/label-actions.yml b/.github/label-actions.yml new file mode 100644 index 00000000..ef7448ab --- /dev/null +++ b/.github/label-actions.yml @@ -0,0 +1,157 @@ +# Configuration for Label Actions - https://github.com/dessant/label-actions + +# Automatically close issues and pull requests when the `status: duplicate` label is applied +'status: duplicate': + issues: + # Post a comment + comment: > + This issue is a duplicate. Please direct all discussion to the original issue. + # Close the issue + close: false + # Set a close reason + # close-reason: 'not planned' + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: rejected' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + - 'status: stale' + prs: + # Post a comment + comment: > + This pull request is a duplicate. Please direct all discussion to the original pull request. + # Close the pull request + close: false + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: rejected' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + - 'status: stale' + +'status: stale': + issues: + # Close the issue + close: true + # Set a close reason + close-reason: 'not planned' + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: duplicate' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: rejected' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + prs: + # Close the pull request + close: true + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: duplicate' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: rejected' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + +'status: rejected': + issues: + # Close the issue + close: true + # Set a close reason + close-reason: 'not planned' + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: duplicate' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + - 'status: stale' + prs: + # Close the pull request + close: true + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: duplicate' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: resolved' + - 'status: resolved internally' + - 'status: reviewing internally' + - 'status: stale' + +'status: resolved': + issues: + # Close the issue + close: true + # Set a close reason + close-reason: 'completed' + # Remove other status labels + unlabel: + - 'status: accepted' + - 'status: bug reproduced' + - 'status: cannot reproduce' + - 'status: duplicate' + - 'status: needs clarification' + - 'status: needs r&d' + - 'status: needs revision' + - 'status: pending pull request' + - 'status: pending triage' + - 'status: planned' + - 'status: rejected' + - 'status: resolved internally' + - 'status: reviewing internally' + - 'status: stale' diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..414df5a3 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,18 @@ +# Add Documentation tag to PR's changing markdown files, or anything in the docs folder +'pr: documentation': +- changed-files: + - any-glob-to-any-file: + - docs/* + - '**/*.md' + +# Add Haxe tag to PR's changing haxe code files +'pr: haxe': +- changed-files: + - any-glob-to-any-file: '**/*.hx' + +# Add GitHub tag to PR's changing yml files, or anything in the .github folder +'pr: github': +- changed-files: + - any-glob-to-any-file: + - github/* + - '**/*.yml' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..18880d43 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ + + + +## Linked Issues + + +## Description + + +## Screenshots/Videos diff --git a/.github/workflows/build-game.yml b/.github/workflows/build-game.yml new file mode 100644 index 00000000..9d75191f --- /dev/null +++ b/.github/workflows/build-game.yml @@ -0,0 +1,250 @@ +name: Build and Upload nightly game builds + +on: + workflow_dispatch: + inputs: + build-defines: + type: string + description: Build defines to use + default: '-DGITHUB_BUILD' + save-artifact: + type: boolean + description: Save the build artifact to Github Actions (sends to itch otherwise) + default: false + push: + +jobs: + + gather-changes: + runs-on: build-set + outputs: + trigger-build: ${{ steps.should-trigger.outputs.result }} + steps: + - name: Checkout repo + uses: funkincrew/ci-checkout@v6 + with: + submodules: false + - uses: dorny/paths-filter@v3 + id: filter + with: + base: ${{ github.ref }} + filters: | + docker: + - '.github/workflows/build-game.yml' + - '**/Dockerfile' + - uses: actions/github-script@v7 + id: should-trigger + with: + result-encoding: string + script: | + const { payload } = context + const changes = ${{ steps.filter.outputs.changes }} + const manual = payload.commits + .some(c => c.message.toLowerCase().replace(/\W/g, " ").includes("docker rebuild")) + + console.log({ payload, changes, manual, commits: payload.commits }) + + return payload.created + || payload.forced + || changes.includes("docker") + || manual + + gather-tags: + runs-on: build-set + outputs: + primary: ${{ steps.build-tags.outputs.primary }} + list: ${{ steps.build-tags.outputs.list }} + steps: + - name: Gather build tags + uses: actions/github-script@v7 + id: build-tags + with: + script: | + const base = "ghcr.io/funkincrew/build-dependencies" + const default_branch = "rewrite/master" + + const ref_path = context.ref.split("/").slice(2) + const ref = ref_path.join("/") + const ref_tag = ref_path.join("-") + + const tags = [ref_tag, context.sha] + + if (ref === default_branch) tags.push("latest") + + console.log([ + `ref: ${ref}`, + `default_branch: ${default_branch}`, + `tags: ${tags.join(", ")}` + ].join('\n')) + + const tag_list = tags + .map(tag => `${base}:${tag}`) + .join("\n") + + core.setOutput("primary", ref_tag) + core.setOutput("list", tag_list) + + docker-image: + needs: [gather-changes, gather-tags] + if: needs.gather-changes.outputs.trigger-build == 'true' + runs-on: build-set + permissions: + contents: read + packages: write + steps: + - name: Checkout repo + uses: funkincrew/ci-checkout@v6 + with: + submodules: false + - name: Log into GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./build + push: true + tags: ${{ needs.gather-tags.outputs.list }} + labels: | + org.opencontainers.image.description=precooked haxe build dependencies + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.title=${{ github.repository_owner }}/build-dependencies + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ github.sha }} + + build-game-on-host: + strategy: + fail-fast: false + matrix: + include: + - target: windows + runs-on: windows + - target: macos + runs-on: macos + runs-on: + - ${{ matrix.runs-on }} + defaults: + run: + shell: bash + env: + BUILD_DEFINES: ${{ github.event.inputs.build-defines || '-DGITHUB_BUILD' }} + steps: + - name: Make git happy + run: | + git config --global --replace-all safe.directory $GITHUB_WORKSPACE + - name: Get checkout token + uses: actions/create-github-app-token@v1 + id: app_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + owner: ${{ github.repository_owner }} + - name: Checkout repo + uses: funkincrew/ci-checkout@v6 + with: + submodules: 'recursive' + token: ${{ steps.app_token.outputs.token }} + persist-credentials: false + - name: Setup build environment + uses: ./.github/actions/setup-haxe + with: + gh-token: ${{ steps.app_token.outputs.token }} + - name: Setup HXCPP dev commit + run: | + cd .haxelib/hxcpp/git/tools/hxcpp + haxe compile.hxml + cd ../../../../.. + - name: Build game (windows) + if: ${{ matrix.target == 'windows' }} + run: | + haxelib run lime build windows -v -release $BUILD_DEFINES + timeout-minutes: 120 + - name: Build game (unix) + if: ${{ matrix.target != 'windows' }} + run: | + haxelib run lime build ${{ matrix.target }} -v -release --times $BUILD_DEFINES + timeout-minutes: 120 + - name: Save build artifact to Github Actions + if: ${{ github.event.inputs.save-artifact }} + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.target }} + path: export/release/${{matrix.target}}/bin/ + - name: Upload build artifacts + uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + target: ${{ matrix.target }} + + build-game-in-container: + needs: [gather-tags, docker-image] + if: ${{ ! cancelled() }} + runs-on: build-set + container: ghcr.io/funkincrew/build-dependencies:${{ needs.gather-tags.outputs.primary }} + strategy: + matrix: + include: + - target: linux + - target: html5 + defaults: + run: + shell: bash + env: + BUILD_DEFINES: ${{ github.event.inputs.build-defines || '-DGITHUB_BUILD' }} + steps: + - name: Get checkout token + uses: actions/create-github-app-token@v1 + id: app_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PEM }} + owner: ${{ github.repository_owner }} + - name: Checkout repo + uses: funkincrew/ci-checkout@v6 + with: + submodules: 'recursive' + token: ${{ steps.app_token.outputs.token }} + persist-credentials: false + - name: Config haxelib + run: | + haxelib --never newrepo + echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV" + - name: Restore cached dependencies + id: cache-hmm + uses: actions/cache@v4 + with: + path: .haxelib + key: haxe-hmm-${{ runner.os }}-${{ hashFiles('**/hmm.json') }} + - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }} + name: Install dependencies + run: | + git config --global 'url.https://x-access-token:${{ steps.app_token.outputs.token }}@github.com/.insteadOf' https://github.com/ + git config --global advice.detachedHead false + haxelib --global run hmm install -q + cd .haxelib/hxcpp/git/tools/hxcpp && haxe compile.hxml + - if: ${{ matrix.target != 'html5' }} + name: Restore hxcpp cache + uses: actions/cache@v4 + with: + path: /usr/share/hxcpp + key: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: haxe-hxcpp-${{ runner.os }}-${{ github.ref_name }} + - name: Build game + run: | + haxelib run lime build ${{ matrix.target }} -v -release --times $BUILD_DEFINES + timeout-minutes: 120 + - name: Save build artifact to Github Actions + if: ${{ github.event.inputs.save-artifact }} + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.target }} + path: export/release/${{matrix.target}}/bin/ + - name: Upload build artifacts + uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + target: ${{ matrix.target }} diff --git a/.github/workflows/cancel-merged-branches.yml b/.github/workflows/cancel-merged-branches.yml new file mode 100644 index 00000000..254b21a2 --- /dev/null +++ b/.github/workflows/cancel-merged-branches.yml @@ -0,0 +1,38 @@ +name: Cancel queued workflows on PR merge + +on: + pull_request: + types: + - closed + +jobs: + + cancel_stuff: + if: github.event.pull_request.merged == true + runs-on: build-set + permissions: + actions: write + + steps: + - name: Cancel queued workflows for ${{ github.event.pull_request.head.ref }} + uses: actions/github-script@v7 + with: + result-encoding: string + retries: 3 + script: | + let branch_workflows = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: "build-shit.yml", + status: "queued", + branch: "${{ github.event.pull_request.head.ref }}" + }); + let runs = branch_workflows.data.workflow_runs; + runs.forEach((run) => { + github.rest.actions.cancelWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id + }); + }); + console.log(runs); diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml new file mode 100644 index 00000000..bc4cf0ad --- /dev/null +++ b/.github/workflows/label-actions.yml @@ -0,0 +1,29 @@ +# Perform actions when labels are applied to issues, discussions, or pull requests +# See .github/label-actions.yml +name: 'Label Actions' + +on: + issues: + types: + - labeled + - unlabeled + pull_request_target: + types: + - labeled + - unlabeled + discussion: + types: + - labeled + - unlabeled + +permissions: + contents: read + issues: write + pull-requests: write + discussions: write + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/label-actions@v4 diff --git a/.github/workflows/label-pull-request-on-create.yml b/.github/workflows/label-pull-request-on-create.yml new file mode 100644 index 00000000..94620657 --- /dev/null +++ b/.github/workflows/label-pull-request-on-create.yml @@ -0,0 +1,19 @@ +name: "Pull Request Labeler 2 (Runs on PR creation)" +on: + pull_request_target: + types: + - opened + +jobs: + # Apply `status: pending triage` to newly created pull requests + apply-pending-triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Apply "status pending triage" to new pull requests + uses: actions-ecosystem/action-add-labels@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + labels: "status: pending triage" diff --git a/.github/workflows/label-pull-request.yml b/.github/workflows/label-pull-request.yml new file mode 100644 index 00000000..525e1b5a --- /dev/null +++ b/.github/workflows/label-pull-request.yml @@ -0,0 +1,29 @@ +name: "Pull Request Labeler" +on: +- pull_request_target + +jobs: + # Apply labels to pull requests based on which files were edited + labeler: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Set basic labels + uses: actions/labeler@v5 + with: + sync-labels: true + # Apply labels to pull requests based on how many lines were edited + changed-lines-count-labeler: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + name: An action for automatically labelling pull requests based on the changed lines count + steps: + - name: Set change count labels + uses: vkirilichev/changed-lines-count-labeler@v0.2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/changed-lines-count-labeler.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ec6b5997 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_STORE +.haxelib/ +.vs/ +APIStuff.hx +dump/ +export/ +project/ +RECOVER_*.fla +shitAudio/ +.build_time +.swp +NewgroundsCredentials.hx + +# Exclude JS stuff +node_modules/ +package.json +package-lock.json +.aider* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..f9d5fd49 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "assets"] + path = assets + url = https://github.com/FunkinCrew/Funkin.assets +[submodule "art"] + path = art + url = https://github.com/FunkinCrew/Funkin.art diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..ccf886f1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# Ignore artifacts +export + +# Ignore all JSONS in the images folder (including FlxAnimate JSONs) +assets/preload/images +assets/shared/images +assets/weekend1/images + +# Don't ignore data files +# TODO: These don't work. +!assets/preload/data/ + +assets/exclude/data/credits.json diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..2939c1c7 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,21 @@ +/** + * Configuration for JSON file formatting. + */ +module.exports = { + // Line width before Prettier tries to add new lines. + printWidth: 80, + + // Indent with 2 spaces. + tabs: false, + useTabs: false, + tabWidth: 2, + + // Use double quotes. + singleQuote: false, + quoteProps: "preserve", + parser: "json", + + bracketSpacing: true, // Spacing between brackets in object literals. + trailingComma: "none", // No trailing commas. + semi: false, // No semicolons at ends of statements. +}; \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..483db9ea --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "nadako.vshaxe", // Basic Haxe integration + "wiggin77.codedox", // Haxe documentation + "vshaxe.haxe-checkstyle", // Haxe code style and conventions + "vshaxe.hxcpp-debugger", // CPP debugging + "openfl.lime-vscode-extension", // Lime integration + "esbenp.prettier-vscode", // JSON formatting + "redhat.vscode-xml" // XML formatting + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6dc1dc00 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Launch in native/CPP on Windows/OSX/Linux + "name": "Lime Build+Debug", + "type": "lime", + "request": "launch" + }, + { + // Launch in native/CPP on Windows/OSX/Linux + "name": "Lime Debug (No Build)", + "type": "lime", + "request": "launch", + "preLaunchTask": null + }, + { + // Launch in browser + "name": "HTML5 Debug", + "type": "chrome", + "request": "launch", + "url": "http://127.0.0.1:3001", + "sourceMaps": true, + "webRoot": "${workspaceFolder}", + "preLaunchTask": "debug: html" + }, + { + // Performs a build for debugging macros + // Debugging is attached on the compilation rather than the game itself + "name": "Haxe Eval", + "type": "haxe-eval", + "request": "launch" + }, + { + // Attaches the debugger to an already running game + "name": "HXCPP - Attach", + "type": "hxcpp", + "request": "attach" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0685989d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,229 @@ +{ + "[haxe]": { + // Automatically keep Haxe files formatted. + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "never" + }, + "editor.defaultFormatter": "nadako.vshaxe", + "editor.tabSize": 2 + }, + + "[json]": { + // Automatically keep JSON files formatted. + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + + "[jsonc]": { + // Automatically keep JSONC files formatted. + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "prettier.tabWidth": 2, + + // XML formatting style configuration + "xml.format.enabled": true, + "xml.format.legacy": false, + "xml.format.emptyElements": "collapse", + "xml.preferences.quoteStyle": "double", + "xml.format.enforceQuoteStyle": "preferred", + "xml.format.preserveAttributeLineBreaks": false, + "xml.format.preservedNewlines": 0, + "xml.format.splitAttributes": false, + "xml.format.joinCDATALines": true, + "xml.format.preserveEmptyContent": false, + "xml.format.joinCommentLines": false, + "xml.format.joinContentLines": false, + "xml.format.spaceBeforeEmptyCloseTag": true, + "xml.format.xsiSchemaLocationSplit": "onPair", + "xml.format.splitAttributesIndentSize": 2, + "xml.format.closingBracketNewLine": false, + "xml.format.preserveSpace": [ + "xsl:text", + "xsl:comment", + "xsl:processing-instruction", + "literallayout", + "programlisting", + "screen", + "synopsis", + "pre", + "xd:pre" + ], + "xml.format.maxLineWidth": 0, + "xml.format.grammarAwareFormatting": true, + + // Generic file formatting style configuration + "files.insertFinalNewline": true, + "files.trimFinalNewlines": false, + "files.trimTrailingWhitespace": true, + + // Automatically detect indentation. + "editor.detectIndentation": true, + "editor.insertSpaces": true, + "editor.tabSize": 2, + + // Automatically enforce Linux style line endings. + "files.eol": "\n", + + "haxe.displayPort": "auto", + "haxe.enableCompilationServer": true, + "haxe.enableServerView": true, + "haxe.displayServer": { + "arguments": ["-v"] + }, + // Fix file associations for HScript. + "files.associations": { + "*.hxp": "haxe", + "*.hscript": "haxe", + "*.haxe": "haxe", + "*.hxs": "haxe", + "*.hxc": "haxe" + }, + "projectManager.git.baseFolders": ["./"], + + "haxecheckstyle.sourceFolders": ["src", "source"], + "haxecheckstyle.externalSourceRoots": [], + "haxecheckstyle.configurationFile": "checkstyle.json", + "haxecheckstyle.codeSimilarityBufferSize": 100, + + "lime.targetConfigurations": [ + { + "label": "Windows / Debug (Discord)", + "target": "windows", + "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS", "-DFEATURE_DISCORD_RPC"] + }, + { + "label": "Windows / Debug (FlxAnimate Test)", + "target": "windows", + "args": ["-debug", "-DANIMATE", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (FlxAnimate Test)", + "target": "hl", + "args": ["-debug", "-DANIMATE"] + }, + { + "label": "Windows / Debug (Straight to Freeplay)", + "target": "windows", + "args": ["-debug", "-DFREEPLAY", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (Straight to Freeplay)", + "target": "hl", + "args": ["-debug", "-DFREEPLAY"] + }, + { + "label": "Windows / Debug (Straight to Play - Bopeebo Normal)", + "target": "windows", + "args": [ + "-debug", + "-DSONG=bopeebo -DDIFFICULTY=normal", + "-DFEATURE_DEBUG_FUNCTIONS" + ] + }, + { + "label": "Windows / Debug (Straight to Play - 2hot)", + "target": "windows", + "args": ["-debug", "-DSONG=2hot", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (Straight to Play - Bopeebo Normal)", + "target": "hl", + "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"] + }, + { + "label": "Windows / Debug (Conversation Test)", + "target": "windows", + "args": ["-debug", "-DDIALOGUE", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (Conversation Test)", + "target": "hl", + "args": ["-debug", "-DDIALOGUE"] + }, + { + "label": "Windows / Debug (Results Screen Test)", + "target": "windows", + "args": ["-debug", "-DRESULTS"] + }, + { + "label": "Windows / Debug (Straight to Animation Editor)", + "target": "windows", + "args": ["-debug", "-DANIMDEBUG", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "Windows / Debug (Debug hxvlc)", + "target": "windows", + "args": ["-debug", "-DHXC_LIBVLC_LOGGING", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (Straight to Animation Editor)", + "target": "hl", + "args": ["-debug", "-DANIMDEBUG"] + }, + { + "label": "Windows / Debug (Latency Test)", + "target": "windows", + "args": ["-debug", "-DLATENCY", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "HashLink / Debug (Latency Test)", + "target": "hl", + "args": ["-debug", "-DLATENCY"] + }, + { + "label": "Windows / Debug (Waveform Test)", + "target": "windows", + "args": ["-debug", "-DWAVEFORM", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "Windows / Release (GitHub Actions)", + "target": "windows", + "args": ["-release", "-DGITHUB_BUILD"] + }, + { + "label": "HashLink / Debug (Waveform Test)", + "target": "hl", + "args": ["-debug", "-DWAVEFORM"] + }, + { + "label": "HTML5 / Debug (Watch)", + "target": "html5", + "args": ["-debug", "-watch", "-DFEATURE_DEBUG_FUNCTIONS"] + } + ], + "lime.buildTypes": [ + { + "label": "Debug", + "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "Debug (Tracy)", + "args": ["-debug", "-DFEATURE_DEBUG_TRACY", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "Debug (Straight to Chart Editor)", + "args": ["-debug", "-DCHARTING", "-DFEATURE_DEBUG_FUNCTIONS"] + }, + { + "label": "Release", + "args": ["-release"] + }, + { + "label": "Release (GitHub Actions)", + "args": ["-release", "-DGITHUB_BUILD"] + } + ], + "cmake.configureOnOpen": false, + "coverage-gutters.coverageFileNames": [ + "lcov.info", + "cov.xml", + "coverage.xml", + "jacoco.xml", + "coverage.cobertura.xml" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..be4414ea --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "lime", + "command": "test", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..44be36d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,995 @@ +# Changelog +All notable changes will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [0.6.4] - 2025-05-02 + +### Changed + +- Misses now actually reduce your clear percentage and rank, as Eric intended. ([5fdbd23](https://github.com/FunkinCrew/Funkin/commit/5fdbd23a17b8eaf21582283400be2c6444e8b198)) - by @Lasercar in [#4880](https://github.com/FunkinCrew/Funkin/pull/4880) +- The miss rebalance is now reflected on the Freeplay clear percent display. ([f6ac4ca](https://github.com/FunkinCrew/Funkin/commit/f6ac4cad43f4834a86a3fc156d6d39eca2c47c7e)) - by @Lasercar, @Hundrec, and @NotHyper-474 in [#4898](https://github.com/FunkinCrew/Funkin/pull/4898) and [#4923](https://github.com/FunkinCrew/Funkin/pull/4923) + +### Fixed + +- HTML5 builds are no longer literally unplayable. ([6f43438](https://github.com/FunkinCrew/Funkin/commit/6f43438cdbfecf26d4bcf62a0d9ff71e40333ac2)) - by @cyn0x8 in [#4398](https://github.com/FunkinCrew/Funkin/pull/4398) +- Medals no longer crash the game when obtained. ([808698a](https://github.com/FunkinCrew/Funkin/commit/808698ace7457bc76a8a52d415d63fa4ee5b9400)) - by @KoloInDaCrib in [#4815](https://github.com/FunkinCrew/Funkin/pull/4815) +- The game no longer crashes when reaching the main menu before authenticating with Newgrounds. ([a83a4a5](https://github.com/FunkinCrew/Funkin/commit/a83a4a599c2e407832ef9bbe47bb8b8c07ce712a)) - by @NotHyper-474 in [#4871](https://github.com/FunkinCrew/Funkin/pull/4871) +- User preferences and other settings are now properly saved after restarting the game. ([23a9e7f](https://github.com/FunkinCrew/Funkin/commit/23a9e7f944fb703160c2ed7b0bef38c70c5370db)) - by @Lasercar in [#4881](https://github.com/FunkinCrew/Funkin/pull/4881) +- Texture atlas sprites no longer loop infinitely, fixing issues with multiple cutscenes. ([f385cf9](https://github.com/FunkinCrew/Funkin/commit/f385cf9fd33e5358427f40dad3fecfed233c1706)) - by @AbnormalPoof in [#4564](https://github.com/FunkinCrew/Funkin/pull/4564) +- The Week 3 Pico Mix cutscene no longer breaks with Naughtyness turned off. ([e3aba5c](https://github.com/FunkinCrew/funkin.assets/commit/e3aba5c4faa8e8c705c3e0daf04e30db7ac5a136)) - by @Lasercar in [funkin.assets#169](https://github.com/FunkinCrew/funkin.assets/pull/169) +- Adjusted the pixel strumline’s position when Downscroll is enabled to match the regular notestyle. ([ea93ec9](https://github.com/FunkinCrew/Funkin/commit/ea93ec90cb5c5dbd4a5bea64114af58c70cb43f4)) - by @Lasercar in [#4318](https://github.com/FunkinCrew/Funkin/pull/4318) +- The Main Menu now correctly remembers your most recently selected menu item. ([05b9d68](https://github.com/FunkinCrew/Funkin/commit/05b9d68645b71c9cc36a5cb0c0e93af6acb36132)) - by @Lasercar and @Hundrec in [#4227](https://github.com/FunkinCrew/Funkin/pull/4227) +- Fixed an occasional softlock when returning to the Freeplay menu from a song. ([b15e809](https://github.com/FunkinCrew/Funkin/commit/b15e809c63664e039781592dcbdee4383e73800e)) - by @TechnikTil in [#4665](https://github.com/FunkinCrew/Funkin/pull/4665) +- Switching to and from the Freeplay Random capsule now behaves more consistently. ([832bc5b](https://github.com/FunkinCrew/Funkin/commit/832bc5bd83d49a646b9c8cb2b767602c848be908)) - by @VioletSnowLeopard in [#4885](https://github.com/FunkinCrew/Funkin/pull/4885) +- Changing Freeplay filters with the Random capsule selected now behaves more consistently. ([edf6889](https://github.com/FunkinCrew/Funkin/commit/edf6889af9e7f68caead9b8139178a36b4b5b2d6)) - by @VioletSnowLeopard in [#4913](https://github.com/FunkinCrew/Funkin/pull/4913) +- Switching between identical Freeplay filters no longer makes the songlist invisible. ([8b9775d](https://github.com/FunkinCrew/Funkin/commit/8b9775d91f4f2b5896a637a014b4be770319968e)) - by @VioletSnowLeopard in [#4919](https://github.com/FunkinCrew/Funkin/pull/4919) +- The millions place digit of the Freeplay score display now updates properly. ([d9fcaf0](https://github.com/FunkinCrew/Funkin/commit/d9fcaf0e6382b121f7773d23f4864364a1e6a577)) - by @Lasercar in [#4065](https://github.com/FunkinCrew/Funkin/pull/4065) +- The Freeplay clear percent display is now more consistently aligned. ([329182e](https://github.com/FunkinCrew/funkin.assets/commit/329182ea1b6839187a1d800b2d009a4c1874479d)) - by @Hundrec in [funkin.assets#37](https://github.com/FunkinCrew/funkin.assets/pull/37) +- The Freeplay alternate instrumental selector now disables all other inputs. ([664d9e0](https://github.com/FunkinCrew/Funkin/commit/664d9e0fb3f8b93b1f941fb3b6724c37f5b9e5b5)) - @Lasercar in [#4214](https://github.com/FunkinCrew/Funkin/pull/4214) +- The Freeplay alternate instrumental selector no longer becomes offset when changing difficulties. ([664d9e0](https://github.com/FunkinCrew/Funkin/commit/664d9e0fb3f8b93b1f941fb3b6724c37f5b9e5b5)) - @Lasercar in [#4214](https://github.com/FunkinCrew/Funkin/pull/4214) +- Exiting and then navigating through the Freeplay alternate instrumental selector no longer crashes the game. ([664d9e0](https://github.com/FunkinCrew/Funkin/commit/664d9e0fb3f8b93b1f941fb3b6724c37f5b9e5b5)) - @Lasercar in [#4214](https://github.com/FunkinCrew/Funkin/pull/4214) +- Ranks no longer overwrite themselves in the Freeplay new rank animation. ([664d9e0](https://github.com/FunkinCrew/Funkin/commit/664d9e0fb3f8b93b1f941fb3b6724c37f5b9e5b5)) - @Lasercar in [#4214](https://github.com/FunkinCrew/Funkin/pull/4214) +- The debris in Pico’s Great Results animation no longer grows in size. ([c108a7f](https://github.com/FunkinCrew/funkin.assets/commit/c108a7ff0d11bf328e7b232160b8f68c71e21bca)) - by @ThatRozebudDude in [funkin.assets#73](https://github.com/FunkinCrew/funkin.assets/pull/73) +- Fixed the song not starting if more than 32 sounds are playing at once. ([31d3718](https://github.com/FunkinCrew/Funkin/commit/31d3718e5c33371cacd495219f9cc3908244bf71)) - by @KoloInDaCrib in [#4352](https://github.com/FunkinCrew/Funkin/pull/4352) +- Hot reloading (pressing F5) during dialogue no longer crashes the game. ([3e0dbe2](https://github.com/FunkinCrew/Funkin/commit/3e0dbe2758d68ccf4ded1d08bec247ab05d70829)) - by @KoloInDaCrib in [#4769](https://github.com/FunkinCrew/Funkin/pull/4769) +- Restarting a song without either a player or an opponent no longer crashes the game, fixing minimal playtest mode. ([bbc0546](https://github.com/FunkinCrew/Funkin/commit/bbc0546c7450b929dc78ad9271171ea899478e9d)) - by @thesuperpig56 in [#4778](https://github.com/FunkinCrew/Funkin/pull/4778) +- The sound tray no longer behaves incorrectly after wiping save data. ([afbb335](https://github.com/FunkinCrew/Funkin/commit/afbb3359938f020707efd92a17419e3658528ea5)) - by @Lasercar in [#4617](https://github.com/FunkinCrew/Funkin/pull/4617) +- Opening a chart from the “Open Recent” section no longer crashes the Chart Editor. ([def1b74](https://github.com/FunkinCrew/Funkin/commit/def1b74eb6987272bc360cbf586ddcd1c26c6381)) - by @CrusherNotDrip in [#4936](https://github.com/FunkinCrew/Funkin/pull/4936) +- Loading an invalid variation in the Chart Editor no longer crashes the game. ([c5f2a74](https://github.com/FunkinCrew/Funkin/commit/c5f2a744940dbc685b25373fa2dabb548f0b26fa)) - by @NotHyper-474 in [#4391](https://github.com/FunkinCrew/Funkin/pull/4391) +- Default difficulties are no longer re-added when saving and loading a .FNFC chart. ([8074d57](https://github.com/FunkinCrew/Funkin/commit/8074d571860aba2c2f6f1c642d8d0e671ff5355e)) - by @Lasercar in [#4216](https://github.com/FunkinCrew/Funkin/pull/4216) +- The Chart Editor now resizes properly after entering Fullscreen during a playtest. ([142ea6d](https://github.com/FunkinCrew/Funkin/commit/142ea6db0c53f6f9288109604702c2e7e5bc6f18)) - by @NotHyper-474 in [#4266](https://github.com/FunkinCrew/Funkin/pull/4266) +- Added three missing hotkeys to the Chart Editor user guide. ([73ccb9a](https://github.com/FunkinCrew/funkin.assets/commit/73ccb9a80d08f3311f51cf88950692d7c1ea5d60)) - by @NotHyper-474 and @Hundrec in [funkin.assets#83](https://github.com/FunkinCrew/funkin.assets/pull/83) +- The “Skip Forward” button is no longer named “Skip Back” in the Chart Editor. ([78f9fb7](https://github.com/FunkinCrew/funkin.assets/commit/78f9fb7cb6eaf7466826f144a0142c008fbff2f8)) - by @Hundrec in [funkin.assets#22](https://github.com/FunkinCrew/funkin.assets/pull/22) +- Exporting a character .JSON from the Animation Editor now fills in the file name. ([a8262a8](https://github.com/FunkinCrew/Funkin/commit/a8262a8d3b4d2169327131e21c98f803da4a5ee4)) - by @anysad in [#3090](https://github.com/FunkinCrew/Funkin/pull/3090) +- Properly credited MtH as a Charter in Monster’s metadata. ([6a10799](https://github.com/FunkinCrew/funkin.assets/commit/6a10799a40eb2b2deb364bcd3cc4387abe8e8bc4)) - by @ChillyBeanBAM in [funkin.assets#66](https://github.com/FunkinCrew/funkin.assets/pull/66) +- Corrected the chances of a Pause Menu easter egg appearing. ([0101bae](https://github.com/FunkinCrew/Funkin/commit/0101bae7ecc800e26efd9590fdfa1305039b4461)) - by @VioletSnowLeopard in [#4358](https://github.com/FunkinCrew/Funkin/pull/4358) +- Removed spammy traces for Playable Pico’s animations. ([f87255d](https://github.com/FunkinCrew/funkin.assets/commit/f87255d4bcb7a7ce0dc920aefc36b98a96115c75)) - by @VioletSnowLeopard in [funkin.assets#179](https://github.com/FunkinCrew/funkin.assets/pull/179) +- Fixed memory overflowing in crash logs. ([189e028](https://github.com/FunkinCrew/Funkin/commit/189e028442c7fb892ce4dab62a5d08db658f3b2b)) - by @ACrazyTown in [#4589](https://github.com/FunkinCrew/Funkin/pull/4589) + +### Removed + +- The CHANGELOG.md file will no longer be included in builds. Visit the [Funkin' GitHub](https://github.com/FunkinCrew/Funkin/blob/main/CHANGELOG.md) to view the latest changes! ([a7da71c](https://github.com/FunkinCrew/Funkin/commit/a7da71c8a87baa440a0bc6e23d7e9b36c7574e14)) - by @Hundrec and @NotHyper-474 in [#4868](https://github.com/FunkinCrew/Funkin/pull/4868) + +## New Contributors for 0.6.4 + +* @thesuperpig56 made their first contribution in [#4778](https://github.com/FunkinCrew/Funkin/pull/4778) +* @CrusherNotDrip made their first contribution in [#4936](https://github.com/FunkinCrew/Funkin/pull/4936) +* @ChillyBeanBAM made their first contribution in [funkin.assets#66](https://github.com/FunkinCrew/funkin.assets/pull/66) +* @ThatRozebudDude made their first contribution in [funkin.assets#73](https://github.com/FunkinCrew/funkin.assets/pull/73) + + + +## [0.6.3] - 2025-04-25 + +### Added + +- New option in the Preferences menu: VSync! + - Set it to Off, On, or Adaptive + - Adaptive turns VSync off during FPS drops, which is better if supported by your graphics card. + - Doesn't work on Mac, sorry! +- Otis now has muzzle flashes when shooting. +- Added a little easter egg to one of Pico's Results Screen animations. +- Overhauled the sticker system to allow modders to easily add custom sticker sets. ([cdc468b](https://github.com/FunkinCrew/Funkin/commit/cdc468ba15683b3c0f54015e337673c77ddd7962)) - by @AbnormalPoof in [#4003](https://github.com/FunkinCrew/Funkin/pull/4003) +- Implemented a macro to optimize registries and entries. ([1497521](https://github.com/FunkinCrew/Funkin/commit/14975212a918adb1a5d96a349ec9e8e63c9fc64b)) - by @lemz1 in [#3694](https://github.com/FunkinCrew/Funkin/pull/3694) +- The macro is now used to retrieve base game assets. ([963e2ec](https://github.com/FunkinCrew/Funkin/commit/963e2ecd630fae24dae4206d315618cf4c4be6db)) - by @lemz1 in [#4707](https://github.com/FunkinCrew/Funkin/pull/4707) +- The Chart Editor now displays the current beat and step of the playhead. ([83bb3bb](https://github.com/FunkinCrew/Funkin/commit/83bb3bb5074ecb5f50ee21979711efcda9d8017d)) - by @NotHyper-474 in [#4649](https://github.com/FunkinCrew/Funkin/pull/4649) + +### Changed + +- The mod API version is now 0.6.3. + - Be sure to update the version in your mods' metadata, then ensure they are still working! +- Eric's scoring rebalance update! + - Misses (not ghost misses) are now worth -100 points, up from -10. + - Hold note trails now grant 20% less health. + - Dropping a hold note early now plays the miss animation and deducts health and score. +- Made a LOT of charting changes! + - Many missing, extra, and mistimed notes have been corrected. + - Stacked notes that were preventing full combos have been removed. + - Camera events have been adjusted to stay within the boundaries of the stage. + - Some songs have been recharted. +- The Freeplay menu can now always switch between variations. + - The nearest song with an Erect variation will be selected when switching. +- Switching difficulties now always plays the capsule jump-in animation. +- The "Random" capsule now plays animations consistent with other capsules. +- The Eggnog Erect cutscene can now be skipped. ([0303a03](https://github.com/FunkinCrew/funkin.assets/commit/1202651db8ea938fe11b6b734fbf7884d101e6ad)) - by @ShadzXD in [funkin.assets#62](https://github.com/FunkinCrew/funkin.assets/pull/62) +- Swapped the positions of “skip” and “restart” items in the dialogue pause menu for consistency. ([1f88a3b](https://github.com/FunkinCrew/Funkin/commit/1f88a3b6e13e9283ad680805deab5e27ba141d96)) - by @VioletSnowLeopard in [#4553](https://github.com/FunkinCrew/Funkin/pull/4553) +- The Controls menu now prevents unbinding essential UI controls to safeguard against softlocking save files. ([7eebce4](https://github.com/FunkinCrew/Funkin/commit/7eebce432d7627a1cf0bbd43f22a6f9bcb63ff65)) - by @VioletSnowLeopard in [#4382](https://github.com/FunkinCrew/Funkin/pull/4382) +- Renamed “Auto Pause” preference to “Pause on Unfocus” for clarity. ([52be941](https://github.com/FunkinCrew/Funkin/commit/52be941b4503da0ac76918e2482ab1804866f2cf)) - by @JackXson-Real in [#4346](https://github.com/FunkinCrew/Funkin/pull/4346) +- Overhauled `FileUtil`, introducing various fixes, new functions, and sandboxing. ([95ade2a](https://github.com/FunkinCrew/Funkin/commit/95ade2a08b7709e8208ec1b3e123bf5b4308ba10)) - by @cyn0x8 in [#3032](https://github.com/FunkinCrew/Funkin/pull/3032) + +### Fixed + +- Story mode medals are now awarded even without scoring a new personal best. +- Highscores are now submitted to Newgrounds even without scoring a new personal best. +- The Freeplay menu no longer displays songs without Erect variations when returning from an Erect variation song. +- Fixed Freeplay DJ animations for Boyfriend and Pico when idling (properly this time). +- Alternate instrumentals for Cocoa, Senpai, Roses, and Stress are now locked until their Pico Mix is beaten. +- The Roses Pico alternate instrumental is now offset to align with the original song's voices. +- Entering Stress or Stress (Pico Mix) a second time no longer crashes the game. +- Restarting Stress or Stress (Pico Mix) no longer spawns too many Tankmen in the background. +- Pico's game over animation now displays properly on Ugh (Pico Mix) and Guns (Pico Mix). +- Tankman's death lines now play for Boyfriend in Ugh, Guns, and Ugh Erect. +- Week 6 dialogue no longer plays for all non-default variations when entered through Freeplay. +- Nene (Pixel) now plays her knife raising animation when the player has low health. +- A-Bot's visualizer is now blank before the song starts. +- Debug editor tooltips no longer display behind windows. +- Clearing save data no longer crashes the game. +- Adding custom parameters to note kinds no longer crashes the Chart Editor. ([0a7bd31](https://github.com/FunkinCrew/Funkin/commit/0a7bd3111f59efbecfe097f8bbcfdefa5ace299d)) - by @Lasercar in [funkin.assets#136](https://github.com/FunkinCrew/funkin.assets/pull/136) +- Improved performance in the Freeplay menu before entering a song. ([2b7254f](https://github.com/FunkinCrew/Funkin/commit/2b7254fbd2356e9da91e216f178a88f17874a6eb)) - by @superpowers04 in [#4729](https://github.com/FunkinCrew/Funkin/pull/4729) +- Prevented a crash by capping Freeplay and Results screen score displays at their largest possible values (actual scores are not capped). ([51324e9](https://github.com/FunkinCrew/Funkin/commit/51324e9c283c43ca861d3369ba9b3e1db9c89765)) - by @KoloInDaCrib in [#3634](https://github.com/FunkinCrew/Funkin/pull/3634) +- The Animation Editor no longer crashes when opened. ([b40b4b0](https://github.com/FunkinCrew/Funkin/commit/b40b4b03692910afceff361cf6ca3298fd787e3c)) - by @sector-a in [#4582](https://github.com/FunkinCrew/Funkin/pull/4582) +- Disabled navigation in the Options menu while the Clear Save Data prompt is open. ([855deb5](https://github.com/FunkinCrew/Funkin/commit/855deb58280e850c0ad1581807c70f05d6b6a2cb)) - by @KoloInDaCrib in [#4703](https://github.com/FunkinCrew/Funkin/pull/4703) +- Save data is no longer wiped after failing to load the current save. ([068c9fb](https://github.com/FunkinCrew/Funkin/commit/068c9fb43d49ff6ea13e5a73f77a42207954be2c)) - by @KoloInDaCrib in [#4574](https://github.com/FunkinCrew/Funkin/pull/4574) +- Selecting a different type of event in the Chart Editor no longer crashes or resets the event to its default values. ([71ed154](https://github.com/FunkinCrew/Funkin/commit/71ed154b81abbd002d78c09c2dada8a2ad3fa73e)) - by @KoloInDaCrib in [#3913](https://github.com/FunkinCrew/Funkin/pull/3913) +- Deleting a stacked event in the Chart Editor now properly removes the deleted event’s sprite. ([3ad14ba](https://github.com/FunkinCrew/Funkin/commit/3ad14bac32096e45259997ab9957bec5fecf296f)) - by @NotHyper-474 in [#4724](https://github.com/FunkinCrew/Funkin/pull/4724) +- Patched a security vulnerability. ([53dec05](https://github.com/FunkinCrew/Funkin/commit/53dec057bee0a913d60e0c70d45dbb59a58620b0)) - by @nebulazorua in [#4740](https://github.com/FunkinCrew/Funkin/pull/4740) +- Added null safety to a bunch of classes. +- Even more tiny bug fixes. + +## New Contributors for 0.6.3 + +* @JackXson-Real made their first contribution in [#4346](https://github.com/FunkinCrew/Funkin/pull/4346) +* @VioletSnowLeopard made their first contribution in [#4382](https://github.com/FunkinCrew/Funkin/pull/4382) +* @superpowers04 made their first contribution in [#4729](https://github.com/FunkinCrew/Funkin/pull/4729) +* @ShadzXD made their first contribution in [#62](https://github.com/FunkinCrew/Funkin/pull/4729) + + + +## [0.6.2] - 2025-03-31 + +### Added + +- Updated the 0.6 credits list + +### Fixed + +- Additional shader fix for Stress (Pico Mix) crashing at the end (was the same issue as Senpai Pico shader error, just in a different shaderfile) + + + +## [0.6.1] - 2025-03-31 + +### Fixed + +- Hopefully Senpai Pico/Erect mix shader isn't brokey +- NG API encryption key was added proper, so medals + leaderboards posting should work + + + +## [0.6.0] - 2025-03-31 +The Pit Stop 2 update! + +### Added + +- Added six (!) new playable songs! Check them out in the Freeplay menu for their respective characters. + - Cocoa (Pico Mix) + - Senpai (Pico Mix) + - Roses (Pico Mix) + - Stress (Pico Mix) + - Darnell Erect + - Lit Up (BF Mix) +- Senpai (Pico Mix) and Roses (Pico Mix) take place on a new Erect variant of the Week 6 stage! + - This stage is now used by Senpai Erect, Roses Erect, and Thorns Erect. +- Stress (Pico Mix) takes place on a new Erect variant of the Week 7 stage! + - This stage is now used by Ugh Erect, Ugh (Pico Mix), and Guns (Pico Mix). +- Newly revamped Newgrounds integration! You can now earn Medals and submit scores to the Leaderboards while logged in on Newgrounds! + - Existing medals have been re-enabled, and new medals have been added! Think you're a Rap God? + - This feature is also available on desktop, check the options menu to find the prompt to login. + - There is no feature to view unlocked medals in-game yet, but the feature is planned. +- Reworked the sticker system to allow different sets of stickers to be used for different characters and songs. + - 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) +- Added an option to launch the game in fullscreen. ([ee53ccd](https://github.com/FunkinCrew/Funkin/commit/ee53ccd32721e0790adfe82c60d4aca419db0a7f)) - by @AbnormalPoof in [#3738](https://github.com/FunkinCrew/Funkin/pull/3738) +- Added on-screen descriptions for each item in the Preferences menu. ([a17b0e8](https://github.com/FunkinCrew/Funkin/commit/a17b0e8b3cc1d56fcdc0b51eaca9fd57cdb5bce0)) - by @anysad in [#3872](https://github.com/FunkinCrew/Funkin/pull/3872) +- Added precise scrolling in the Chart Editor using Ctrl-Mouse Wheel. ([0d8e4a5](https://github.com/FunkinCrew/Funkin/commit/0d8e4a53305d6d069454812766300122f3581e31)) - by @ninjamuffin99 in [#3806](https://github.com/FunkinCrew/Funkin/pull/3806) +- Added a “None” option to the character selector in the Chart Editor. ([9c2ef02](https://github.com/FunkinCrew/Funkin/commit/9c2ef0236818883ad1275571dac49eab70ca0ea0)) - by @Lasercar in [#4279](https://github.com/FunkinCrew/Funkin/pull/4279) +- Added the ability to flip the character horizontally in the Animation Editor by pressing G. ([de02137](https://github.com/FunkinCrew/Funkin/commit/de02137d7c7d1779e85aeda34743f506a5b9cc27)) - by @AbnormalPoof in [#3028](https://github.com/FunkinCrew/Funkin/pull/3028) +- Added offsets support for album titles. ([69d8570](https://github.com/FunkinCrew/Funkin/commit/69d8570a9eb06011ed6dd95fcbef83d90f7f8684)) - by @AbnormalPoof in [#3618](https://github.com/FunkinCrew/Funkin/pull/3618) +- Added three new properties to stage data: `angle`, `scroll`, and `alpha`. ([ff56b19](https://github.com/FunkinCrew/Funkin/commit/ff56b1948aef42bbb6bb4ede4f9b2012d49ab044)) - by @AbnormalPoof in [#3720](https://github.com/FunkinCrew/Funkin/pull/3720) +- Added script events for losing/gaining focus. ([4b127b6](https://github.com/FunkinCrew/Funkin/commit/4b127b64130f6f753d0574ec66a1672322e4bd13)) - by @AbnormalPoof in [#3721](https://github.com/FunkinCrew/Funkin/pull/3721) +- Added 10 new functions to `ReflectUtil`. ([6216655](https://github.com/FunkinCrew/Funkin/commit/62166554e7a176245d1a63bd15122033044c4e40)) - by @AbnormalPoof in [#3622](https://github.com/FunkinCrew/Funkin/pull/3622), [#3809](https://github.com/FunkinCrew/Funkin/pull/3809), and [#4019](https://github.com/FunkinCrew/Funkin/pull/4019) +- Added `DEBUG_BUILD` value to `Constants` to indicate whether a build has debug functions enabled. ([ad45b72](https://github.com/FunkinCrew/Funkin/commit/ad45b72b1ae8eb73a12dc51bcb59f66cc55e7bbd)) - by @AbnormalPoof in [#3853](https://github.com/FunkinCrew/Funkin/pull/3853) + +### Changed + +- Switched from hxCodec to hxvlc for video playback. This may break a mod or two. + - Check the [Funkin Modding Docs](https://funkincrew.github.io/funkin-modding-docs/09-migration/09-02-0.5.0-to-0.6.0.html) for more info on how to update your mods. +- Polymod should now ignore `.git` files when loading mods. +- The pause menu can now be opened and closed rapidly. +- Adjusted difficulty ratings and scroll speeds for many songs. +- Chart Editor event fields now allow for values to be as specific as desired. + - For example, the Zoom Camera event can now be set to 0.9857. +- Lots of improvements to GitHub issue and pull request organization. - by @Hundrec and @AbnormalPoof +- Overhauled the Changelog to improve readability and properly credit contributors. ([4383fcf](https://github.com/FunkinCrew/Funkin/commit/4383fcf32c280a1c0ee7b9c80d255611d497cabc)) - by @Hundrec in [#4296](https://github.com/FunkinCrew/Funkin/pull/4296) and [#4298](https://github.com/FunkinCrew/Funkin/pull/4298) +- Made various improvements to the screenshot plugin. ([868932c](https://github.com/FunkinCrew/Funkin/commit/868932cd138fad4be5b541cbea3110e30479057b)) - by @Lasercar in [#4082](https://github.com/FunkinCrew/Funkin/pull/4082) +- Accept keybinds (Z and Space by default) can now be used to exit the Results screen. ([edb270d](https://github.com/FunkinCrew/Funkin/commit/edb270d15e41784dccbf75639ac731840e80fe23)) - by @JVNpixels in [#3799](https://github.com/FunkinCrew/Funkin/pull/3799) +- Reordered UI keybinds in the controls menu for consistency. ([a01bcc3](https://github.com/FunkinCrew/Funkin/commit/a01bcc3da836ec52851ca9de13ef459daf61269a)) - by @lemz1 in [#3027](https://github.com/FunkinCrew/Funkin/pull/3027) +- New save files now have default Freeplay controls for gamepads. ([2b7f62e](https://github.com/FunkinCrew/Funkin/commit/2b7f62edd33de5527e259d9e5643f926d35da734)) - by @MrMadera in [#3934](https://github.com/FunkinCrew/Funkin/pull/3934) +- Made scrolling smoother in the Chart Editor. ([20d9016](https://github.com/FunkinCrew/Funkin/commit/20d90169845f1e50f849e39f4c5f818359756c78)) - by @ninjamuffin99 in [#3768](https://github.com/FunkinCrew/Funkin/pull/3768) +- Mods with missing dependencies are now skipped instead of preventing all mods from loading. ([1c2fb43](https://github.com/FunkinCrew/Funkin/commit/1c2fb43ae16cf40be5ef94c40b047e8e772b1211)) - by @AbnormalPoof in [#3993](https://github.com/FunkinCrew/Funkin/pull/3993) +- Slightly improved flexibility for modding note hit animations. ([3aad825](https://github.com/FunkinCrew/Funkin/commit/3aad825f865c4ed87016983d44121e2c1610d332)) - by @TechnikTil in [#3936](https://github.com/FunkinCrew/Funkin/pull/3936) +- Introduced several QoL modding changes. ([785c4be](https://github.com/FunkinCrew/Funkin/commit/785c4be88b52dc1b5899013822fc004ba7d9894d)) - by @Kade-github in [#4009](https://github.com/FunkinCrew/Funkin/pull/4009) +- Lots of smaller changes. + +### Fixed + +- Shaders no longer create thin seams within atlas sprites. +- Completing a song in Practice Mode no longer plays a new rank animation in the Freeplay menu. +- Fixed lots of charting issues across many songs. +- The Chart Editor grid now properly adjusts to the new BPM after switching variations. +- Fixed a few crashes in the Stage Editor. +- Fixed a bug where the song would restart from the beginning instead of moving to the Results screen. ([3667c51](https://github.com/FunkinCrew/Funkin/commit/3667c51c1efe14cfe7c810e2f35991f08f50781a)) - by @KoloInDaCrib in [#4309](https://github.com/FunkinCrew/Funkin/pull/4309) and @Lasercar in [#4330](https://github.com/FunkinCrew/Funkin/pull/4330) +- Reduced stuttering when resyncing instrumental and voices tracks. ([22d41d2](https://github.com/FunkinCrew/Funkin/commit/22d41d21b88acb7422a0afcda8414682710bd2ed)) - by @TechnikTil in [#3955](https://github.com/FunkinCrew/Funkin/pull/3955) +- Songs with only instrumental tracks no longer stutter. ([dfe02ec](https://github.com/FunkinCrew/Funkin/commit/dfe02ec668b61d6308f459c978d12a7487f9dc28)) - by @KoloInDaCrib in [#3861](https://github.com/FunkinCrew/Funkin/pull/3861) +- The debug mouse cursor no longer flickers before the Title Screen loads. ([1c12b84](https://github.com/FunkinCrew/Funkin/commit/1c12b8467eca350eb28138473360d5358fa620e2)) - by @sphis-Sinco in [#3881](https://github.com/FunkinCrew/Funkin/pull/3881) +- Unbound keys now display as [N/A] instead of crashing the game. ([099c309](https://github.com/FunkinCrew/Funkin/commit/099c309f9babdc1ea99b7dbed3fdccf1e952fc8e)) - by @NotHyper-474 in [#4355](https://github.com/FunkinCrew/Funkin/pull/4355) +- Songs can no longer be spam-selected after selecting an instrumental in Freeplay. ([0e0c4ae](https://github.com/FunkinCrew/Funkin/commit/0e0c4aeb7745cfb9479685ccbb635cf3743cddbb)) - by @AbnormalPoof in [#3866](https://github.com/FunkinCrew/Funkin/pull/3866) +- The Random capsule can now switch to Erect/Nightmare difficulties in Freeplay. ([a90b911](https://github.com/FunkinCrew/Funkin/commit/a90b911653a1beaba57d64b1f05b840109fec42b)) - by @KoloInDaCrib in [#3838](https://github.com/FunkinCrew/Funkin/pull/3838) +- Fixed a rare bug where a song would not register as beaten. ([a3e2373](https://github.com/FunkinCrew/Funkin/commit/a3e23733db104b1ef00cfcff17db3a5d032a4d67)) - by @AbnormalPoof in [#3820](https://github.com/FunkinCrew/Funkin/pull/3820) +- The difficulty graphic on the Results screen no longer cuts off incorrectly. ([b13bf05](https://github.com/FunkinCrew/Funkin/commit/b13bf05d16ff2977309e0c7ba3f049c0134e8902)) - by @AbnormalPoof in [#4161](https://github.com/FunkinCrew/Funkin/pull/4161) +- Four-digit long Total Notes values in the Results screen no longer overflow to the right. ([91a594c](https://github.com/FunkinCrew/Funkin/commit/91a594cc858ed086cd2146a1ac5d2379c6fdd27a)) - by @Hundrec in [#4356](https://github.com/FunkinCrew/Funkin/pull/4356) +- The Character Select screen no longer plays the unlock animation for some locked characters. ([7058126](https://github.com/FunkinCrew/Funkin/commit/7058126e99adb55e43f5f487b007d3efa9f324d5)) - by @AbnormalPoof in [#3748](https://github.com/FunkinCrew/Funkin/pull/3748) +- All time signatures in the Chart Editor now display the correct number of beat/step tick lines. ([e570dfb](https://github.com/FunkinCrew/Funkin/commit/e570dfb8e754f9cb29ac2d8fff6e8513bc68b630)) - by @Keoiki in [#2860](https://github.com/FunkinCrew/Funkin/pull/2860) +- The Debug menu now opens with the correct camera position. ([090ddd1](https://github.com/FunkinCrew/Funkin/commit/090ddd1f1c2aa48fdb83127b2235041643c99af5)) - by @ninjamuffin99 in [#3769](https://github.com/FunkinCrew/Funkin/pull/3769) +- Finally added an outline to the third GF sticker. [9e62572](https://github.com/FunkinCrew/funkin.assets/commit/9e62572ae27dc676c624a81af5c755490eb2dafe) - @M7theguy in [funkin.assets#33](https://github.com/FunkinCrew/funkin.assets/pull/33) +- Removed an unused Freeplay class left over from legacy versions. ([abe4ac8](https://github.com/FunkinCrew/Funkin/commit/abe4ac8485539cbebe527a9a75698950232b68d2)) - by @AbnormalPoof in [#4370](https://github.com/FunkinCrew/Funkin/pull/4370) +- Blacklisted an additional class for security. ([3492d41](https://github.com/FunkinCrew/Funkin/commit/3492d412c65c7f3fd61e6fc6c9410d8467122ab0)) - by @AbnormalPoof in [#4074](https://github.com/FunkinCrew/Funkin/pull/4074) +- Removed an unused class from Polymod blacklist. ([06c12e3](https://github.com/FunkinCrew/Funkin/commit/06c12e36c6bd6df4e2be32a3bec540172e79e162)) - by @AbnormalPoof in [#3729](https://github.com/FunkinCrew/Funkin/pull/3729) +- Many additional small bug fixes. + +## New Contributors for 0.6.0 + +* @PatoFlamejanteTV made their first contribution in [#3843](https://github.com/FunkinCrew/Funkin/pull/3843) +* @sphis-Sinco made their first contribution in [#3881](https://github.com/FunkinCrew/Funkin/pull/3881) +* @MrMadera made their first contribution in [#3934](https://github.com/FunkinCrew/Funkin/pull/3934) +* @MidyGamy made their first contribution in [#4068](https://github.com/FunkinCrew/Funkin/pull/4068) +* @Lasercar made their first contribution in [#4082](https://github.com/FunkinCrew/Funkin/pull/4082) +* @MrScottyPieey made their first contribution in [#4085](https://github.com/FunkinCrew/Funkin/pull/4085) +* @M7theguy made their first contribution in [funkin.assets#33](https://github.com/FunkinCrew/funkin.assets/pull/33) + + + +## [0.5.3] - 2024-10-18 +This patch resolves a critical issue that could cause user's save data to become corrupted. It is recommended that users switch to this version immediately and avoid using version 0.5.2. + +### Fixed + +- Fixed a critical issue in which the Stage Editor theme value could not be parsed by older versions of the game, resulting in all save data being destroyed. + - Added a check that prevents save data from being loaded if it is corrupted rather than overwriting it. +- Converted `optionsStageEditor.theme`, `optionsChartEditor.theme`, and `optionsChartEditor.chartEditorLiveInputStyle` in the save data from an Enum to a String to fix save data compatibility issues. + - In the future, Enum values should not be used in order to prevent incompatibilities caused by introducing new types to the save data that older versions cannot parse. + + + +## [0.5.2] - 2024-10-11 + +### Added + +- Added InverseDotsShader that emulates flash selections. ([097dbf5](https://github.com/FunkinCrew/Funkin/commit/097dbf5bb4346d431d8ca9f0ec4bc5b5e6f4523f)) - by @ninjamuffin99 +- Added a new reworked Stage Editor. ([27a0b44](https://github.com/FunkinCrew/Funkin/pull/3482/commits/27a0b4426f86f04362f97e16e2eff580c9402f34)) - by @KoloInDaCrib in [#3482](https://github.com/FunkinCrew/Funkin/pull/3482) +- Added the `color` attribute to stage prop JSON data to allow them to be tinted without code. ([27a0b44](https://github.com/FunkinCrew/Funkin/pull/3482/commits/27a0b4426f86f04362f97e16e2eff580c9402f34)) - by @KoloInDaCrib in [#3482](https://github.com/FunkinCrew/Funkin/pull/3482) +- Added the `angle` attribute to stage prop JSON data to allow them to be rotated without code. ([27a0b44](https://github.com/FunkinCrew/Funkin/pull/3482/commits/27a0b4426f86f04362f97e16e2eff580c9402f34)) - by @KoloInDaCrib in [#3482](https://github.com/FunkinCrew/Funkin/pull/3482) +- Added the `blend` attribute to the stage prop JSON data to allow blend modes to be applied without code. ([27a0b44](https://github.com/FunkinCrew/Funkin/pull/3482/commits/27a0b4426f86f04362f97e16e2eff580c9402f34)) - by @KoloInDaCrib in [#3482](https://github.com/FunkinCrew/Funkin/pull/3482) + +### Fixed + +- Input offsets no longer cause songs to stutter or skip. ([410cfe9](https://github.com/FunkinCrew/Funkin/commit/410cfe972d6df9de4d4d128375cf8380c4f06d92)) - by @KoloInDaCrib in [#3546](https://github.com/FunkinCrew/Funkin/pull/3546) +- Exiting the Input Offset menu no longer crashes the game. ([39b1a42](https://github.com/FunkinCrew/Funkin/commit/39b1a42cfeafe2b7be8b66e2fe529e853d9ae197)) - by @lemz1 in [#3493](https://github.com/FunkinCrew/Funkin/pull/3493) +- Pico's songs now display properly in the Freeplay Menu. ([1d2bd61](https://github.com/FunkinCrew/Funkin/commit/1d2bd61119e5f418df7f11d7ef2a0fdedee17d3d)) - by @ninjamuffin99 in [#3506](https://github.com/FunkinCrew/Funkin/pull/3506) +- Fixed issues with variation/difficulty loading for Freeplay Menu which caused some songs to disappear. ([c0314c8](https://github.com/FunkinCrew/Funkin/commit/c0314c85ecd5116641aff3de8e9153f7fe48e79c)) - by @ninjamuffin99 in [#3506](https://github.com/FunkinCrew/Funkin/pull/3506) +- `Song.getFirstValidVariation()` now properly takes into account multiple variation/difficulty inputs. ([d2e2987](https://github.com/FunkinCrew/Funkin/commit/d2e29879fe2acc6febfe0f335f655b741d630c34)) - by @ninjamuffin99 in [#3506](https://github.com/FunkinCrew/Funkin/pull/3506) +- Song previews no longer restart when changing difficulties within the same variation. ([903b3fc](https://github.com/FunkinCrew/Funkin/commit/903b3fc59905a70802618a1cd67407722ea956ed)) - by @KoloInDaCrib in [#3587](https://github.com/FunkinCrew/Funkin/pull/3587) +- Main menu music no longer cuts out when switching states. ([711e0a6](https://github.com/FunkinCrew/Funkin/commit/711e0a6b7547eb04113e9318dab900f01ad576a5)) - by @EliteMasterEric in [#3530](https://github.com/FunkinCrew/Funkin/pull/3530) +- Centered preloader 'fnf' and 'dsp' text so they don't clip anymore. ([165ad60](https://github.com/FunkinCrew/Funkin/commit/165ad6015539a295e9eefdaef291c312e9566b26)) - by @Burgerballs in [#3567](https://github.com/FunkinCrew/Funkin/pull/3567) +- FPS setting in options menu no longer flickers when selected. ([b2647fe](https://github.com/FunkinCrew/Funkin/commit/b2647fe09f5281ce7074b26d47bc1524764168ee)) - by @lemz1 in [#3629](https://github.com/FunkinCrew/Funkin/pull/3629) +- Volume sound tray is now anti-aliased/smoothed. ([e66290c](https://github.com/FunkinCrew/Funkin/commit/e66290c55f7141402223644f06ec8a69edeee089)) - by @Kn1ghtNight in [#2853](https://github.com/FunkinCrew/Funkin/pull/2853) +- Fixed looping animations for modded StrumlineNote sprites. ([bc546e8](https://github.com/FunkinCrew/Funkin/commit/bc546e86aa77ffc795b3f079de5f590289a9c583)) - by @DaWaterMalone in [#3577](https://github.com/FunkinCrew/Funkin/pull/3577) +- Stopped allowing inputs after selecting a character in Character Select. ([dbf66ac](https://github.com/FunkinCrew/Funkin/commit/dbf66ac250137262866d75f7c1387645b35d88d0)) - by @ACrazyTown in [#3398](https://github.com/FunkinCrew/Funkin/pull/3398) +- The player and girlfriend no longer disappear or overlap themselves in Character Select. ([9324359](https://github.com/FunkinCrew/Funkin/commit/9324359d2fce6a7097077d169a0efcd80e6fefa1)) - by @gamerbross in [#3457](https://github.com/FunkinCrew/Funkin/pull/3457) +- The player no longer enters twice after entering Character Select or when spamming buttons. ([30a9887](https://github.com/FunkinCrew/Funkin/commit/30a98871367b494c85934cd3fcfa91eeb774a7d5)) - by @gamerbross in [#3457](https://github.com/FunkinCrew/Funkin/pull/3457) +- The wrong girlfriend no longer appears in Character Select. ([9324359](https://github.com/FunkinCrew/Funkin/commit/9324359d2fce6a7097077d169a0efcd80e6fefa1)) - by @gamerbross in [#3457](https://github.com/FunkinCrew/Funkin/pull/3457) +- Cursor now updates properly when moving and selecting in Character Select. ([9324359](https://github.com/FunkinCrew/Funkin/commit/9324359d2fce6a7097077d169a0efcd80e6fefa1)) - by @gamerbross in [#3457](https://github.com/FunkinCrew/Funkin/pull/3457) +- Cursor now moves properly at lower framerates in Character Select. ([ab5bda3](https://github.com/FunkinCrew/Funkin/commit/ab5bda3ee573a6e03595ec6941e6de38df851889)) - by @ninjamuffin99 in [#3507](https://github.com/FunkinCrew/Funkin/pull/3507) +- Exiting the Chart Editor no longer crashes the game. ([f52472a](https://github.com/FunkinCrew/Funkin/commit/f52472a4767388b22cfbab0f5f7860f6e6762856)) - by @EliteMasterEric and @ianharrigan in [#3519](https://github.com/FunkinCrew/Funkin/pull/3519) +- The millisecond counter in the Chart Editor playbar is now properly formatted. ([f1b6e6c](https://github.com/FunkinCrew/Funkin/commit/f1b6e6c4e42455e0c2900d738ebc24893f2479a0)) - by @afreetoplaynoob in [#3537](https://github.com/FunkinCrew/Funkin/pull/3537) +- Pressing F1 multiple times no longer creates more than one help window in the Chart Editor. ([777978f](https://github.com/FunkinCrew/Funkin/commit/777978f5a544e1b7c89b47dcc365f734eb6d0df1)) - by @amyspark-ng in [#3552](https://github.com/FunkinCrew/Funkin/pull/3552) +- The dialog box now shows up in the Animation Editor. ([1fde59f](https://github.com/FunkinCrew/Funkin/commit/1fde59f999eac94eb10fc22094885de2f5310705)) - by @EliteMasterEric in [#3530](https://github.com/FunkinCrew/Funkin/pull/3530) +- (debug) No more fullscreening when typing "F" in the flixel debugger console. ([29b6763](https://github.com/FunkinCrew/Funkin/commit/29b6763290df05d42039806f3d142740568c80f0)) - by @ninjamuffin99 +- Added additional classes to Polymod blacklist for security. ([b0b73c8](https://github.com/FunkinCrew/Funkin/commit/b0b73c83994f33118c6a69550da9ec8ec1c07adc)) - by @EliteMasterEric in [#3558](https://github.com/FunkinCrew/Funkin/pull/3558) + +## New Contributors for 0.5.2 + +* @Kn1ghtNight made their first contribution in [#2853](https://github.com/FunkinCrew/Funkin/pull/2853) +* @Cartridge-Man made their first contribution in [#3082](https://github.com/FunkinCrew/Funkin/pull/3082) +* @afreetoplaynoob made their first contribution in [#3537](https://github.com/FunkinCrew/Funkin/pull/3537) +* @amyspark-ng made their first contribution in [#3552](https://github.com/FunkinCrew/Funkin/pull/3552) +* @DaWaterMalone made their first contribution in [#3577](https://github.com/FunkinCrew/Funkin/pull/3577) + + + +## [0.5.1] - 2024-09-30 + +### Added + +- Readded the Merch button to the main menu. + - Click it to check out our Makeship campaign! +- Added Discord Rich Presence support. People can now see what song you are playing from Discord! + - We'll get mod support working for this eventually. +- Added an FPS limit option to the Preferences menu. + - You can now change how high the game tries to push your frame rate, from as little as 30 to as high as 300. +- Added support for the Tracy instrumentation-based profiling tool in development environments. Enable it with the `-DFEATURE_DEBUG_TRACY` compilation flag. + - For the people who aren't nerds, this is a tool for tracking down performance issues! +- Playable Character data now defines an asset location for an Animate Atlas to display Girlfriend. + - This includes the option to display a visualizer, if configured correctly. +- Separated the Perfect and Perfect (Gold) animations in the Playable Character data. + - Base game just uses the same animation for both, but modders can split the animations up on their custom characters now. +- Added a bunch of Flash project files from the Weekend 1 and Playable Pico updates to the `funkin.art` repository. +- Added the `flipX` and `flipY` parameters to props in the Stage data. ([community feature by AbnormalPoof](https://github.com/FunkinCrew/Funkin/pull/3474)) + +### Changed + +- Pico is no longer unlocked for all players automatically. + - You need to beat Weekend 1 in Story Mode in order to unlock him in Character Select. +- The game's mod API version check is now more dynamic. + - The update accepts mods with API version `0.5.0` as well as `0.5.1`. +- Removed some of the more spammy `trace()` calls to improve debugging a bit. +- The game now complains if you create a song variation with symbols in its name. +- Switched the force crash keybind from Ctrl-Shift-L to Ctrl-Alt-Shift-L. +- Added some additional functions to `funkin.Assets` after `openfl.utils.Assets` had to get blacklisted from scripts. + +### Fixed + +- Pico is no longer locked every time the game starts, so you no longer have to watch the unlock animation each game boot. + - The animation should now play only once per save file. +- The clear % now displays in Freeplay after switching characters. +- Character remixes no longer display the base song's highscore in Freeplay. +- Freeplay no longer displays the wrong text on capsules. +- Freeplay now displays custom songs when switching characters. +- Duplicate difficulties from custom variations now display properly in Freeplay. +- DadBattle (Pico Mix) now has charts for Normal and Easy difficulties. +- DadBattle (Pico Mix) is now properly credited to `TeraVex (ft. Saruky)`. +- Spookeez (Pico Mix) is now properly credited to `Six Impala (ft. Saster)`. +- The audio track now unmutes if you miss a note just before Pico burps. +- Pico now plays out his full burp animation in South (Pico Mix). +- Removed a tap note stacked on top of a hold note in Cocoa Erect (Erect difficulty). +- Pico Erect can no longer be played with different instrumentals. +- The curtains in Week 1 no longer display in front of larger characters. +- Boyfriend now plays his death animation properly on the Week 2 Remix stage. +- The game no longer stutters when playing on the Week 5 Remix stage. +- The "Shit!" judgement no longer displays with anti-aliasing in Week 6. +- Spirit's trail in Week 6 now displays correctly. +- Pico now plays his shooting animations in Stress. +- Characters with high offsets no longer shift over after the player dies or restarts. +- Custom note styles no longer sometimes use default values rather than the fallback note style. +- Custom note styles no longer randomly fail to fetch information about their fallback note style. +- Screenshots and Chart Editor binds no longer display in the controls menu on Web builds (where they are disabled). +- Stage Editor bind no longer displays in the controls menu even when the feature is disabled. +- Freeplay Character Select keybind no longer displays strangely in the controls menu. +- Audio tracks no longer get destroyed if they are flagged as persistent. +- Video cutscenes now scale their volume properly. +- Results screen audio no longer continues into Freeplay or gameplay. +- The Results screen now plays the percentage tick sound when the value changes instead of spamming the sound. +- The save data version number is now written to the save data properly. +- The example mod can now be loaded. +- Pressing F5 to force reload a song no longer occasionally causes the game to crash. +- Animations on Animate Atlas characters no longer throw a bunch of warnings in the console. +- Entering Blazin' no longer displays a script error. +- The Input Offsets menu no longer crashes when entering it before playing a song on web builds. +- Setting the input offset or visual offset to high values no longer causes the song to skip. +- Classic FocusCamera song events no longer cause the camera to snap in place. ([community fix by nebulazorua](https://github.com/FunkinCrew/Funkin/pull/2331)) +- Pixel hold note trails in Week 6 are now scaled/positioned correctly. ([community fix by dombomb64](https://github.com/FunkinCrew/Funkin/pull/3351)) +- Achieving the same rank on a song with a lower clear % no longer overwrites your clear %. ([community fix by lemz1](https://github.com/FunkinCrew/Funkin/pull/3019)) +- The FPS counter no longer displays if Debug Display is turned off. ([community fix by Lethrial](https://github.com/FunkinCrew/Funkin/pull/3356)) +- The Chart Editor can now be interacted with properly. ([community fix by Kade-github](https://github.com/FunkinCrew/Funkin/pull/3337)) +- Selecting the area to the left of the Chart Editor no longer selects some of the player's notes. ([community fix by NotHyper-474](https://github.com/FunkinCrew/Funkin/pull/3093)) +- Pixel icons now display correctly in the Chart Editor. ([community fix by TechnikTil](https://github.com/FunkinCrew/Funkin/pull/3339)) +- Audio offsets now interact with the Chart Editor properly. ([community fix by Kade-github](https://github.com/FunkinCrew/Funkin/pull/3384)) +- Players can no longer crash the game by interacting with Character Select during the unlock sequence. ([community fix by ActualMandM](https://github.com/FunkinCrew/Funkin/pull/3355)) +- `Stage.addCharacter` now properly assigns the `characterType`. ([community fix by Kade-github](https://github.com/FunkinCrew/Funkin/pull/3357)) +- Fetching Modules during the `onDestroy` event no longer fails at random. ([community fix by cyn0x8](https://github.com/FunkinCrew/Funkin/pull/3131)) +- `onSubStateOpenEnd` and `onSubStateCloseEnd` script events are now called consistently. ([community fix by lemz1](https://github.com/FunkinCrew/Funkin/pull/3138)) + +## New Contributors for 0.5.1 + +* @dombomb64 made their first contribution in [#3351](https://github.com/FunkinCrew/Funkin/pull/3351) +* @Lethrial made their first contribution in [#3356](https://github.com/FunkinCrew/Funkin/pull/3356) +* @KoloInDaCrib made their first contribution in [#3371](https://github.com/FunkinCrew/Funkin/pull/3371) + + + +## [0.5.0] - 2024-09-12 +The Playable Pico Update! +### Added + +- Added a new Character Select screen to switch between playable characters in Freeplay. + - Modding isn't 100% there but we're working on it! +- Added Pico as a playable character! Unlock him by completing Weekend 1 (if you haven't already done that). + - The songs from Weekend 1 have moved; you must now switch to Pico via Character Select screen in Freeplay to access them. +- Added 11 new Pico remixes! Access them by selecting Pico in the Character Select screen. + - Bopeebo (Pico Mix) + - Fresh (Pico Mix) + - DadBattle (Pico Mix) + - Spookeez (Pico Mix) + - South (Pico Mix) + - Pico (Pico Mix) + - Philly Nice (Pico Mix) + - Blammed (Pico Mix) + - Eggnog (Pico Mix) + - Ugh (Pico Mix) + - Guns (Pico Mix) +- Added 1 new Boyfriend remix! Access it by completing Weekend 1 as Pico and then selecting Boyfriend in the Character Select screen. + - Darnell (BF Mix) +- Added 2 new Erect remixes! Access them by switching difficulties on the songs. + - Cocoa Erect + - Ugh Erect +- Implemented support for a new Instrumental Selector in Freeplay. + - Beating a Pico remix lets you use that instrumental when playing as Boyfriend. +- Added the first batch of Erect Stages! These graphical overhauls of the original stages will be used when playing Erect remixes and Pico remixes: + - Week 1 Erect Stage + - Week 2 Erect Stage + - Week 3 Erect Stage + - Week 4 Erect Stage + - Week 5 Erect Stage + - Weekend 1 Erect Stage +- Implemented alternate animations and music for Pico in the Results screen. + - These display on Pico remixes, as well as when playing Weekend 1. +- Implemented support for scripted Note Kinds. You can use HScript define a different note style to display for these notes as well as custom behavior. (community feature by lemz1) +- Implemented support for Numeric and Selector options in the Options menu. ([community feature by FlooferLand](https://github.com/FunkinCrew/Funkin/pull/2942)) +- Implemented new animations for Tankman and Pico. + +## Changed + +- Girlfriend and Nene now perform previously unused animations when you achieve a large combo, or drop a large combo. +- The pixel character icons in the Freeplay menu now display an animation! +- Altered how Week 6 displays sprites to make things look more retro. +- Character offsets are now independent of the character's scale. + - This should resolve issues with offsets when porting characters from older mods. + - Pixel character offsets have been modified to compensate. +- Reworked the JSON merging system in Polymod; you can now include JSONPatch files under `_merge` in your mod folder to add, modify, or remove values in a JSON without replacing it entirely! +- The `danceEvery` property of characters and stage props can now use values with a precision of `0.25`, to play their idle animation up to four times per beat. +- Characters now respect the `danceEvery` property. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2925)) +- Note style data can now specify custom combo count graphics, judgement graphics, countdown graphics, and countdown audio. ([community feature by anysad](https://github.com/FunkinCrew/Funkin/pull/3020)) + - These were previously using hardcoded values based on whether the stage was `school` or `schoolEvil`. +- The YEAH! events in Tutorial now use chart events rather than being hard-coded. ([community fix by anysad](https://github.com/FunkinCrew/Funkin/pull/3007)) +- Cutscenes now automatically pause when tabbing out. ([community fix by AbnormalPoof](https://github.com/FunkinCrew/Funkin/pull/2903)) +- The F5 function now reloads the current song's chart data from disk. ([community feature by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2990)) +- Made several layout improvements and fixes to the Animation Offsets editor in the Debug menu. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2820)) +- Animation offsets no longer directly modify the `x` and `y` position of props, making props work better with tweens. ([community fix by Sword352](https://github.com/FunkinCrew/Funkin/pull/2310)) +- Fixed a bug where the Back sound would be not played when leaving the Story menu and Options menu. ([community fix by AppleHair](https://github.com/FunkinCrew/Funkin/pull/2986)) +- The player's Score now displays commas in it. (community fix by loggo) + +## Fixed + +- Songs with no notes no longer crash on the Results screen. +- Note inputs are no longer accepted during the Thorns cutscene. +- The old icon easter egg now works properly on pixel levels. +- The Heart icon is no longer malformed when favoriting a song in Freeplay. +- Freeplay songs with no `Normal` difficulty no longer crash the game. ([community fix by AppleHair](https://github.com/FunkinCrew/Funkin/pull/3036) and [gamerbross](https://github.com/FunkinCrew/Funkin/pull/2712)) +- Songs that aren't valid for the current variation can no longer be selected. ([community fix by AppleHair](https://github.com/FunkinCrew/Funkin/pull/3037)) +- The Freeplay song preview now respects the instrumental ID specified in the song metadata. ([community fix by AppleHair](https://github.com/FunkinCrew/Funkin/pull/2742)) +- Modded songs now play previews in the Freeplay menu. ([community fix by KarimAkra](https://github.com/FunkinCrew/Funkin/pull/2724)) +- The Story Mode menu can now be scrolled using the mouse wheel. ([community fix by JVNpixels](https://github.com/FunkinCrew/Funkin/pull/2873)) +- Pressing F5 after seeing the sticker transition no longer crashes the game. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2863)) +- Pausing the game during a camera zoom no longer zooms the pause menu as well. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2567)) +- The song no longer majorly desyncs at times. ([community fix by Burgerballs](https://github.com/FunkinCrew/Funkin/pull/3058)) +- Pico's death animation no longer displays a faint blue background. ([community fix by doggogit](https://github.com/FunkinCrew/funkin.assets/pull/1)) +- The game now uses the placeholder health icon as a fallback. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/3005)) +- Pressing the Chart Editor keybind while playtesting a chart no longer resets the Chart Editor. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2739)) +- The Chart Editor no longer gets stuck creating a hold note when using Live Inputs. ([community fix by gamerbross](https://github.com/FunkinCrew/Funkin/pull/2992)) +- Tankman's icon now displays in the Chart Editor. ([community fix by Hundrec](https://github.com/FunkinCrew/Funkin/pull/2912)) +- The Memory Usage counter no longer overflows and displays a negative number. ([community fix by KarimAkra](https://github.com/FunkinCrew/Funkin/pull/2713)) +- Certain UI elements now flash at a consistent rate. ([community fix by cyn0x8](https://github.com/FunkinCrew/Funkin/pull/2494)) +- Character graphics can now be placed in week folders. ([community fix by 7oltan](https://github.com/FunkinCrew/Funkin/pull/3035)) + +## New Contributors for 0.5.0 + +* @Sword352 made their first contribution in [#2310](https://github.com/FunkinCrew/Funkin/pull/2310) +* @cyn0x8 made their first contribution in [#2494](https://github.com/FunkinCrew/Funkin/pull/2494) +* @KarimAkra made their first contribution in [#2713](https://github.com/FunkinCrew/Funkin/pull/2713) +* @tposejank made their first contribution in [#2717](https://github.com/FunkinCrew/Funkin/pull/2717) +* @AppleHair made their first contribution in [#2742](https://github.com/FunkinCrew/Funkin/pull/2742) +* @JVNpixels made their first contribution in [#2873](https://github.com/FunkinCrew/Funkin/pull/2873) +* @Flooferland made their first contribution in [#2942](https://github.com/FunkinCrew/Funkin/pull/2942) +* @Punkinator7 made their first contribution in [#2962](https://github.com/FunkinCrew/Funkin/pull/2962) +* @anysad made their first contribution in [#3007](https://github.com/FunkinCrew/Funkin/pull/3007) +* @7oltan made their first contribution in [#3035](https://github.com/FunkinCrew/Funkin/pull/3035) + + + +## [0.4.1] - 2024-06-12 + +### Added + +- Pressing ESCAPE on the title screen on desktop now exits the game, allowing you to exit the game while in fullscreen on desktop. +- Freeplay menu controls (favoriting and switching categories) are now rebindable from the Options menu, and now have default binds on controllers. + +### Changed + +- Highscores and ranks are now saved separately, fixing an issue where lower ranks would overwrite higher ranks if the player achieved a new highscore. +- A-Bot speaker now reacts to the user's volume preference on desktop. ([thanks to M7theguy for the issue report/suggestion](https://github.com/FunkinCrew/Funkin/issues/2744)!) +- Freeplay heart icons are now shifted to the right when favoriting a song with no rank. +- The `scrollMenu` sound effect now only plays when there's a real change on the Freeplay menu. ([thanks gamerbross for the PR!](https://github.com/FunkinCrew/Funkin/pull/2741)) +- Applied anti-aliasing to the edge of the Freeplay Dad graphic. +- Rearranged some controls in the controls menu. +- Made several chart revisions: + - Re-enabled custom camera events in Roses (Erect/Nightmare) + - Tweaked chart for Lit Up (Hard) + - Corrected difficulty ratings for M.I.L.F (Easy/Normal/Hard) + +### Fixed + +- Control binds in the controls menu no longer overlap their names. +- Attempting to exit the gameover screen and retry the song at the same time no longer crashes the game. ([thanks DMMaster636 for the PR!](https://github.com/FunkinCrew/Funkin/pull/2709)) +- Botplay mode now handles the player's animations properly during hold notes. ([thanks Hundrec!](https://github.com/FunkinCrew/Funkin/pull/2683)) +- Camera movement now pauses when the game is paused. ([thanks Matriculaso!](https://github.com/FunkinCrew/Funkin/pull/2684)) +- Pico's gameplay sprite no longer appears on the gameover screen when dying from an explosion in 2hot. +- Freeplay previews now properly fade in volume during the DJ's idle animation. +- DadBattle no longer incorrectly appears as DadBattle Erect when returning to Freeplay on Hard. +- 2hot now appears under the "#" category in Freeplay menu. +- The Chart Editor no longer crashes when selecting an event with the Event toolbox open. +- Improved offsets for Pico and Tankman opponents so they don't slide around as much. +- The black "temp" graphic in Freeplay is now correctly sized/masked, now it's identical to the Dad Freeplay graphic. + +## New Contributors for 0.4.1 + +* @Hundrec made their first contribution in [#2661](https://github.com/FunkinCrew/Funkin/pull/2661) +* @DMMaster636 made their first contribution in [#2709](https://github.com/FunkinCrew/Funkin/pull/2709) +* @eltociear made their first contribution in [#2730](https://github.com/FunkinCrew/Funkin/pull/2730) + + + +## [0.4.0] - 2024-06-06 +The Pit Stop 1 update! +### Added + +- 2 new Erect remixes, Eggnog and Satin Panties. Check them out from the Freeplay menu! +- Major visual improvements to the Results screen, with additional animations and audio based on your performance. +- Major visual improvements to the Freeplay screen, with song difficulty ratings and player rank displays. + - Freeplay now plays a preview of songs when you hover over them. +- Added a Charter field to the chart format, to allow for crediting the creator of a level's chart. + - You can see who charted a song from the Pause menu. +- Added a new Scroll Speed chart event to change the note speed mid-song. ([thanks Burgerballs!](https://github.com/FunkinCrew/Funkin/pull/2409)) + +### Changed + +- Tweaked charts for several songs: + - Tutorial (increased the note speed slightly) + - Spookeez + - Monster + - Winter Horrorland + - M.I.L.F + - Senpai (increased the note speed) + - Roses + - Thorns (increased the note speed slightly) + - Ugh + - Stress + - Lit Up +- Favorite songs marked in Freeplay are now stored between sessions. +- The Freeplay easter eggs are now easier to see. +- In the event that the game cannot load your save data, it will now perform a backup before clearing it, so that we can try to repair it in the future. +- Custom note styles are now properly supported for songs; add new notestyles via JSON, then select it for use from the Chart Editor Metadata toolbox. ([thanks Keoiki!](https://github.com/FunkinCrew/Funkin/pull/2581)) +- Health icons now support a Winning frame without requiring a spritesheet, simply include a third frame in the icon file. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2593)) + - Remember that for more complex behaviors such as animations or transitions, you should use an XML file to define each frame. +- Improved the Event Toolbox in the Chart Editor; dropdowns are now bigger, include search field, and display elements in alphabetical order rather than a random order. + +### Fixed + +- Nene's visualizer now plays on Desktop builds. +- The game no longer silently fails to load saves on HTML5. +- Props on the Story Menu now bop properly. +- Additional fixes for the loading bar on HTML5. ([thanks lemz1!](https://github.com/FunkinCrew/Funkin/pull/2553)) +- Fixed several bugs with the TitleState, including missing music when returning from the Main Menu. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2539)) +- The Main Menu camera now properly follows the selected item. ([thanks richTrash21!](https://github.com/FunkinCrew/Funkin/pull/2576)) +- Changing difficulties in Story Mode now updates the score text. ([thanks sector-a!](https://github.com/FunkinCrew/Funkin/pull/2585)) +- Fixed a crash in Freeplay caused by a level referencing an invalid song. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2457)) +- Pressing the volume keys no longer stops the Toy commercial. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2540)) +- Playtesting in the Chart Editor no longer crashes when losing. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2518)) +- Hold notes now display properly in the Chart Editor when downscroll is enabled for gameplay. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2565)) +- Hold notes are now positioned correctly with downscroll enabled. ([thanks MaybeMaru!](https://github.com/FunkinCrew/Funkin/pull/2488)) +- Removed a large number of unused imports to optimize builds. ([thanks Ethan-makes-music!](https://github.com/FunkinCrew/Funkin/pull/2624)) +- Improved debug logging for unscripted stages. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2603)) +- Fixed a crash on Linux caused by an old version of hxCodec. ([thanks Noobz4Life!](https://github.com/FunkinCrew/Funkin/pull/2472)) +- Optimized animation handling for characters. ([thanks richTrash21!](https://github.com/FunkinCrew/Funkin/pull/2493)) +- The Chart Editor no longer uses an incorrect instrumental on imported Legacy songs. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2604)) +- Opening the game from the command line no longer crashes the preloader. ([thanks NotHyper-474!](https://github.com/FunkinCrew/Funkin/pull/2629)) +- Characters no longer use the wrong scale value sometimes. ([thanks PurSnake!](https://github.com/FunkinCrew/Funkin/pull/2610)) +- Additional bug fixes and optimizations. + +## New Contributors for 0.4.0 + +* @doggogit made their first contribution in [#2325](https://github.com/FunkinCrew/Funkin/pull/2325) +* @Noobz4Life made their first contribution in [#2472](https://github.com/FunkinCrew/Funkin/pull/2472) +* @MaybeMaru made their first contribution in [#2488](https://github.com/FunkinCrew/Funkin/pull/2488) +* @NotHyper-474 made their first contribution in [#2490](https://github.com/FunkinCrew/Funkin/pull/2490) +* @richTrash21 made their first contribution in [#2493](https://github.com/FunkinCrew/Funkin/pull/2493) +* @TechnikTil made their first contribution in [#2508](https://github.com/FunkinCrew/Funkin/pull/2508) +* @SanicBTW made their first contribution in [#2544](https://github.com/FunkinCrew/Funkin/pull/2544) +* @EnterTheVoid-x86 made their first contribution in [#2573](https://github.com/FunkinCrew/Funkin/pull/2573) +* @Keoiki made their first contribution in [#2581](https://github.com/FunkinCrew/Funkin/pull/2581) +* @sector-a made their first contribution in [#2585](https://github.com/FunkinCrew/Funkin/pull/2585) +* @PurSnake made their first contribution in [#2610](https://github.com/FunkinCrew/Funkin/pull/2610) +* @Ethan-makes-music made their first contribution in [#2624](https://github.com/FunkinCrew/Funkin/pull/2624) +* @An-enderman made their first contribution in [#2662](https://github.com/FunkinCrew/Funkin/pull/2662) +* @moondroidcoder made their first contribution in [#2701](https://github.com/FunkinCrew/Funkin/pull/2701) + + + +## [0.3.3] - 2024-05-14 + +### Changed + +- Cleaned up some code in `PlayAnimationSongEvent.hx`. ([thanks Burgerballs!](https://github.com/FunkinCrew/Funkin/pull/2308)) + +### Fixed + +- Fixes for the Loading bar on HTML5. ([thanks lemz1!](https://github.com/FunkinCrew/Funkin/pull/2499)) +- Don't allow inputs when exiting Freeplay. ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2470)) +- Fixed mouse wheel scrolling in Freeplay. ([thanks JugieNoob!](https://github.com/FunkinCrew/Funkin/pull/2466)) +- Health icons, score, and notes now reset properly when re-entering gameplay from gameover. ([thanks ImCodist!](https://github.com/FunkinCrew/Funkin/pull/2390)) +- Fixed the character selector's hitbox width in the Chart Editor. ([thanks MadBear422!](https://github.com/FunkinCrew/Funkin/pull/2370)) +- Fixed camera stutter once a wipe transition to the Main Menu completes. ([thanks ImCodist!](https://github.com/FunkinCrew/Funkin/pull/2315)) +- Hold notes no longer become invisible for a single frame. ([thanks ImCodist!](https://github.com/FunkinCrew/Funkin/pull/2309)) +- Tweens no longer accumulate on the Title screen when pressing Y multiple times. ([thanks TheGaloXx!](https://github.com/FunkinCrew/Funkin/pull/2300)) +- Fixed a crash when querying FlxG.state in the crash handler. +- Fixed a game over easter egg so you don't accidentally exit it when viewing. +- The Freeplay menu can now display 100% clear. +- Weekend 1 Pico no longer attempts to retrieve a missing asset. +- Fixed an issue where duplicate keybinds would be stored, potentially causing a crash. +- Chart debug key now properly returns you to the previous chart editor session if you were playtesting a chart. ([thanks nebulazorua!](https://github.com/FunkinCrew/Funkin/pull/2323)) +- Fixed a crash on Freeplay found on AMD graphics cards. + +## New Contributors for 0.3.3 + +* @Chubercik made their first contribution in [#2297](https://github.com/FunkinCrew/Funkin/pull/2297) +* @TheGaloXx made their first contribution in [#2300](https://github.com/FunkinCrew/Funkin/pull/2300) +* @Burgerballs made their first contribution in [#2308](https://github.com/FunkinCrew/Funkin/pull/2308) +* @ImCodist made their first contribution in [#2309](https://github.com/FunkinCrew/Funkin/pull/2309) +* @nebulazorua made their first contribution in [#2323](https://github.com/FunkinCrew/Funkin/pull/2323) +* @MadBear422 made their first contribution in [#2370](https://github.com/FunkinCrew/Funkin/pull/2370) +* @JugieNoob made their first contribution in [#2466](https://github.com/FunkinCrew/Funkin/pull/2466) +* @gamerbross made their first contribution in [#2470](https://github.com/FunkinCrew/Funkin/pull/2470) +* @lemz1 made their first contribution in [#2499](https://github.com/FunkinCrew/Funkin/pull/2499) + + + +## [0.3.2] - 2024-05-03 + +### Added + +- Added `,` and `.` keybinds to the Chart Editor. These place Focus Camera events at the playhead, for the opponent and player respectively. +- Implemented a blacklist to prevent mods from calling system functions. + - Added a couple utility functions to call useful stuff that got blacklisted. +- Added an `onSongLoad` script event which allows for mutation of notes and events. +- Added the currently loaded modlist to crash logs. +- Added the `visible` attribute to Level JSON data. +- Enabled ZIP file system support for Polymod (make sure the metadata is in the root of the ZIP). + +### Changed + +- Songs in the mod folders will display in Freeplay without any extra scripting. +- Story levels in the mod folders will display in Story without any extra scripting. +- All audio should sound better in HTML5, less muddy. + +### Fixed + +- Fixed a typo in the credits folder (`Custcene` -> `Cutscene`) +- Health icon transition animations now finish properly instead of looping forever. +- Video cutscenes flagged as mid-song no longer crash the game when they finish. +- Substate lifecycle events are now dispatched consistently. +- Trying to load into the Animation Offsets menu with an invalid character no longer crashes the game. +- The preloader no longer spams the logs when it is complete and waiting for user input. +- Should definitely have the fix for Freeplay where it stops taking control of the main menu below it. +- Changed the code for the Story Mode menu difficulties so that "normal" doesn't overlap the arrows after leaving Weekend 1. + +### Removed + +- Removed some unused `.txt` files in the `assets/data` folder. + + + +## [0.3.1] - 2024-05-01 + +### Changed + +- Ensure the Git commit hash always displays in the log files. +- Added whether the local Git repo was modified to the log files. +- Removed "PROTOTYPE" text on release builds only (it still shows on debug builds). +- Added additional credits and special thanks. +- Updated peepo in creds to peepo173. + +### Fixed + +- Fixed a crash when retrieving system specs while handing a crash. +- Fixed a crash triggered when pausing before the song starts. +- Fixed a crash triggered when dying before the song starts. +- Fixed a crash triggered when unloading certain graphics. +- Pico game over confirm now plays correctly. +- When exiting from a song into Freeplay, main menu no longer takes inputs unintentionally (aka issues with merch links opening up when selecting songs). +- Arrow keys no longer cause the web browser page to scroll. + + + +## [0.3.0] - 2024-04-30 +The Weekend 1 update! +### Added + +- New Story Level: Weekend 1, starring Pico, Darnell, and Nene. + - Beat the level in Story Mode to unlock the songs for Freeplay! +- 12 new Erect remixes, featuring Kawai Sprite, Saruky, Kohta Takahashi, and Saster. + - Unlocked instantly in Freeplay +- New visually enhanced Freeplay menu. + - Sorting, favorites, and more +- New Results screen upon completing any song or story level. +- New refactored Chart Editor prototype (accessible via `~` in the main menu or `7` in the Play State, rebindable). (VERY EARLY PROTOTYPE. EXPECT BUGS AND CRASHES) +- Implemented a new scripting system using HScript (an interpreted language with Haxe-like syntax) for incredible flexibility. + - All character-specific, stage-specific, or song-specific behaviors have been moved to HScript. +- New song events system allows for simple customization of camera behavior. + - Mods can implement custom song events via HScript, and new built-in song events will come in the future. +- New credits menu to list all the dozens of people who contributed. + +### Changed + +- Completely refactored the game's input system for higher reliability and accuracy. +- Reworked note rendering to massively reduce lag on larger charts. +- Reworks to scoring and health gain. +- Dedicated gamepad support with the ability to rebind buttons. +- Improvements to video cutscenes and dialogue, allowing them to be easily skipped or restarted. +- Updated Polymod by several major versions, allowing for fully dynamic asset replacement and support for scripted classes. +- Completely refactored almost every part of the game's code for performance, stability, and extensibility. + - This is not the Ludum Dare game held together with sticks and glue you played three years ago. +- Characters, stages, songs, story levels, and dialogue are now built from JSON data registries rather than being hardcoded. + - All of these also support attaching scripts for custom behavior, more documentation on this soon. + - You can forcibly reload the game's JSON data and scripts by pressing F5. +- Fully refactored the game's chart file format for extensibility and readability. + - You can migrate old charts using the Import FNF Legacy option in the chart editor. +- Various visual tweaks and improvements. + +### Fixed + +- 17 quadrillion bugs across hundreds of PRs. + + + +## [0.2.8] - 2021-04-18 (note, this one is iffy cuz we slacked wit it lol!) + +### Added + +- TANKMAN! 3 NEW SONGS BY KAWAISPRITE (UGH, GUNS, STRESS)! Charting help by MtH! +- Monster added into week 2, FINALLY (Charting help by MtH and ChaoticGamer!) +- Can now change song difficulty mid-game. +- Shows some song info on pause screen. +- Cute little icons onto freeplay menu +- Offset files for easier modification of characters + +### Changed + +- ASSET LOADING OVERHAUL, WAY FASTER LOAD TIMES ON WEB!!! (THANKS TO GEOKURELI WOKE KING) +- Made difficulty selector on freeplay menu more apparent + +### Fixed + +- That one random note on Bopeebo + + + +## [0.2.7.1] - 2021-02-14 + +### Added + +- Easter eggs +- readme's in desktop versions of the game + +### Changed + +- New icons, old one was placeholder since October woops! +- Made the transitions between the story mode levels more seamless. +- Offset of the Newgrounds logo on boot screen. +- Made the changelog txt so it can be opened easier by normal people who don't have a markdown reader (most normal people); + +### Fixed + +- Fixed crashes on Week 6 story mode dialogue if spam too fast ([Thanks to Lotusotho for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/357)) +- Should show intro credits on desktop versions of the game more consistently +- Layering on Week 4 songs with GF and the LIMO LOL HOW TF I MISS THIS +- Chart's and chart editor now support changeBPM, GOD BLESS MTH FOR THIS ONE I BEEN STRUGGLIN WIT THAT SINCE OCTOBER LMAO ([GOD BLESS MTH](https://github.com/FunkinCrew/Funkin/pull/382)) +- Fixed sustain note trails ALSO THANKS TO MTH U A REAL ONE ([MTH VERY POWERFUL](https://github.com/FunkinCrew/Funkin/pull/415)) +- Antialiasing on the skyscraper lights + + + +## [0.2.7] - 2021-02-02 + +### Added + +- PIXEL DAY UPDATE LOL 1 WEEK LATER +- 3 New songs by Kawaisprite! +- COOL CUTSCENES +- WEEK 6 YOYOYOYOY +- Swaggy pixel art by Moawling! + +### Changed + +- Made it so you lose sliiiightly more health when you miss a note. +- Removed the default HaxeFlixel pause screen when the game window loses focus, can get screenshots of the game easier hehehe + +### Fixed + +- Idle animation bug with BF christmas and BF hair blow sprites ([Thanks to Injourn for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/237)) + + + +## [0.2.6] - 2021-01-20 + +### Added + +- 3 NEW CHRISTMAS SONGS. 2 BY KAWAISPRITE, 1 BY BASSETFILMS!!!!! BF WITH DRIP! SANTA HANGIN OUT! +- Enemy icons change when they you are winning a lot ([Thanks to pahaze for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/138)) +- Holding CTRL in charting editor places notes on both sides +- Q and E changes sustain lengths in note editor +- Other charting editor workflow improvements +- More hair physics +- Heads appear at top of chart editor to help show which side ur charting for + +### Changed + +- Tweaked code relating to inputs, hopefully making notes that are close together more fair to hit + +### Removed + +- Removed APE + +### Fixed + +- Maybe fixed double notes / jump notes. Need to tweak it for balance, but should open things up for cooler charts in the future. +- Old Verison popup screen weirdness ([Thanks to gedehari for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/155)) +- Song no longer loops when finishing the song. ([Thanks Injourn for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/132)) +- Screen wipe being cut off in the limo/mom stage. Should fill the whole screen now. +- Boyfriend animations on hold notes, and pressing on repeating notes should behave differently + + + +## [0.2.5] - 2020-12-27 + +### Added + +- MOMMY GF, 3 NEW ASS SONGS BY KAWAISPRITE, NEW ART BY PHANTOMARCADE,WOOOOOOAH!!!! +- Different icons depending on which character you are against, art by EVILSK8R!! +- Autosave to chart editor +- Clear section button to note editor +- Swap button in note editor +- a new boot text or two +- automatic check for when you're on an old version of the game! + +### Changed + +- Made Spookeez on Normal easier. +- Mouse is now visible in note editor + +### Fixed + +- Crash when playing Week 3 and then playing a non-week 3 song +- When pausing music at the start, it doesn't continue the song anyways. ([shoutouts gedehari for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/48)) +- IDK i think backing out of song menu should play main menu songs again hehe ([shoutouts gedehari for the Pull Request!](https://github.com/FunkinCrew/Funkin/pull/48)) + + + +## [0.2.4] - 2020-12-11 + +### Added + +- 3 NEW SONGS BY KAWAISPRITE. Pico, Philly, and Blammed. +- NEW CHARACTER, PICO. Based off the classic Flash game "Pico's School" by Tom Fulp +- NEW LEVEL WOW! PHILLY BABEEEE + +### Changed + +- Made it less punishing to ATTEMPT to hit a note and miss, rather than let it pass you + +### Fixed + +- Song desync of you paused and unpaused frequently ([shoutouts SonicBlam](https://github.com/FunkinCrew/Funkin/issues/37)) +- Animation offsets when GF is scared + + + +## [0.2.3] - 2020-12-04 + +### Added + +- More intro texts + +### Fixed + +- Exploit where you could potentially give yourself a high score via the debug menu +- Issue/bug where you could spam the confirm button on the story menu ([shoutouts lotusotho for the CODE contribution/pull request!](https://github.com/FunkinCrew/Funkin/pull/19)) +- Glitch where if you never would lose health if you missed a note on a fast song (shoutouts [MrDulfin](https://github.com/FunkinCrew/Funkin/issues/10), [HotSauceBurritos](https://github.com/FunkinCrew/Funkin/issues/13) and [LobsterMango](https://lobstermango.newgrounds.com)) +- Fixed tiny note bleed over thingies (shoutouts [lotusotho](https://github.com/FunkinCrew/Funkin/pull/24)) + + + +## [0.2.2] - 2020-11-20 + +### Added + +- Music playing on the freeplay menu. +- UI sounds on freeplay menu +- Score now shows mid-song. +- Menu on pause screen! Can resume, and restart song, or go back to main menu. +- New music made for pause menu! + +### Changed + +- Moved all the intro texts to its own txt file instead of being hardcoded, this allows for much easier customization. File is in the data folder, called "introText.txt", follow the format in there and you're probably good to go! + +### Fixed + +- Fixed soft lock when pausing on song finish ([shoutouts gedehari](https://github.com/FunkinCrew/Funkin/issues/15)) +- Think I fixed issue that led to in-game scores being off by 2 ([shoutouts Mike](https://github.com/FunkinCrew/Funkin/issues/4)) +- Should have fixed the 1 frame note appearance thing. ([shoutouts Mike](https://github.com/FunkinCrew/Funkin/issues/6)) +- Cleaned up some charting on South on hard mode +- Fixed some animation timings, should feel both better to play, and watch. (shoutouts Dave/Ivan lol) +- Animation issue where GF would freak out on the title screen if you returned to it([shoutouts MultiXIII](https://github.com/FunkinCrew/Funkin/issues/12)). + + + +## [0.2.1.2] - 2020-11-06 + +### Fixed + +- Story mode scores not properly resetting, leading to VERY inflated highscores on the leaderboards. This also requires me to clear the scores that are on the leaderboard right now, sorry! +- Difficulty on storymode and in freeplay scores +- Hard mode difficulty on campaign levels have been fixed + + + +## [0.2.1.1] - 2020-11-06 + +### Fixed + +- Week 2 not unlocking properly + + + +## [0.2.1] - 2020-11-06 + +### Added + +- Scores to the freeplay menu +- A few new intro boot messages. +- Lightning effect in Spooky stages +- Campaign scores, can now compete on scoreboards for campaign! +- Can now change difficulties in Freeplay mode + +### Changed + +- Balanced out Normal mode for the harder songs(DadBattle and Spookeez, not South yet). Should be much easier all around. +- Put tutorial in it's own 'week', so that if you want to play week 1, you don't have to play the tutorial. + +### Fixed + +- One of the charting bits on South and Spookeez during the intro. + + + +## [0.2.0] - 2020-11-01 + +### Added + +- Uhh Newgrounds release lolol I always lose track of shit. + + + +## [0.1.0] - 2020-10-05 + +### Added + +- Uh, everything. This the game's initial gamejam release. We put it out diff --git a/CODESTYLE.md b/CODESTYLE.md new file mode 100644 index 00000000..32723531 --- /dev/null +++ b/CODESTYLE.md @@ -0,0 +1,32 @@ +# Code Style Guide + +Code style is enforced using Visual Studio Code extensions. + +## .hx +Formatting is handled by the `nadako.vshaxe` extension, which includes Haxe Formatter. +Haxe Formatter automatically resolves issues such as indentation style and line breaks, and can be configured in `hxformat.json`. + +Code Quality is handled by the `vshaxe.haxe-checkstyle` extension, which includes Haxe Checkstyle. + +### Haxe Checkstyle Notes +* Checks can be escalated to display as different severities in the Problems window. + * Checks can be disabled by setting the severity to `IGNORE`. +* `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. + - Re-configure `MethodLength` + - Re-configure `CyclomaticComplexity` + - Re-enable `MagicNumber` + - Re-configure `NestedControlFlow` + - Re-configure `NestedIfDepth` + - Figure out something for `Trace` + - Fix bug and enable `DocCommentStyle` + +## .json +Formatting is handled by the `esbenp.prettier-vscode` extension, which includes Prettier. +Prettier automatically handles formatting of JSON files, and can be configured in `.prettierrc.js`. + +### 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 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/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..68a0cba2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,213 @@ +# Friday Night Funkin' + +The Friday Night Funkin' source code is licensed under the Apache 2.0 license: (https://www.apache.org/licenses/LICENSE-2.0) + +Friday Night Funkin' Copyright 2020-2024 The Funkin' Crew Inc. +All Rights Reserved. "Friday Night Funkin'" and the "Friday Night Funkin'" logo are trademarks of The Funkin' Crew Inc. + +You can view the `funkin-assets` license here: (https://github.com/FunkinCrew/funkin.assets/blob/main/LICENSE.md) + +## Apache 2.0 License +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020-2024 The Funkin' Crew Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` diff --git a/README.md b/README.md new file mode 100644 index 00000000..59d1cb3d --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Friday Night Funkin' + +Friday Night Funkin' is a rhythm game. Built using HaxeFlixel for Ludum Dare 47. + +This game was made with love to Newgrounds and its community. Extra love to Tom Fulp. + +- [Playable web demo on Newgrounds!](https://www.newgrounds.com/portal/view/770371) +- [Demo download builds for Windows, Mac, and Linux from Itch.io!](https://ninja-muffin24.itch.io/funkin) + +# Getting Started + +**PLEASE USE THE LINKS ABOVE IF YOU JUST WANT TO PLAY THE GAME** + +To learn how to install the necessary dependencies and compile the game from source, please follow our [Compiling Guide](/docs/COMPILING.md). + +# Contributing + +Check out our [Contributing Guide](/docs/CONTRIBUTING.md) to learn how you can actively contribute to the development of Friday Night Funkin'! + +# Modding + +Feel free to start learning to mod the game by reading our [documentation](https://funkincrew.github.io/funkin-modding-docs/) and guide to modding. + +# Credits and Special Thanks + +Full credits can be found in-game, or in the `credits.json` file which is located [here](https://github.com/FunkinCrew/funkin.assets/blob/main/exclude/data/credits.json). + +## Programming +- [ninjamuffin99](https://twitter.com/ninja_muffin99) - Lead Programmer +- [EliteMasterEric](https://twitter.com/EliteMasterEric) - Programmer +- [MtH](https://twitter.com/emmnyaa) - Charting and Additional Programming +- [GeoKureli](https://twitter.com/Geokureli/) - Additional Programming +- Our contributors on GitHub + +## Art / Animation / UI +- [PhantomArcade3K](https://twitter.com/phantomarcade3k) - Artist and Animator +- [Evilsk8r](https://twitter.com/evilsk8r) - Art +- [Moawling](https://twitter.com/moawko) - Week 6 Pixel Art +- [IvanAlmighty](https://twitter.com/IvanA1mighty) - Misc UI Design + +## Music +- [Kawaisprite](https://twitter.com/kawaisprite) - Musician +- [BassetFilms](https://twitter.com/Bassetfilms) - Music for "Monster", Additional Character Design + +## Special Thanks +- [Tom Fulp](https://twitter.com/tomfulp) - For being a great guy and for Newgrounds +- [JohnnyUtah](https://twitter.com/JohnnyUtahNG/) - Voice of Tankman +- [L0Litsmonica](https://twitter.com/L0Litsmonica) - Voice of Mommy Mearest diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 00000000..2ea9ecce --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,188 @@ +FROM ubuntu:noble + +ARG haxe_version=4.3.6 +ARG haxelib_version=4.1.0 +ARG neko_version=2.4.0 + +# prepare runner +ENV GITHUB_HOME="/github/home" + +RUN <> /etc/apt/apt.conf.d/10apt-autoremove +APT::Get::AutomaticRemove "0"; +APT::Get::HideAutoRemove "1"; +EOC + +echo <> /etc/apt/apt.conf.d/80retries +"APT::Acquire::Retries \"10\";" +EOC + +echo <> /etc/apt/apt.conf.d/90assumeyes +"APT::Get::Assume-Yes \"true\";" +EOC +EOF + +# Prepare apt-fast +RUN <> /etc/gitconfig +[safe] + directory = * +EOC + +ssh-keyscan -t rsa,ecdsa,ed25519 github.com >> /etc/ssh/ssh_known_hosts +ssh-keyscan -t rsa,ecdsa,ed25519 ravy.dev >> /etc/ssh/ssh_known_hosts +EOF + +# Haxe native dependencies +RUN <", + "<", + ">=", + "<=", + "==", + "!=", + "&", + "|", + "^", + "&&", + "||", + "<<", + ">>", + ">>>", + "+=", + "-=", + "*=", + "/=", + "%=", + "<<=", + ">>=", + ">>>=", + "|=", + "&=", + "^=", + "...", + "=>", + "++", + "--" + ], + "option": "nl" + }, + "type": "OperatorWrap" + }, + { + "props": { + "tokens": ["="], + "option": "eol" + }, + "type": "OperatorWrap" + }, + { + "props": { + "ignoreExtern": true, + "format": "^(_|[a-z][a-zA-Z0-9]*$)", + "tokens": [] + }, + "type": "ParameterName" + }, + { + "props": { + "max": 7, + "ignoreOverriddenMethods": false + }, + "type": "ParameterNumber" + }, + { + "props": { + "severity": "WARNING" + }, + "type": "PublicAccessor" + }, + { + "props": { + "prohibitMeta": false + }, + "type": "RedundantAccessMeta" + }, + { + "props": { + "prohibitMeta": false + }, + "type": "RedundantAllowMeta" + }, + { + "props": { + "enforcePrivate": false, + "enforcePublic": true, + "enforcePublicPrivate": false + }, + "type": "RedundantModifier" + }, + { + "props": { + "enforceReturnTypeForAnonymous": false, + "allowEmptyReturn": true, + "enforceReturnType": true + }, + "type": "Return" + }, + { + "props": { + "ignoreFormat": "^$", + "max": 2, + "severity": "IGNORE" + }, + "type": "ReturnCount" + }, + { + "props": { + "severity": "IGNORE" + }, + "type": "RightCurly" + }, + { + "props": { + "commaPolicy": "after", + "semicolonPolicy": "after", + "allowTrailingComma": false, + "dotPolicy": "none" + }, + "type": "SeparatorWhitespace" + }, + { + "props": { + "tokens": [","], + "option": "eol" + }, + "type": "SeparatorWrap" + }, + { + "props": {}, + "type": "SimplifyBooleanExpression" + }, + { + "props": {}, + "type": "SimplifyBooleanReturn" + }, + { + "props": { + "spaceIfCondition": "should", + "spaceAroundBinop": true, + "spaceForLoop": "should", + "ignoreRangeOperator": true, + "spaceWhileLoop": "should", + "spaceCatch": "should", + "spaceSwitchCase": "should", + "noSpaceAroundUnop": true + }, + "type": "Spacing" + }, + { + "props": { + "policy": "onlySingle", + "allowException": true, + "severity": "IGNORE" + }, + "type": "StringLiteral" + }, + { + "props": { + "format": "^\\s*(TODO|FIXME|HACK|XXX|BUG)" + }, + "type": "TODOComment" + }, + { + "props": { + "ignorePattern": "^$" + }, + "type": "TabForAligning" + }, + { + "props": { + "severity": "IGNORE" + }, + "type": "Trace" + }, + { + "props": {}, + "type": "TrailingWhitespace" + }, + { + "props": { + "tokens": ["CLASS_DEF", "ENUM_DEF", "INTERFACE_DEF", "TYPEDEF_DEF"] + }, + "type": "TypeDocComment" + }, + { + "props": { + "ignoreExtern": true, + "format": "^[A-Z]+[a-zA-Z0-9]*$", + "tokens": [] + }, + "type": "TypeName" + }, + { + "props": {}, + "type": "UnnecessaryConstructor" + }, + { + "props": { + "moduleTypeMap": {}, + "ignoreModules": [] + }, + "type": "UnusedImport" + }, + { + "props": { + "severity": "WARNING" + }, + "type": "UnusedLocalVar" + }, + { + "props": { + "typeHintPolicy": "enforce_all", + "ignoreEnumAbstractValues": true + }, + "type": "VarTypeHint" + }, + { + "props": { + "tokens": [",", ";"], + "allowTrailingComma": false + }, + "type": "WhitespaceAfter" + }, + { + "props": { + "tokens": [ + "=", + "+", + "-", + "*", + "/", + "%", + ">", + "<", + ">=", + "<=", + "==", + "!=", + "&", + "|", + "^", + "&&", + "||", + "<<", + ">>", + ">>>", + "+=", + "-=", + "*=", + "/=", + "%=", + "<<=", + ">>=", + ">>>=", + "|=", + "&=", + "^=", + "=>" + ] + }, + "type": "WhitespaceAround" + } + ], + "numberOfCheckerThreads": 5 +} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..1a7d3d04 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,90 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +[remote.github] +owner = "FunkinCrew" +repo = "Funkin" + +# To bypass "you have reached your rate limit!", you can input a github token either as an environment variable +# or alongside the `git cliff` command like so `git cliff --github-token TOKEN_HERE` +# Personally I like to use the github cli `gh` tool to get a token +# `git cliff --github-token $(gh auth token)` + +[changelog] +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} + +{% if version -%} + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} + ## [Unreleased] +{% endif -%} + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {%- for commit in commits %} + - {% if commit.scope %}({{commit.scope}}) {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }} \ + ([{{ commit.id | truncate(length=7, end="") }}]({{self::remote_url()}}/commit/{{ commit.id }})) -\ + {% if commit.remote.username %} by @{{ commit.remote.username }} + {%- elif commit.author.name %} by {{ commit.author.name }} + {%- endif -%} + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ + {%- endif -%} + {% if commit.links | length != 0 %}\ + [#{{ commit.links }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ + {%- endif -%} + {% endfor %} +{% endfor %} + +{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} + ## New Contributors +{%- endif -%} + +{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} + * @{{ contributor.username }} made their first contribution + {%- if contributor.pr_number %} in \ + [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ + {%- endif %} +{%- endfor %}\n +""" + +# remove the leading and trailing whitespace from the templates +trim = true + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# regex for preprocessing the commit messages +commit_preprocessors = [ + # remove issue numbers from commits + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^docs", group = "Changed", scope="docs" }, + { message = "^[a|A]dd", group = "Added" }, + { message = "^[s|S]upport", group = "Added" }, + { message = "^[r|R]emove", group = "Removed" }, + { message = "^.*: add", group = "Added" }, + { message = "^.*: support", group = "Added" }, + { message = "^.*: remove", group = "Removed" }, + { message = "^.*: delete", group = "Removed" }, + { message = "^test", group = "Fixed" }, + { message = "^fix", group = "Fixed" }, + { message = "^.*: fix", group = "Fixed" }, + { message = "^.*", group = "Changed" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" + diff --git a/docs/COMPILING.md b/docs/COMPILING.md new file mode 100644 index 00000000..f537f8a8 --- /dev/null +++ b/docs/COMPILING.md @@ -0,0 +1,50 @@ +# Compiling Friday Night Funkin' + +0. Setup + - Download Haxe from [Haxe.org](https://haxe.org) + - Download Git from [git-scm.com](https://www.git-scm.com) + - Do NOT download the repository using the Download ZIP button on GitHub or you may run into errors! + - Instead, open a command prompt and do the following steps... +1. Run `cd the\directory\you\want\the\source\code\in` to specify which folder the command prompt is working in. + - For example, `cd C:\Users\YOURNAME\Documents` would instruct the command prompt to perform the next steps in your Documents folder. +2. Run `git clone https://github.com/FunkinCrew/funkin.git` to clone the base repository. +3. Run `cd funkin` to enter the cloned repository's directory. +4. Run `git submodule update --init --recursive` to download the game's assets. + - NOTE: By performing this operation, you are downloading Content which is proprietary and protected by national and international copyright and trademark laws. See [the LICENSE.md file for the Funkin.assets](https://github.com/FunkinCrew/funkin.assets/blob/main/LICENSE.md) repo for more information. +5. Run `haxelib --global install hmm` and then `haxelib --global run hmm setup` to install hmm.json +6. Run `hmm install` to install all haxelibs of the current branch + - If you have issues installing Lime, try utilizing Funkin's patched libraries for hmm and haxelib: `haxelib --global git haxelib https://github.com/FunkinCrew/haxelib.git` and `haxelib --global git hmm https://github.com/FunkinCrew/hmm.git` +8. Run `haxelib run lime setup` to set up Lime +9. Perform additional platform setup + - For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe) + - When prompted, select "Individual Components" and make sure to download the following: + - MSVC v143 VS 2022 C++ x64/x86 build tools + - Windows 10/11 SDK + - Mac: [`lime setup mac` Documentation](https://lime.openfl.org/docs/advanced-setup/macos/) + - Linux: [`lime setup linux` Documentation](https://lime.openfl.org/docs/advanced-setup/linux/) + - One of Funkin's dependencies uses libVLC, which requires you to install some development packages to be able to compile. + Command for Ubuntu/Debian based systems: `sudo apt install libvlc-dev libvlccore-dev libvlccore9`, and for other distros, please refer to [hxvlc's documentation](https://github.com/MAJigsaw77/hxvlc?tab=readme-ov-file#dependencies) + - HTML5: Compiles without any extra setup +10. If you are targeting for native, you may need to run `lime rebuild ` and `lime rebuild -debug` +11. `lime test ` to build and launch the game for your platform (for example, `lime test windows`) + +## Build Flags + +There are several useful build flags you can add to a build to affect how it works. A full list can be found in `project.hxp`, but here's information on some of them: + +- `-debug` to build the game in debug mode. This automatically enables several useful debug features. + - This includes enabling in-game debug functions, disables compile-time optimizations, enabling asset redirection (see below), and enabling the VSCode debug server (which can slow the game on some machines but allows for powerful debugging through breakpoints). + - `-DGITHUB_BUILD` will enable in-game debug functions (such as the ability to time travel in a song by pressing `PgUp`/`PgDn`), without enabling the other stuff +- `-DFEATURE_POLYMOD_MODS` or `-DNO_FEATURE_POLYMOD_MODS` to forcibly enable or disable modding support. +- `-DREDIRECT_ASSETS_FOLDER` or `-DNO_REDIRECT_ASSETS_FOLDER` to forcibly enable or disable asset redirection. + - This feature causes the game to load exported assets from the project's assets folder rather than the exported one. Great for fast iteration, but the game will break if you try to zip it up and send it to someone, so it's disabled for release builds. +- `-DFEATURE_DISCORD_RPC` or `-DNO_FEATURE_DISCORD_RPC` to forcibly enable or disable support for Discord Rich Presence. +- `-DFEATURE_VIDEO_PLAYBACK` or `-DNO_FEATURE_VIDEO_PLAYBACK` to forcibly enable or disable video cutscene support. +- `-DFEATURE_CHART_EDITOR` or `-DNO_FEATURE_CHART_EDITOR` to forcibly enable or disable the chart editor in the Debug menu. +- `-DFEATURE_SCREENSHOTS` or `-DNO_FEATURE_SCREENSHOTS` to forcibly enable or disable the screenshots feature. +- `-DFEATURE_STAGE_EDITOR` to forcibly enable the experimental stage editor. +- `-DFEATURE_GHOST_TAPPING` to forcibly enable an experimental gameplay change to the anti-mash system. + +# Troubleshooting + +If you experience any issues during the compilation process, DO NOT open an issue on GitHub. Instead, check the [Troubleshooting Guide](TROUBLESHOOTING.md) for steps on how to resolve common problems. diff --git a/docs/COMPILING_MAC.md b/docs/COMPILING_MAC.md new file mode 100644 index 00000000..d62a8b86 --- /dev/null +++ b/docs/COMPILING_MAC.md @@ -0,0 +1,10 @@ +# Mac Compiling Guide + Considerations + +There's a few extra considerations when compiling FNF for Mac that *we* have to handle when creating a wider release. +- [Creating a Universal Binary](#creating-a-universal-binary) +- Code-signing +- Notarizing + +## Creating a Universal Binary + +Run the `art/macos-universal.sh` script, which automatically compiles release versions of both arm64 and x86 of Funkin. You can also see there for reference of how it's done. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..563ee24b --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,418 @@ +# Contributing +Welcome to the Contributing Guide! +You can contribute to the Funkin' repository by opening issues or pull requests. + +This guide will cover best practices for each type of contribution. + +# Table of Contents +
+Contents + +[Part 1: Etiquette](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#part-1-etiquette) + +
+Part 2: Issues + +* [Requirements](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#requirements) + +* [Rejected Features](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#rejected-features) + +* [Issue Types](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#issue-types) + +* [Before You Submit...](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#before-you-submit) + +
+ +
+ +Part 3: Pull Requests + +* [Choosing a base branch](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#choosing-a-base-branch) + +* [Merge conflicts and rebasing](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#merge-conflicts-and-rebasing) + +* [Code PRs](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#code-prs) + +* [Documentation PRs](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#documentation-prs) + +* [GitHub PRs](https://github.com/FunkinCrew/Funkin/blob/main/docs/CONTRIBUTING.md#github-prs) + +* [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) + +
+ + +# Part 1: Etiquette +- Be respectful to one another. We're here to help each other out! +- Keep your titles clear and concise. Do not type the whole description into the title. +- Do not spam by creating unnecessary issues and pull requests. +- Use common sense! + +# 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). + +## Requirements +Make sure you're playing: +- the latest version of the game (currently v0.6.4) +- without any mods +- on [Newgrounds](https://www.newgrounds.com/portal/view/770371) or downloaded from [itch.io](https://ninja-muffin24.itch.io/funkin) + +## Rejected Features +If you want to **suggest a feature**, make sure it hasn't already been rejected. +Here's a list of commonly suggested features and the reasons why they won't be added: + +| Feature | Reason | +| ------- | ------- | +| Combo Break + Accuracy Displays | https://github.com/FunkinCrew/Funkin/pull/2681#issuecomment-2156308982 | +| Toggleable Ghost Tapping | https://github.com/FunkinCrew/Funkin/pull/2564#issuecomment-2119701802 | +| Perfectly Centered Strumlines | _same as above^_ | +| MultiKey, 9k, More than 4 keys, etc. | https://github.com/FunkinCrew/Funkin/issues/4243#issuecomment-2692371969 | +| Flashy Combo Milestones | https://github.com/FunkinCrew/Funkin/pull/4700#issuecomment-2798916579 | +| Losing Icons for DD and Parents | https://github.com/FunkinCrew/Funkin/issues/3048#issuecomment-2243491536 | +| Playable GF / Speaker BF / Speaker Pico | https://github.com/FunkinCrew/Funkin/issues/2953#issuecomment-2216985230 | +| New Intro Text Lines | https://github.com/FunkinCrew/Funkin/issues/5031#issuecomment-2855593376 | +| Fresh (Chill Mix) as Title Screen Music | https://github.com/FunkinCrew/Funkin/pull/4282#issuecomment-2709334718 | +| Difficulty Ratings above 20 | https://github.com/FunkinCrew/Funkin/issues/3075#issuecomment-2368984497 | +| Quick Restart Keybind (not R) | https://github.com/FunkinCrew/Funkin/issues/3268#issuecomment-2351095232 | +| Countdown after Unpausing Song | https://github.com/FunkinCrew/Funkin/issues/2721#issuecomment-2159330106 | +| "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 | + +## Issue Types +Choose the issue template that best suits your needs! +Here's what each template is designed for: + +### Bug Report ([view list](https://github.com/FunkinCrew/Funkin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22type%3A%20minor%20bug%22)) +For minor bugs and general issues with the game. Choose this one if none of the others fit your needs. + +### Crash Report ([view list](https://github.com/FunkinCrew/Funkin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22type%3A%20major%20bug%22)) +For crashes and freezes like the [Null Object Reference](https://github.com/FunkinCrew/Funkin/issues/2209) problem from v0.3.0. + +### Charting Issue ([view list](https://github.com/FunkinCrew/Funkin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22type%3A%20charting%20issue%22)) +For misplaced notes, wonky camera movements, broken song events, and everything related to the game's charts. + +### Enhancement ([view list](https://github.com/FunkinCrew/Funkin/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22type%3A%20enhancement%22)) +For suggestions to add new features or improve existing ones. We'd love to hear your ideas! + +### Compiling Help (only after reading the [Troubleshooting Guide](https://github.com/FunkinCrew/Funkin/blob/main/docs/TROUBLESHOOTING.md)) +For issues with compiling the game. Legacy versions (before v0.3.0) are not supported. + +> [!TIP] +> If none of the above Issue templates suit your inquiry (e.g. Questions or Coding Help), please [open a discussion](https://github.com/FunkinCrew/Funkin/discussions). + +## Before You Submit... +Complete the Issue Checklist at the top of your template! + +> [!IMPORTANT] +> If you do not complete each step of the Issue Checklist, **your issue may be closed.** + +Be sure to use the search bar on the Issues page to check that your issue hasn't already been reported by someone else. +Duplicate issues make it harder to keep track of important issues with the game. + +Also only report one issue or enhancement at a time! If you have multiple bug reports or suggestions, split them up into separate submissions so they can be checked off one by one. + +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 +Community members are welcome to contribute their changes by [opening pull requests](https://github.com/FunkinCrew/Funkin/pulls). +This section covers guidelines for opening and managing pull requests (PRs). + +## Choosing a base branch +When creating a branch in your fork, base your branch on either the `main` or `develop` branch depending on the types of changes you want to make. + +> [!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) +- GitHub files (`.yml` files or anything in the `.github` folder) + +Choose the `develop` branch if you modify: +- Game code (`.hx` files) +- Any other type of file + +> [!TIP] +> When in doubt, base your branch on the `develop` branch. + +Choosing the right base branch helps keep your commit history clean and avoid merge conflicts. +Once you’re satisfied with the changes you’ve made, open a PR and base it on the same branch you previously chose. + +## Maintaining your pull request +Keeping your pull request clean and easy to review increases the chance that it will be accepted! + +Our maintenance policy is as follows: +- If we require changes to your PR, we will label your PR `status: needs revision`. +- We may also leave a comment under your PR specifying what changes you should make. +- If you receive a comment, you have 90 days to implement the requested changes. +- After this period, your PR will be closed due to inactivity and labeled `status: stale`. +- Even after your PR is closed, you may request for us to reopen it. Just be sure to address the issues! + +This policy ensures that PRs awaiting review are up to date and ready to merge. + +### Merge conflicts and rebasing +Some game updates introduce significant breaking changes that may create merge conflicts in your PR. To resolve them, you will need to update or rebase your PR. + +Most merge conflicts are small and will only require you to modify a few files to resolve them. +However, some changes are so big that your commit history will be a total mess! +In this case, you will have to perform a [**rebase**](https://docs.github.com/en/get-started/using-git/about-git-rebase). +This process reapplies your changes on top of the updated branch and cleanly resolves the merge conflicts. + +> [!TIP] +> If your commit history becomes too long, you can use rebase to `squash` your PR's commits into a single commit. + +## Code PRs + +> [!IMPORTANT] +> 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). 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. 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 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 +{ + // 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 + { + 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 +{ + 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(); +} +``` + +```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. + +This involves modifying one or several of the repository’s `.md` files, found throughout the repository. + +Make sure your changes are easy to understand and formatted consistently to maximize clarity and readability. + +> [!CAUTION] +> DO NOT TOUCH THE `LICENSE.md` FILE, EVEN TO MAKE SMALL CHANGES! + +### Example Documentation PR +#### DO NOT: +``` +// The original documentation +- `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! +- `F5`: ***HOT RELOAD***: Forcibly reload the game's scripts and data files, then restart the current state. If any files in the `assets` folder have been modified, the game should process the changes for you! NOTE: Known bug, this does not reset song charts or song scripts, but it should reset everything else (such as stage layout data and character animation data). +- `CTRL-SHIFT-L`: ***FORCE CRASH***: Immediately crash the game with a detailed crash log and a stack trace. + +// The new PR additions +- `ctrl alt shift e`: No idea what this does +- `alt-f4`: closes the game +``` + +#### DO: +``` +// The original documentation +- `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! +- `F5`: ***HOT RELOAD***: Forcibly reload the game's scripts and data files, then restart the current state. If any files in the `assets` folder have been modified, the game should process the changes for you! NOTE: Known bug, this does not reset song charts or song scripts, but it should reset everything else (such as stage layout data and character animation data). +- `CTRL-SHIFT-L`: ***FORCE CRASH***: Immediately crash the game with a detailed crash log and a stack trace. + +// The new PR additions +- `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. +- `ALT-F4`: ***CLOSE GAME***: Closes the game forcibly on Windows. +``` + +## GitHub PRs +GitHub-related PRs make changes such as **tweaking Issue Templates** or **updating the repository’s workflows**. + +This involves modifying one or several of the repository’s `.yml` files, or any other file in the `.github` folder. + +Please test these changes on your fork’s main branch to avoid breaking anything in this repository (e.g. GitHub Actions, issue templates, etc.)! + +## funkin.assets PRs +The `assets` submodule has its own repository called [funkin.assets](https://github.com/FunkinCrew/funkin.assets). + +If you only modify files in the `assets` folder, open a PR in the `funkin.assets` repository instead of the main repository. + +If you simultaneously modify files from both repositories, then open two separate PRs and explain the connection in your PR descriptions. + +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 **adjusting chart metadata**. + +This involves modifying one or several of the `funkin.assets` repository's `*-metadata.json` files, found in the `preload/data/songs/` directory. + +> [!CAUTION] +> We **do not** accept changes to the chart `.json` file itself. +> If you find an error in the game's charts, please open an issue in the `Funkin` repo, and the Funkin' Crew will review it and make tweaks themselves as necessary. + +These PRs should only be opened in the `funkin.assets` repository. + +# Closing +Thank you for reading the Contributing Guide. + +We look forward to seeing your contributions to the game! diff --git a/docs/FNFC-SPEC.md b/docs/FNFC-SPEC.md new file mode 100644 index 00000000..3a444843 --- /dev/null +++ b/docs/FNFC-SPEC.md @@ -0,0 +1,186 @@ +# .fnfc File Specification + +*Updated 2024-04-29* + +- Manifest version: `1.0.0` +- Metadata version: `2.2.2` +- Chart data version: `2.0.0` + +## Introduction + +This document describes the structure of the FNFC file format used for saving and loading charts in the Chart Editor for Friday Night Funkin'. It is designed to refactor how the game stores levels, as the original format was clunky and poorly extensible. + +FNFC files are a store of all the required files for crediting and editing charts. This includes any relevant audio files, which is perfect for collaboration between charters. + +## Overview + +Friday Night Funkin' charts utilize a concept called "variations"; these are groups of difficulties which share gameplay data. This is done to prevent significant redundancy; if each chart + +If a difficulty for a song should have the same events as another difficulty, those difficulties should be in the same variation (for example, Normal and Hard). If a particular difficulty for a song should have different song events, characters, stages, or music (including instruments or vocals) from the base variation, they should use a different variation. Difficulties for a song start in the `default` variation. + +An example of this is the Erect and Nightmare difficulties. These are defined in an alternative variation, which allows for those difficulties to utilize different events and music from the base variation. + +The Chart Editor is made aware of which variations are available in the current chart file by querying the `playData.songVariations` key in the `default` variation's metadata file. + +The chart data itself is split into two files. The `-metadata.json` file should contain all the information the game needs to display a song in menus such as Freeplay, including but not limited to the song name, artist, and BPM. The `-chart.json` should only contain the note data for each difficulty of that variation, and the song event data for the variation. This allows the game to, when it first loads, parse and cache the metadata for all available songs for use in menus, while keeping the bulky chart file data unloaded until that chart specifically is played. + +Note that the game itself does not store its songs as FNFC files; rather, it stores the `metadata.json`, `chart.json`, and `.ogg` files separately. + +Note also that the files may include some values whose functionality are not yet fully implemented into the game itself. + +## File Contents + +FNFC files are standard ZIP files containing the following: + +- `manifest.json`: This file contains minimal information to minimize work in the parsing of chart files. +- `Inst.ogg`: Song instrumental for the default variation. +- `Inst-.ogg`: *(optional)* An alternative instrumental which can be used. +- `Voices-.ogg`: *(optional)* Song vocals for a specific character, for the default variation. +- `Voices--.ogg`: *(optional)* Song vocals for a specific character, for an alternate variation. +- `-metadata.json`: Song metadata for the `default` variation. +- `-metadata-.json`: *(optional)* Song metadata for alternate variations. +- `-chart.json`: Song chart data for the `default` variation. +- `-chart-.json`: *(optional)* Song chart data for alternate variations. + +## Files + +Note that each component file contains its own separate [Semantic Version](https://semver.org/) number which the game adheres to when parsing. New functionality (with backwards compatibility) should be represented by a `1.x.0` change and breaking changes should be kept to a minimum, and represented by a `x.0.0` change. + +### manifest.json + +`manifest.json` is a JSON-formatted text file, containing a single object with the following keys: + +- `version`: The Semantic Version string for the manifest file. +- `songId`: The song ID associated with this chart. Used to allow the Chart Editor to easily determine the proper filenames for the `metadata` and `chart` files. + +#### manifest.json Example + +```jsonc +{ + "version": "1.0.0", // The Semantic Version string. + "songId": "dadbattle" // The song ID. +} +``` + +### Inst.ogg + +This is an audio file in the OGG container format with the Vorbis audio codec. + +This file is mandatory. A chart file without an instrumental track is considered invalid. + +This file is used as the default backing track for the song. + +### Inst-.ogg + +This is an audio file in the OGG container format with the Vorbis audio codec. + +This file is optional if no alternative instrumental is specified in any variation. + +This file is used as an alternate backing track for the song. It is specified using the `playData.characters.instrumental` key in the current variation's metadata file. + +### Voices-.ogg + +This is an audio file in the OGG container format with the Vorbis audio codec. + +This file is optional. The game will look for the specified file but will ignore if it is missing. + +This file is used for the character vocal track for the song. The game will look for and play the vocal track for the player (using the ID specified by the `playData.characters.player` key in the current variation's metadata file), and the opponent (using the ID specified by the `playData.characters.opponent` key in the current variation's metadata file). + +### Voices--.ogg + +This is an audio file in the OGG container format with the Vorbis audio codec. + +This file is optional. The game will look for the specified file but will ignore if it is missing. + +This file is used for the character vocal track for the song, for the given variation. The game will look for and play the vocal track for the player and the opponent using the same JSON keys as the `Voices-.ogg` files for the default variation, while also applying the current variation ID. + +### -metadata.json + +`-metadata.json` is a JSON-formatted text file, specifying metadata about the default variation for this song. It has the following keys: + +- `version`: The Semantic Version string for the metadata file. +- `songName`: The human readable name for the song, as a string. +- `artist`: The human readable artist(s) for the song, as a string. +- `timeFormat`: The time format. In the future, this will allow chart files to define the timestamps for BPM changes, note data, or event data, in fractional beats and steps, but for now the only supported value is the string `"ms"`. +- `timeChanges`: An array of Song Time Change objects. Note that at least one Song Time Change object must be specified, with a timestamp of `0`. +- `playData`: A Song Play Data object. +- `offsets`: A Song Offset Data object. +- `generatedBy`: A string specified when creating a chart. Should only be used for debugging purposes, and not read or used by the game. Custom engines should modify `Constants.hx` to ensure unique values in case of issues with the metadata or chart data. + +The Song Time Change objects have the following keys: +- `t`: The timestamp for the BPM change, in milliseconds, as a float. +- `bpm`: The new song timing, in beats per minute, as a float. +- `n`: *optional* Time signature numerator, as an integer. Defaults to 4. (int). Optional, defaults to `4`. +- `d`: *optional* Time signature denominator, as an integer. Should only ever be a power of two. Defaults to `4`. +- `bt`: *optional* Beat tuplets. This defines how many steps each beat is divided into. Defaults to `[4, 4, 4, 4]` + +The Song Play Data objects have the following keys: +- `album`: The album ID to display in the Freeplay menu, as a string ID. +- `previewStart`: The timestamp to begin the audio preview for this track in the Freeplay Menu, in milliseconds, as a float. +- `previewEnd`: The timestamp to end the audio preview for this track in the Freeplay Menu, in milliseconds, as a float. +- `ratings`: An map object; each key is a difficulty ID and each value is an integer difficulty rating value for display in Freeplay. +- `songVariations`: An array of string variation IDs this song has. The game will attempt to read `-metadata-.json` and `-chart-.json` files for each variation ID included in this list. +- `difficulties`: An array of string difficulties this song has available to play. Any difficulties in this list will be made available to players in-game, and any difficulties not in this list will be ignored. +- `characters`: A Song Character Data object. +- `stage`: The stage to use for this chart, as a string ID. +- `noteStyle`: The note style to use for this chart, as a string ID. + +The Song Character Data objects have the following keys: +- `player`: The player character to use, as a string ID. +- `girlfriend`: The girlfriend character to use, as a string ID. +- `opponent`: The opponent character to use, as a string ID. +- `instrumental`: The instrumental ID to use. Defaults to a blank string to use `Inst.ogg` +- `altInstrumentals`: + +The Song Offset Data objects have the following keys: + +#### -metadata.json Example + +```jsonc +{ + "version": "2.2.2", // Semantic Version string + "songName": "DadBattle", // Readable song name + "artist": "Kawai Sprite", // Song artist(s) + "timeFormat": "ms", // Time format to use + "timeChanges": [{ // List of BPM changes. Must have at least one. + "t": 0, // BPM change timestamp in milliseconds. + "bpm": 180 // The target BPM. + }], + "playData": { + "album": "volume1", // The album to display in Freeplay. + "previewStart": 0, // Time (ms) to start preview at + "previewEnd": 15000, // Time (ms) to end preview at + "ratings": { // Rating data for each difficulty + "easy": 1, // Rating for Easy difficulty + "normal": 3, // Rating for Normal difficulty + "hard": 5 // Rating for Hard difficulty + }, + "songVariations": [ // Available variation files. + "erect" // This says dadbattle-metadata-erect.json exists. + ], + "difficulties": [ // Available difficulties. + "easy", + "normal", + "hard" + ], + "characters": { // Characters to use for this variation. + "player": "bf", // Boyfriend + "girlfriend": "gf", // Girlfriend + "opponent": "dad" // Daddy Dearest + }, + "stage": "mainStage", // Week 1 stage. + "noteStyle": "funkin" // Default note style. + }, + "generatedBy": "EliteMasterEric (by hand)" // Unique string. +} + +``` + +### -chart.json +Song chart data for the `default` variation. + +### -metadata-.json +*(optional)* Song metadata for alternate variations. + +### -chart-.json +*(optional)* Song chart data for alternate variations. diff --git a/docs/Funkin' Debug Hotkeys.md b/docs/Funkin' Debug Hotkeys.md new file mode 100644 index 00000000..dc567eda --- /dev/null +++ b/docs/Funkin' Debug Hotkeys.md @@ -0,0 +1,46 @@ +# Funkin' Debug Hotkeys + +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! +- `F5`: ***HOT RELOAD***: Forcibly reload the game's scripts and data files, then restart the current state. If any files in the `assets` folder have been modified, the game should process the changes for you! NOTE: Known bug, this does not reset song charts or song scripts, but it should reset everything else (such as stage layout data and character animation data). +- `CTRL-ALT-SHIFT-L`: ***FORCE CRASH***: Immediately crash the game with a detailed crash log and a stack trace. (Only works in the Main Menu on debug builds). + +- `CTRL-SHIFT-L`: ***FORCE CRASH***: Immediately crash the game with a detailed crash log and a stack trace. + +## **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. +- `2`: ***GAIN HEALTH***: Debug function, add 10% to the player's health. +- `3`: ***LOSE HEALTH***: Debug function, subtract 5% to the player's health. +- `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** +- `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. + +## **Title State** +- `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. diff --git a/docs/TRACY.md b/docs/TRACY.md new file mode 100644 index 00000000..cac03989 --- /dev/null +++ b/docs/TRACY.md @@ -0,0 +1,12 @@ +# Tracy Performance Profiling + +In v0.5.1, Funkin' gained support for a powerful instrumentation-based profiler known as Tracy. This development tool allows you to see exactly what the game is doing at any given moment, how long each function is taking to call, and even how memory is allocated and deallocated. This is the most powerful tool in your toolbox for diagnosing and resolving performance issues. + +## How to Use Tracy + +1. Download [Tracy](https://github.com/wolfpld/tracy) +2. Build the game with the `FEATURE_DEBUG_TRACY` flag. For example, `lime build windows -debug -DFEATURE_DEBUG_TRACY` +3. Start Tracy, click Connect. Tracy will start waiting for Funkin' to start. +4. Start the game with Tracy enabled. + +![Image](https://github.com/user-attachments/assets/2a394ca7-bc21-4fe3-ae55-347768fb5b87) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 00000000..f488f28d --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,53 @@ +# Troubleshooting Common Compilation Issues + +- Any output containing `WARNING` or `(WDeprecated)` + - Will not disrupt compilation and can be safely ignored. + +- `This version of hxcpp` ... `Would you like to do this now [y/n]` + - Type "y" into the console and press Enter. + +- Weird macro error with a very tall call stack: Restart Visual Studio Code + - NOTE: This is caused by Polymod somewhere, and seems to only occur when there is another compile error somewhere in the program. There is a bounty up for it. + +- `Get Thread Context Failed` + - Turn off other expensive applications while building. + +- `Type not found: T1` + - This is thrown by `json2object`, make sure the data type of `@:default` is correct. + - NOTE: `flixel.util.typeLimit.OneOfTwo` isn't supported. + +- `Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project.` + - This is a bug specific to HTML5. Simply perform the steps listed (don't forget to restart the IDE too). + +- `LINK : fatal error LNK1201: error writing to program database ''; check for insufficient disk space, invalid path, or insufficient privilege` + - This error occurs if the PDB file located in your `export` folder is in use or exceeds 4 GB. Try deleting the `export` folder and building again from scratch. + +- `error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)` + - This error can happen during cloning as a result of poor network connectivity. A common fix is to run `git config --global http.postBuffer 4096M` in your terminal. + +- Repository is missing an `assets` folder, or `assets` folder is empty. + - You did not clone the repository correctly! Copy the path to your `funkin` folder and run `cd the\path\you\copied`. Then follow the compilation guide starting from **Step 4**. + +- Other compilation issues may be caused by installing bad library versions. Try deleting the `.haxelib` folder and following the guide starting from **Step 5**. + +## Lime Related Issues +- Segmentation fault and/or crash after `Done mapping time changes: [SongTimeChange(0ms,102bpm)]` + - Caused by using official Lime instead of Funkin's fork. Reinstalling Lime should fix it. + (NOTE: Make sure you do this via `hmm` (e.g `hmm reinstall -f lime`) to guarantee you get Funkin's version of Lime.) + +- `Uncaught exception - Could not find lime.ndll.` ... `Advanced users may run "lime rebuild cpp" instead.` + - If on Linux: + - The binaries GLibC version might more recent than the one your system supports. Running the commands below should fix it. + ``` + cd .haxelib/lime/git + git submodule init + git submodule sync + git submodule update + cd ../../.. + # Note: The command and packages here might be different depending on your distro. + sudo apt install libgl1-mesa-dev libglu1-mesa-dev g++ g++-multilib gcc-multilib libasound2-dev libx11-dev libxext-dev libxi-dev libxrandr-dev libxinerama-dev libpulse-dev + lime rebuild cpp -64 -release -clean + ``` + + - The binaries are missing for some reason. You can download pre-built binaries from [Funkin's Lime](https://github.com/FunkinCrew/lime/tree/dev-funkin/ndll). + You should copy them to `.haxelib/lime/git/ndll/64/`, where `` is the current platform you're on, which can be either Windows, Linux or MacOS. diff --git a/docs/style-guide.md b/docs/style-guide.md new file mode 100644 index 00000000..4df4a163 --- /dev/null +++ b/docs/style-guide.md @@ -0,0 +1,65 @@ +# Funkin' Repo Code Style Guide + +This short document is designed to give a run-down on how code should be formatted to maintain a consistent style throughout, making the repo easier to maintain. + +## Notes on IDEs and Extensions + +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. + +## Whitespace and Indentation + +The Haxe extension of VSCode will use the repo's `hxformat.json` to tell VSCode how to format the code files of the repo. Enabling VSCode's "Format on Save" feature is recommended. + +## Variable and Function Names + +It is recommended to name variables and functions with descriptive titles, in lowerCamelCase. There is no penalty for giving a variable a longer name as long as it isn't excessive. + +## Code Comments + +The CodeDox extension for VSCode provides extensive support for JavaDoc-style code comments, and these should be used for public functions wherever possible to ensure the usage of each function is clear. + +Example: +``` +/** + * Finds the largest deviation from the desired time inside this VoicesGroup. + * + * @param targetTime The time to check against. + * If none is provided, it checks the time of all members against the first member of this VoicesGroup. + * @return The largest deviation from the target time found. + */ +public function checkSyncError(?targetTime:Float):Float +``` + +## Commenting Unused Code + +Do not comment out sections of code that are unused. Keep these snippets elsewhere or remove them. Older chunks of code can be retrieved by referring to the older Git commits, and having large chunks of commented code makes files longer and more confusing to navigate. + +## License Headers + +Do not include headers specifying code license on individual files in the repo, since the main `LICENSE.md` file covers all of them. + +## Imports + +Imports should be placed in a single group, in alphabetical order, at the top of the code file. The exception is conditional imports (using compile defines), which should be placed at the end of the list (and sorted alphabetically where possible). + +Example: +``` +import haxe.format.JsonParser; +import openfl.Assets; +import openfl.geom.Matrix; +import openfl.geom.Matrix3D; +#if sys +import funkin.io.FileUtil; +import sys.io.File; +#end +``` + +## Argument Formatting + +[Optional arguments](https://haxe.org/manual/types-function-optional-arguments.html) and [default arguments](https://haxe.org/manual/types-function-default-values.html) should be mutually exclusive and not used together! + +For example, `myFunction(?input:Int)` should be used if you want the argument to be a `Null` whose value is `null` if no value is passed, and `myFunction(input:Int = 0)` should be used if you want the argument to be an `Int`, whose value is `0` if no value is passed. + +Using both at the same time is considered valid by Haxe, but `myFunction(?input:Int = 0)` results in a `Null` whose value defaults to 0 anyway, so it's never null, but it's annotated as nullable! The biggest consequence of this is that it makes null safety more annoying. diff --git a/example_mods/introMod/README.md b/example_mods/introMod/README.md new file mode 100644 index 00000000..060eaba5 --- /dev/null +++ b/example_mods/introMod/README.md @@ -0,0 +1,6 @@ +# introMod + +This intro mod demonstrates two simple things: + +1. You can replace any game asset simply by placing a modded asset in the right spot. +2. You can append to text files simply by placing a text file in the right spot, but under the `_append` directory. \ No newline at end of file diff --git a/example_mods/introMod/_polymod_icon.png b/example_mods/introMod/_polymod_icon.png new file mode 100644 index 00000000..2828bd55 Binary files /dev/null and b/example_mods/introMod/_polymod_icon.png differ diff --git a/example_mods/introMod/_polymod_meta.json b/example_mods/introMod/_polymod_meta.json new file mode 100644 index 00000000..74c4a250 --- /dev/null +++ b/example_mods/introMod/_polymod_meta.json @@ -0,0 +1,12 @@ +{ + "title": "Intro Mod", + "description": "An introductory mod.", + "contributors": [ + { + "name": "EliteMasterEric" + } + ], + "api_version": "0.5.0", + "mod_version": "1.0.0", + "license": "Apache-2.0" +} diff --git a/example_mods/introMod/data/introText.txt b/example_mods/introMod/data/introText.txt new file mode 100644 index 00000000..f0dd8224 --- /dev/null +++ b/example_mods/introMod/data/introText.txt @@ -0,0 +1 @@ +awesomes tream--really awesome \ No newline at end of file diff --git a/example_mods/testing123/_polymod_icon.png b/example_mods/testing123/_polymod_icon.png new file mode 100644 index 00000000..2828bd55 Binary files /dev/null and b/example_mods/testing123/_polymod_icon.png differ diff --git a/example_mods/testing123/_polymod_meta.json b/example_mods/testing123/_polymod_meta.json new file mode 100644 index 00000000..b8fc60b1 --- /dev/null +++ b/example_mods/testing123/_polymod_meta.json @@ -0,0 +1,12 @@ +{ + "title": "Testing123", + "description": "Newgrounds? More like OLDGROUNDS lol.", + "contributors": [ + { + "name": "EliteMasterEric" + } + ], + "api_version": "0.5.0", + "mod_version": "1.0.0", + "license": "Apache-2.0" +} diff --git a/example_mods/testing123/images/newgrounds_logo.png b/example_mods/testing123/images/newgrounds_logo.png new file mode 100644 index 00000000..2828bd55 Binary files /dev/null and b/example_mods/testing123/images/newgrounds_logo.png differ diff --git a/hmm.json b/hmm.json new file mode 100644 index 00000000..406a2a1d --- /dev/null +++ b/hmm.json @@ -0,0 +1,213 @@ +{ + "dependencies": [ + { + "name": "FlxPartialSound", + "type": "git", + "dir": null, + "ref": "41f35ddb1eb9d10bc742e6f8b5bcc62f9ef8ad84", + "url": "https://github.com/FunkinCrew/FlxPartialSound.git" + }, + { + "name": "flixel", + "type": "git", + "dir": null, + "ref": "fffb1a74cf08f63dacc2ab09976340563f5b6e6d", + "url": "https://github.com/FunkinCrew/flixel" + }, + { + "name": "flixel-addons", + "type": "git", + "dir": null, + "ref": "b9118f47f43a66bc0e5fbfcfd9903f0425e918ee", + "url": "https://github.com/FunkinCrew/flixel-addons" + }, + { + "name": "flixel-text-input", + "type": "git", + "dir": null, + "ref": "951a0103a17bfa55eed86703ce50b4fb0d7590bc", + "url": "https://github.com/FunkinCrew/flixel-text-input" + }, + { + "name": "flxanimate", + "type": "git", + "dir": null, + "ref": "f8842cea9883d6112a2c854cf93fa72856171428", + "url": "https://github.com/FunkinCrew/flxanimate" + }, + { + "name": "format", + "type": "haxelib", + "version": "3.5.0" + }, + { + "name": "funkin.vis", + "type": "git", + "dir": null, + "ref": "1966f8fbbbc509ed90d4b520f3c49c084fc92fd6", + "url": "https://github.com/FunkinCrew/funkVis" + }, + { + "name": "grig.audio", + "type": "git", + "dir": "src", + "ref": "57f5d47f2533fd0c3dcd025a86cb86c0dfa0b6d2", + "url": "https://gitlab.com/haxe-grig/grig.audio.git" + }, + { + "name": "hamcrest", + "type": "haxelib", + "version": "3.0.0" + }, + { + "name": "haxeui-core", + "type": "git", + "dir": null, + "ref": "07fc7aa8098deaea633b0726d01f83eb4ef8a832", + "url": "https://github.com/haxeui/haxeui-core" + }, + { + "name": "haxeui-flixel", + "type": "git", + "dir": null, + "ref": "b899a4c7d7318c5ff2b1bb645fbc73728fad1ac9", + "url": "https://github.com/haxeui/haxeui-flixel" + }, + { + "name": "hscript", + "type": "git", + "dir": null, + "ref": "27c86f9a761c1d16d4433c4cf252eccb7b2e18de", + "url": "https://github.com/FunkinCrew/hscript" + }, + { + "name": "hxcpp", + "type": "git", + "dir": null, + "ref": "v4.3.75", + "url": "https://github.com/HaxeFoundation/hxcpp" + }, + { + "name": "hxcpp-debug-server", + "type": "git", + "dir": "hxcpp-debug-server", + "ref": "147294123f983e35f50a966741474438069a7a8f", + "url": "https://github.com/FunkinCrew/hxcpp-debugger" + }, + { + "name": "hxdiscord_rpc", + "type": "git", + "dir": null, + "ref": "82c47ecc1a454b7dd644e4fcac7e91155f176dec", + "url": "https://github.com/FunkinCrew/hxdiscord_rpc" + }, + { + "name": "hxjsonast", + "type": "git", + "dir": null, + "ref": "20e72cc68c823496359775ac1f06500e67f189d5", + "url": "https://github.com/nadako/hxjsonast/" + }, + { + "name": "hxp", + "type": "haxelib", + "version": "1.3.0" + }, + { + "name": "hxvlc", + "type": "haxelib", + "version": "2.1.2" + }, + { + "name": "json2object", + "type": "git", + "dir": null, + "ref": "a8c26f18463c98da32f744c214fe02273e1823fa", + "url": "https://github.com/FunkinCrew/json2object" + }, + { + "name": "jsonpatch", + "type": "git", + "dir": null, + "ref": "f9b83215acd586dc28754b4ae7f69d4c06c3b4d3", + "url": "https://github.com/EliteMasterEric/jsonpatch" + }, + { + "name": "jsonpath", + "type": "git", + "dir": null, + "ref": "7a24193717b36393458c15c0435bb7c4470ecdda", + "url": "https://github.com/EliteMasterEric/jsonpath" + }, + { + "name": "lime", + "type": "git", + "dir": null, + "ref": "be81ad7e4e1a92c3482bcc009648a4ac892cfa35", + "url": "https://github.com/FunkinCrew/lime" + }, + { + "name": "mconsole", + "type": "git", + "dir": null, + "ref": "06c0499ed8f80628a0e6e55ffa32c3cbd688a838", + "url": "https://github.com/massive-oss/mconsole" + }, + { + "name": "mcover", + "type": "git", + "dir": "src", + "ref": "c3c47cd682b0b202a41caee95321989391b617ef", + "url": "https://github.com/massive-oss/mcover" + }, + { + "name": "mockatoo", + "type": "git", + "dir": "src", + "ref": "13d77a0a8eaf5e789ef5dae6cd33eee812deda36", + "url": "https://github.com/FunkinCrew/mockatoo" + }, + { + "name": "munit", + "type": "git", + "dir": "src", + "ref": "f61be7f7ba796595f45023ca65164a485aba0e7e", + "url": "https://github.com/FunkinCrew/MassiveUnit" + }, + { + "name": "newgrounds", + "type": "git", + "dir": null, + "ref": "c8b30832027fa04f48fe6a45fa7903d5b265d56e", + "url": "https://github.com/Geokureli/Newgrounds/" + }, + { + "name": "openfl", + "type": "git", + "dir": null, + "ref": "d061c936b462f040304ec2bd42d9f59d2e59e285", + "url": "https://github.com/FunkinCrew/openfl" + }, + { + "name": "polymod", + "type": "git", + "dir": null, + "ref": "0fbdf27fe124549730accd540cec8a183f8652c0", + "url": "https://github.com/larsiusprime/polymod" + }, + { + "name": "thx.core", + "type": "git", + "dir": null, + "ref": "76d87418fadd92eb8e1b61f004cff27d656e53dd", + "url": "https://github.com/fponticelli/thx.core" + }, + { + "name": "thx.semver", + "type": "git", + "dir": null, + "ref": "bdb191fe7cf745c02a980749906dbf22719e200b", + "url": "https://github.com/fponticelli/thx.semver" + } + ] +} diff --git a/hxformat.json b/hxformat.json new file mode 100644 index 00000000..88a528e3 --- /dev/null +++ b/hxformat.json @@ -0,0 +1,30 @@ +{ + "disableFormatting": false, + + "indentation": { + "character": " ", + "tabWidth": 2, + "indentCaseLabels": true + }, + "lineEnds": { + "anonFunctionCurly": { + "emptyCurly": "break", + "leftCurly": "after", + "rightCurly": "both" + }, + "leftCurly": "both", + "rightCurly": "both" + }, + + "sameLine": { + "ifBody": "same", + "ifElse": "next", + "doWhile": "next", + "tryBody": "next", + "tryCatch": "next" + }, + + "whitespace": { + "switchPolicy": "around" + } +} diff --git a/project.hxp b/project.hxp new file mode 100644 index 00000000..e0ba25c5 --- /dev/null +++ b/project.hxp @@ -0,0 +1,1193 @@ +package; + +// I don't think we can import `funkin` classes here. Macros? Recursion? IDK. +import hxp.*; +import lime.tools.*; +import sys.FileSystem; + +using StringTools; + +/** + * This HXP performs the functions of a Lime `project.xml` file, + * but it's written in Haxe rather than XML! + * + * This makes it far easier to organize, reuse, and refactor, + * and improves management of feature flag logic. + */ +@:nullSafety +class Project extends HXProject { + // + // METADATA + // + + /** + * The game's version number, as a Semantic Versioning string with no prefix. + * 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.4"; + + /** + * The game's name. Used as the default window title. + */ + static final TITLE:String = "Friday Night Funkin'"; + + /** + * The name of the generated executable file. + * For example, `"Funkin"` will create a file called `Funkin.exe`. + */ + static final EXECUTABLE_NAME:String = "Funkin"; + + /** + * The relative location of the source code. + */ + static final SOURCE_DIR:String = "source"; + + /** + * The fully qualified class path for the game's preloader. + * Particularly important on HTML5 but we use it on all platforms. + */ + static final PRELOADER:String = "funkin.ui.transition.preload.FunkinPreloader"; + + /** + * A package name used for identifying the app on various app stores. + */ + static final PACKAGE_NAME:String = "me.funkin.fnf"; + + /** + * The fully qualified class path for the entry point class to execute when launching the game. + * It's where `public static function main():Void` goes. + */ + static final MAIN_CLASS:String = "Main"; + + /** + * The company name for the game. + * This appears in metadata in places I think. + */ + static final COMPANY:String = "The Funkin' Crew"; + + /** + * Path to the Haxe script run before building the game. + */ + static final PREBUILD_HX:String = "source/Prebuild.hx"; + + /** + * Path to the Haxe script run after building the game. + */ + static final POSTBUILD_HX:String = "source/Postbuild.hx"; + + /** + * Asset path globs to always exclude from asset libraries. + */ + static final EXCLUDE_ASSETS:Array = [".*", "cvs", "thumbs.db", "desktop.ini", "*.hash", "*.md"]; + + /** + * Asset path globs to exclude on web platforms. + */ + static final EXCLUDE_ASSETS_WEB:Array = ["*.ogg"]; + /** + * Asset path globs to exclude on native platforms. + */ + static final EXCLUDE_ASSETS_NATIVE:Array = ["*.mp3"]; + + // + // FEATURE FLAGS + // Inverse feature flags are automatically populated. + // + + /** + * `-DGITHUB_BUILD` + * If this flag is enabled, the game will use the configuration used by GitHub Actions + * to generate playtest builds to be pushed to the launcher. + * + * This is generally used to forcibly enable debugging features, + * even when the game is built in release mode for performance reasons. + */ + static final GITHUB_BUILD:FeatureFlag = "GITHUB_BUILD"; + + /** + * `-DREDIRECT_ASSETS_FOLDER` + * If this flag is enabled, the game will redirect the `assets` folder from the `export` folder + * to the `assets` folder at the root of the workspace. + * This is useful for ensuring hot reloaded changes don't get lost when rebuilding the game. + */ + static final REDIRECT_ASSETS_FOLDER:FeatureFlag = "REDIRECT_ASSETS_FOLDER"; + + /** + * `-DTOUCH_HERE_TO_PLAY` + * If this flag is enabled, the game will display a prompt to the user after the preloader completes, + * requiring them to click anywhere on the screen to start the game. + * This is done to ensure that the audio context can initialize properly on HTML5. Not necessary on desktop. + */ + static final TOUCH_HERE_TO_PLAY:FeatureFlag = "TOUCH_HERE_TO_PLAY"; + + /** + * `-DPRELOAD_ALL` + * Whether to preload all asset libraries. + * Disabled on web, enabled on desktop. + */ + static final PRELOAD_ALL:FeatureFlag = "PRELOAD_ALL"; + + /** + * `-DEMBED_ASSETS` + * Whether to embed all asset libraries into the executable. + */ + static final EMBED_ASSETS:FeatureFlag = "EMBED_ASSETS"; + + /** + * `-DHARDCODED_CREDITS` + * If this flag is enabled, the credits will be parsed and encoded in the game at compile time, + * rather than read from JSON data at runtime. + */ + static final HARDCODED_CREDITS:FeatureFlag = "HARDCODED_CREDITS"; + + /** + * `-DFEATURE_DEBUG_FUNCTIONS` + * If this flag is enabled, the game will have all playtester-only debugging functionality enabled. + * This includes debug hotkeys like time travel in the Play State. + * By default, enabled on debug builds or playtester builds and disabled on release builds. + */ + static final FEATURE_DEBUG_FUNCTIONS:FeatureFlag = "FEATURE_DEBUG_FUNCTIONS"; + + /** + * `-DFEATURE_DEBUG_TRACY` + * If this flag is enabled, the game will have the necessary hooks for the Tracy profiler. + * Only enable this if you're using the correct fork of Haxe to support this. + * @see https://github.com/HaxeFoundation/hxcpp/pull/1153 + */ + static final FEATURE_DEBUG_TRACY:FeatureFlag = "FEATURE_DEBUG_TRACY"; + + /** + * `-DFEATURE_LOG_TRACE` + * If this flag is enabled, the game will print debug traces to the console. + * Disable to improve performance a bunch. + */ + static final FEATURE_LOG_TRACE:FeatureFlag = "FEATURE_LOG_TRACE"; + + /** + * `-DFEATURE_DISCORD_RPC` + * If this flag is enabled, the game will enable the Discord Remote Procedure Call library. + * This is used to provide Discord Rich Presence support. + */ + static final FEATURE_DISCORD_RPC:FeatureFlag = "FEATURE_DISCORD_RPC"; + + /** + * `-DFEATURE_NEWGROUNDS` + * If this flag is enabled, the game will enable the Newgrounds library. + * This is used to provide Medal and Leaderboard support. + */ + static final FEATURE_NEWGROUNDS:FeatureFlag = "FEATURE_NEWGROUNDS"; + + /** + * `-DFEATURE_NEWGROUNDS_DEBUG` + * If this flag is enabled, the game will enable Newgrounds.io's debug functions. + * This provides additional information in requests, as well as "faking" medal and leaderboard submissions. + */ + static final FEATURE_NEWGROUNDS_DEBUG:FeatureFlag = "FEATURE_NEWGROUNDS_DEBUG"; + + /** + * `-DFEATURE_NEWGROUNDS_AUTOLOGIN` + * If this flag is enabled, the game will attempt to automatically login to Newgrounds on startup. + */ + static final FEATURE_NEWGROUNDS_AUTOLOGIN:FeatureFlag = "FEATURE_NEWGROUNDS_AUTOLOGIN"; + + /** + * `-DFEATURE_NEWGROUNDS_TESTING_MEDALS` + * If this flag is enabled, use the medal IDs from the debug test bench. + * If disabled, use the actual medal IDs from the release project on Newgrounds. + */ + static final FEATURE_NEWGROUNDS_TESTING_MEDALS:FeatureFlag = "FEATURE_NEWGROUNDS_TESTING_MEDALS"; + + /** + * `-DFEATURE_NEWGROUNDS_EVENTS` + * If this flag is enabled, the game will attempt to send events to Newgrounds when the user does stuff. + * This lets us see cool anonymized stats! It only works if the user is logged in. + */ + static final FEATURE_NEWGROUNDS_EVENTS:FeatureFlag = "FEATURE_NEWGROUNDS_EVENTS"; + + /** + * `-DFEATURE_FUNKVIS` + * If this flag is enabled, the game will enable the Funkin Visualizer library. + * This is used to provide audio visualization like Nene's speaker. + * Disabling this will make some waveforms inactive. + */ + static final FEATURE_FUNKVIS:FeatureFlag = "FEATURE_FUNKVIS"; + + /** + * `-DFEATURE_PARTIAL_SOUNDS` + * If this flag is enabled, the game will enable the FlxPartialSound library. + * This is used to provide audio previews in Freeplay. + * Disabling this will make those previews not play. + */ + static final FEATURE_PARTIAL_SOUNDS:FeatureFlag = "FEATURE_PARTIAL_SOUNDS"; + + /** + * `-DFEATURE_VIDEO_PLAYBACK` + * If this flag is enabled, the game will enable support for video playback. + * This requires the hxvlc library on desktop platforms. + */ + static final FEATURE_VIDEO_PLAYBACK:FeatureFlag = "FEATURE_VIDEO_PLAYBACK"; + + /** + * `-DFEATURE_FILE_DROP` + * If this flag is enabled, the game will support dragging and dropping files onto it for various features. + * Disabled on MacOS. + */ + static final FEATURE_FILE_DROP:FeatureFlag = "FEATURE_FILE_DROP"; + + /** + * `-DFEATURE_OPEN_URL` + * If this flag is enabled, the game will support opening URLs (such as the merch page). + */ + static final FEATURE_OPEN_URL:FeatureFlag = "FEATURE_OPEN_URL"; + + /** + * `-DFEATURE_SCREENSHOTS` + * If this flag is enabled, the game will support the screenshots feature. + */ + static final FEATURE_SCREENSHOTS:FeatureFlag = "FEATURE_SCREENSHOTS"; + + /** + * `-DFEATURE_CHART_EDITOR` + * If this flag is enabled, the Chart Editor will be accessible from the debug menu. + */ + static final FEATURE_CHART_EDITOR:FeatureFlag = "FEATURE_CHART_EDITOR"; + + /** + * `-DFEATURE_STAGE_EDITOR` + * If this flag is enabled, the Stage Editor will be accessible from the debug menu. + */ + static final FEATURE_STAGE_EDITOR:FeatureFlag = "FEATURE_STAGE_EDITOR"; + + /** + * `-DFEATURE_RESULTS_DEBUG + * If this flag is enabled, a debug menu for Results screen will be accessible from the debug menu. + */ + static final FEATURE_RESULTS_DEBUG:FeatureFlag = "FEATURE_RESULTS_DEBUG"; + + /** + * `-DFEATURE_POLYMOD_MODS` + * If this flag is enabled, the game will enable the Polymod library's support for atomic mod loading from the `./mods` folder. + * If this flag is disabled, no mods will be loaded. + */ + static final FEATURE_POLYMOD_MODS:FeatureFlag = "FEATURE_POLYMOD_MODS"; + + /** + * `-DFEATURE_GHOST_TAPPING` + * If this flag is enabled, misses will not be counted when it is not the player's turn. + * Misses are still counted when the player has notes to hit. + */ + static final FEATURE_GHOST_TAPPING:FeatureFlag = "FEATURE_GHOST_TAPPING"; + + // + // CONFIGURATION FUNCTIONS + // + + public function new() { + super(); + + flair(); + configureApp(); + + displayTarget(); + configureFeatureFlags(); + configureCompileDefines(); + configureIncludeMacros(); + configureCustomMacros(); + configureOutputDir(); + configurePolymod(); + configureHaxelibs(); + configureAssets(); + configureIcons(); + } + + /** + * Do something before building, display some ASCII or something IDK + */ + function flair() { + // TODO: Implement this. + info("Friday Night Funkin'"); + info("Initializing build..."); + + info("Target Version: " + VERSION); + info("Git Branch: " + getGitBranch()); + info("Git Commit: " + getGitCommit()); + info("Git Modified? " + getGitModified()); + info("Display? " + isDisplay()); + } + + /** + * Apply basic project metadata, such as the game title and version number, + * as well as info like the package name and company (used by various app stores). + */ + function configureApp() { + this.meta.title = TITLE; + this.meta.version = VERSION; + this.meta.packageName = PACKAGE_NAME; + this.meta.company = COMPANY; + + this.app.main = MAIN_CLASS; + this.app.file = EXECUTABLE_NAME; + this.app.preloader = PRELOADER; + + // Tell Lime where to look for the game's source code. + // If for some reason we have multiple source directories, we can add more entries here. + this.sources.push(SOURCE_DIR); + + // Tell Lime to run some prebuild and postbuild scripts. + this.preBuildCallbacks.push(buildHaxeCLICommand(PREBUILD_HX)); + this.postBuildCallbacks.push(buildHaxeCLICommand(POSTBUILD_HX)); + + // TODO: Should we provide this? + // this.meta.buildNumber = 0; + + // These values are only used by the SWF target I think. + // this.app.path + // this.app.init + // this.app.swfVersion + // this.app.url + + // These values are only used by... FIREFOX MARKETPLACE WHAT? + // this.meta.description = ""; + // this.meta.companyId = COMPANY; + // this.meta.companyUrl = COMPANY; + + // Configure the window. + // Automatically configure FPS. + this.window.fps = 60; + // Set the window size. + this.window.width = 1280; + this.window.height = 720; + // Black background on release builds, magenta on debug builds. + this.window.background = FEATURE_DEBUG_FUNCTIONS.isEnabled(this) ? 0xFFFF00FF : 0xFF000000; + + this.window.hardware = true; + this.window.vsync = false; + // force / allow high DPI + this.window.allowHighDPI = true; + + if (isWeb()) { + this.window.resizable = true; + } + + if (isDesktop()) { + this.window.orientation = Orientation.LANDSCAPE; + this.window.fullscreen = false; + this.window.resizable = true; + this.window.vsync = false; + } + + if (isMobile()) { + this.window.orientation = Orientation.LANDSCAPE; + this.window.fullscreen = false; + this.window.resizable = false; + this.window.width = 0; + this.window.height = 0; + } + } + + /** + * Log information about the configured target platform. + */ + function displayTarget() { + // Display the target operating system. + switch (this.target) { + case Platform.WINDOWS: + info('Target Platform: Windows'); + case Platform.MAC: + info('Target Platform: MacOS'); + case Platform.LINUX: + info('Target Platform: Linux'); + case Platform.ANDROID: + info('Target Platform: Android'); + case Platform.IOS: + info('Target Platform: IOS'); + case Platform.HTML5: + info('Target Platform: HTML5'); + // See lime.tools.Platform for a full list. + // case Platform.EMSCRITEN: // A WebAssembly build might be interesting... + // case Platform.AIR: + // case Platform.BLACKBERRY: + // case Platform.CONSOLE_PC: + // case Platform.FIREFOX: + // case Platform.FLASH: + // case Platform.PS3: + // case Platform.PS4: + // case Platform.TIZEN: + // case Platform.TVOS: + // case Platform.VITA: + // case Platform.WEBOS: + // case Platform.WIIU: + // case Platform.XBOX1: + default: + error('Unsupported platform (got ${target})'); + } + + switch (this.platformType) { + case PlatformType.DESKTOP: + info('Platform Type: Desktop'); + case PlatformType.MOBILE: + info('Platform Type: Mobile'); + case PlatformType.WEB: + info('Platform Type: Web'); + case PlatformType.CONSOLE: + info('Platform Type: Console'); + default: + error('Unknown platform type (got ${platformType})'); + } + + // Print whether we are using HXCPP, HashLink, or something else. + if (isWeb()) { + info('Target Language: JavaScript (HTML5)'); + } else if (isHashLink()) { + info('Target Language: HashLink'); + } else if (isNeko()) { + info('Target Language: Neko'); + } else if (isJava()) { + info('Target Language: Java'); + } else if (isNodeJS()) { + info('Target Language: JavaScript (NodeJS)'); + } else if (isCSharp()) { + info('Target Language: C#'); + } else { + info('Target Language: C++'); + } + + for (arch in this.architectures) { + // Display the list of target architectures. + switch (arch) { + case Architecture.X86: + info('Architecture: x86'); + case Architecture.X64: + info('Architecture: x64'); + case Architecture.ARMV5: + info('Architecture: ARMv5'); + case Architecture.ARMV6: + info('Architecture: ARMv6'); + case Architecture.ARMV7: + info('Architecture: ARMv7'); + case Architecture.ARMV7S: + info('Architecture: ARMv7S'); + case Architecture.ARM64: + info('Architecture: ARMx64'); + case Architecture.MIPS: + info('Architecture: MIPS'); + case Architecture.MIPSEL: + info('Architecture: MIPSEL'); + case null: + if (!isWeb()) { + error('Unsupported architecture (got null on non-web platform)'); + } else { + info('Architecture: Web'); + } + default: + error('Unsupported architecture (got ${arch})'); + } + } + } + + /** + * Apply various feature flags based on the target platform and the user-provided build flags. + */ + function configureFeatureFlags() { + // You can explicitly override any of these. + // For example, `-DGITHUB_BUILD` or `-DNO_HARDCODED_CREDITS` + + // Should be false unless explicitly requested. + GITHUB_BUILD.apply(this, false); + FEATURE_GHOST_TAPPING.apply(this, false); + + // Should be true unless explicitly requested. + HARDCODED_CREDITS.apply(this, true); + FEATURE_OPEN_URL.apply(this, true); + FEATURE_POLYMOD_MODS.apply(this, true); + FEATURE_FUNKVIS.apply(this, true); + FEATURE_PARTIAL_SOUNDS.apply(this, true); + FEATURE_VIDEO_PLAYBACK.apply(this, true); + FEATURE_STAGE_EDITOR.apply(this, true); + + // Should be true on debug builds or if GITHUB_BUILD is enabled. + FEATURE_DEBUG_FUNCTIONS.apply(this, isDebug() || GITHUB_BUILD.isEnabled(this)); + FEATURE_RESULTS_DEBUG.apply(this, isDebug() || GITHUB_BUILD.isEnabled(this)); + + // Got a lot of complains about this being turned off by default on some builds. + // TODO: Look into ways to optimize logging (maybe by using a thread pool?) + FEATURE_LOG_TRACE.apply(this, true); + + FEATURE_NEWGROUNDS.apply(this, true); + FEATURE_NEWGROUNDS_DEBUG.apply(this, false); + FEATURE_NEWGROUNDS_TESTING_MEDALS.apply(this, FEATURE_NEWGROUNDS.isEnabled(this) && FEATURE_DEBUG_FUNCTIONS.isEnabled(this)); + FEATURE_NEWGROUNDS_AUTOLOGIN.apply(this, FEATURE_NEWGROUNDS.isEnabled(this) && isWeb()); + FEATURE_NEWGROUNDS_EVENTS.apply(this, FEATURE_NEWGROUNDS.isEnabled(this)); + + // Should default to true on workspace builds and false on release builds. + REDIRECT_ASSETS_FOLDER.apply(this, isDebug() && isDesktop()); + + // Should be true on desktop, release, non-tester builds. + // We don't want testers to accidentally leak songs to their Discord friends! + FEATURE_DISCORD_RPC.apply(this, isDesktop() && !FEATURE_DEBUG_FUNCTIONS.isEnabled(this)); + + // Should be true only on web builds. + // Audio context issues only exist there. + TOUCH_HERE_TO_PLAY.apply(this, isWeb()); + + // Should be true only on web builds. + // Enabling embedding and preloading is required to preload assets properly. + EMBED_ASSETS.apply(this, isWeb()); + PRELOAD_ALL.apply(this, !isWeb()); + + // Should be true except on MacOS. + // File drop doesn't work there. + FEATURE_FILE_DROP.apply(this, !isMac()); + + // Should be true except on web builds. + // Chart editor doesn't work there. + FEATURE_CHART_EDITOR.apply(this, !isWeb()); + + // Should be true except on web builds. + // Screenshots doesn't work there. + FEATURE_SCREENSHOTS.apply(this, !isWeb()); + } + + /** + * Set compilation flags which are not feature flags. + */ + function configureCompileDefines() { + // Enable OpenFL's error handler. Required for the crash logger. + setHaxedef("openfl-enable-handle-error"); + + // Enable stack trace tracking. Good for debugging but has a (minor) performance impact. + setHaxedef("HXCPP_CHECK_POINTER"); + setHaxedef("HXCPP_STACK_LINE"); + setHaxedef("HXCPP_STACK_TRACE"); + setHaxedef("hscriptPos"); + + setHaxedef("safeMode"); + + // If we aren't using the Flixel debugger, strip it out. + if (FEATURE_DEBUG_FUNCTIONS.isDisabled(this)) { + setHaxedef("FLX_NO_DEBUG"); + } + + // Disable the built in pause screen when unfocusing the game. + setHaxedef("FLX_NO_FOCUS_LOST_SCREEN"); + + // HaxeUI configuration. + setHaxedef("haxeui_no_mouse_reset"); + setHaxedef("haxeui_focus_out_on_click"); // Unfocus a dialog when clicking out of it + setHaxedef("haxeui_dont_impose_base_class"); // Suppress a macro error + + if (isRelease()) { + // Improve performance on Nape + // TODO: Do we even use Nape? + setHaxedef("NAPE_RELEASE_BUILD"); + } + + // Cleaner looking compiler errors. + setHaxedef("message.reporting", "pretty"); + + if (FEATURE_DEBUG_TRACY.isEnabled(this)) { + setHaxedef("HXCPP_TELEMETRY"); // Enable telemetry + setHaxedef("HXCPP_TRACY"); // Enable Tracy telemetry + setHaxedef("HXCPP_TRACY_MEMORY"); // Track memory allocations + setHaxedef("HXCPP_TRACY_ON_DEMAND"); // Only collect telemetry when Tracy is open and reachable + // setHaxedef("HXCPP_TRACY_INCLUDE_CALLSTACKS"); // Inspect callstacks per zone, inflating telemetry data + + setHaxedef("absolute-paths"); // Fix source locations so Tracy can see them + } + } + + /** + * Set compilation flags which manage dead code elimination. + */ + function configureIncludeMacros() { + // Disable dead code elimination. + // This prevents functions that are unused by the base game from being unavailable to HScript. + addHaxeFlag("-dce no"); + + // Forcibly include all Funkin' classes in builds. + // This prevents classes that are unused by the base game from being unavailable to HScript. + addHaxeMacro("include('funkin')"); + + // Ensure all HaxeUI components are available at runtime. + addHaxeMacro("include('haxe.ui.backend.flixel.components')"); + addHaxeMacro("include('haxe.ui.core')"); + addHaxeMacro("include('haxe.ui.components')"); + addHaxeMacro("include('haxe.ui.containers')"); + addHaxeMacro("include('haxe.ui.containers.dialogs')"); + addHaxeMacro("include('haxe.ui.containers.menus')"); + addHaxeMacro("include('haxe.ui.containers.properties')"); + + // Ensure all Flixel classes are available at runtime. + // Explicitly ignore packages which require additional dependencies. + addHaxeMacro("include('flixel', true, [ 'flixel.addons.editors.spine.*', 'flixel.addons.nape.*', 'flixel.system.macros.*', 'flixel.addons.tile.FlxRayCastTilemap' ])"); + } + + /** + * Set compilation flags which manage bespoke build-time macros. + */ + function configureCustomMacros() { + // This macro allows addition of new functionality to existing Flixel. --> + addHaxeMacro("addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')"); + } + + function configureOutputDir() { + // Set the output directory. Depends on the target platform and build type. + + var buildDir = 'export/${isDebug() ? 'debug' : 'release'}'; + + // we use a dedicated 'tracy' folder, since it generally needs a recompile when in use + if (FEATURE_DEBUG_TRACY.isEnabled(this)) + buildDir += "-tracy"; + + // trailing slash might not be needed, works fine on macOS without it, but I haven't tested on Windows! + buildDir += "/"; + + + info('Output directory: $buildDir'); + // setenv('BUILD_DIR', buildDir); + app.path = buildDir; + } + + function configurePolymod() { + // The file extension to use for script files. + setHaxedef("POLYMOD_SCRIPT_EXT", ".hscript"); + // Which asset library to use for scripts. + setHaxedef("POLYMOD_SCRIPT_LIBRARY", "scripts"); + // The base path from which scripts should be accessed. + setHaxedef("POLYMOD_ROOT_PATH", "scripts/"); + // Determines the subdirectory of the mod folder used for file appending. + setHaxedef("POLYMOD_APPEND_FOLDER", "_append"); + // Determines the subdirectory of the mod folder used for file merges. + setHaxedef("POLYMOD_MERGE_FOLDER", "_merge"); + // Determines the file in the mod folder used for metadata. + setHaxedef("POLYMOD_MOD_METADATA_FILE", "_polymod_meta.json"); + // Determines the file in the mod folder used for the icon. + setHaxedef("POLYMOD_MOD_ICON_FILE", "_polymod_icon.png"); + + if (isDebug()) { + // Turns on additional debug logging. + setHaxedef("POLYMOD_DEBUG"); + } + } + + function configureHaxelibs() { + // Don't enforce + addHaxelib('lime'); // Game engine backend + addHaxelib('openfl'); // Game engine backend + + addHaxelib('flixel'); // Game engine + + addHaxelib('flixel-addons'); // Additional utilities for Flixel + addHaxelib('hscript'); // Scripting + // addHaxelib('flixel-ui'); // UI framework (DEPRECATED) + addHaxelib('haxeui-core'); // UI framework + addHaxelib('haxeui-flixel'); // Integrate HaxeUI with Flixel + addHaxelib('flixel-text-input'); // Improved text field rendering for HaxeUI + + addHaxelib('polymod'); // Modding framework + addHaxelib('flxanimate'); // Texture atlas rendering + + addHaxelib('json2object'); // JSON parsing + addHaxelib('jsonpath'); // JSON parsing + addHaxelib('jsonpatch'); // JSON parsing + addHaxelib('thx.core'); // General utility library, "the lodash of Haxe" + addHaxelib('thx.semver'); // Version string handling + + if (isDebug()) { + addHaxelib('hxcpp-debug-server'); // VSCode debug support + } + + if (isDesktop() && !isHashLink() && FEATURE_VIDEO_PLAYBACK.isEnabled(this)) { + // hxvlc doesn't function on HashLink or non-desktop platforms + // It's also unnecessary if video playback is disabled + addHaxelib('hxvlc'); // Video playback + } + + if (FEATURE_DISCORD_RPC.isEnabled(this)) { + addHaxelib('hxdiscord_rpc'); // Discord API + } + + if (FEATURE_NEWGROUNDS.isEnabled(this)) { + addHaxelib('newgrounds'); // Newgrounds API + } + + if (FEATURE_FUNKVIS.isEnabled(this)) { + addHaxelib('funkin.vis'); // Audio visualization + addHaxelib('grig.audio'); // Audio data utilities + } + + if (FEATURE_PARTIAL_SOUNDS.isEnabled(this)) { + addHaxelib('FlxPartialSound'); // Partial sound + } + } + + function configureAssets() { + var exclude = EXCLUDE_ASSETS.concat(isWeb() ? EXCLUDE_ASSETS_WEB : EXCLUDE_ASSETS_NATIVE); + var shouldPreload = PRELOAD_ALL.isEnabled(this); + var shouldEmbed = EMBED_ASSETS.isEnabled(this); + + if (shouldEmbed) { + info('Embedding assets into executable...'); + } else { + info('Including assets alongside executable...'); + } + + // Default asset library + var shouldPreloadDefault = true; + addAssetLibrary("default", shouldEmbed, shouldPreloadDefault); + addAssetPath("assets/preload", "assets", "default", ["*"], exclude, shouldEmbed); + + // Font assets + var shouldEmbedFonts = true; + addAssetPath("assets/fonts", null, "default", ["*"], exclude, shouldEmbedFonts); + + // Shared asset libraries + addAssetLibrary("songs", shouldEmbed, shouldPreload); + addAssetPath("assets/songs", "assets/songs", "songs", ["*"], exclude, shouldEmbed); + addAssetLibrary("shared", shouldEmbed, shouldPreload); + addAssetPath("assets/shared", "assets/shared", "shared", ["*"], exclude, shouldEmbed); + if (FEATURE_VIDEO_PLAYBACK.isEnabled(this)) { + var shouldEmbedVideos = false; + addAssetLibrary("videos", shouldEmbedVideos, shouldPreload); + addAssetPath("assets/videos", "assets/videos", "videos", ["*"], exclude, shouldEmbedVideos); + } + + // Level asset libraries + addAssetLibrary("tutorial", shouldEmbed, shouldPreload); + addAssetPath("assets/tutorial", "assets/tutorial", "tutorial", ["*"], exclude, shouldEmbed); + addAssetLibrary("week1", shouldEmbed, shouldPreload); + addAssetPath("assets/week1", "assets/week1", "week1", ["*"], exclude, shouldEmbed); + addAssetLibrary("week2", shouldEmbed, shouldPreload); + addAssetPath("assets/week2", "assets/week2", "week2", ["*"], exclude, shouldEmbed); + addAssetLibrary("week3", shouldEmbed, shouldPreload); + addAssetPath("assets/week3", "assets/week3", "week3", ["*"], exclude, shouldEmbed); + addAssetLibrary("week4", shouldEmbed, shouldPreload); + addAssetPath("assets/week4", "assets/week4", "week4", ["*"], exclude, shouldEmbed); + addAssetLibrary("week5", shouldEmbed, shouldPreload); + addAssetPath("assets/week5", "assets/week5", "week5", ["*"], exclude, shouldEmbed); + addAssetLibrary("week6", shouldEmbed, shouldPreload); + addAssetPath("assets/week6", "assets/week6", "week6", ["*"], exclude, shouldEmbed); + addAssetLibrary("week7", shouldEmbed, shouldPreload); + addAssetPath("assets/week7", "assets/week7", "week7", ["*"], exclude, shouldEmbed); + addAssetLibrary("weekend1", shouldEmbed, shouldPreload); + addAssetPath("assets/weekend1", "assets/weekend1", "weekend1", ["*"], exclude, shouldEmbed); + + // Art asset library (where README pulls from) + var shouldEmbedArt = false; + var shouldPreloadArt = false; + addAssetLibrary("art", shouldEmbedArt, shouldPreloadArt); + addAsset("art/readme.txt", "do NOT readme.txt", "art", shouldEmbedArt); + addAsset("LICENSE.md", "LICENSE.md", "art", shouldEmbedArt); + } + + /** + * Configure the application's favicon and executable icon. + */ + function configureIcons() { + addIcon("art/icon16.png", 16); + addIcon("art/icon32.png", 32); + addIcon("art/icon64.png", 64); + addIcon("art/iconOG.png"); + } + + // + // HELPER FUNCTIONS + // Easy functions to make the code more readable. + // + + public function isWeb():Bool { + return this.platformType == PlatformType.WEB; + } + + public function isMobile():Bool { + return this.platformType == PlatformType.MOBILE; + } + + public function isDesktop():Bool { + return this.platformType == PlatformType.DESKTOP; + } + + public function isConsole():Bool { + return this.platformType == PlatformType.CONSOLE; + } + + public function is32Bit():Bool { + return this.architectures.contains(Architecture.X86); + } + + public function is64Bit():Bool { + return this.architectures.contains(Architecture.X64); + } + + public function isWindows():Bool { + return this.target == Platform.WINDOWS; + } + + public function isMac():Bool { + return this.target == Platform.MAC; + } + + public function isLinux():Bool { + return this.target == Platform.LINUX; + } + + public function isAndroid():Bool { + return this.target == Platform.ANDROID; + } + + public function isIOS():Bool { + return this.target == Platform.IOS; + } + + public function isHashLink():Bool { + return this.targetFlags.exists("hl"); + } + + public function isNeko():Bool { + return this.targetFlags.exists("neko"); + } + + public function isJava():Bool { + return this.targetFlags.exists("java"); + } + + public function isNodeJS():Bool { + return this.targetFlags.exists("nodejs"); + } + + public function isCSharp():Bool { + return this.targetFlags.exists("cs"); + } + + public function isDisplay():Bool { + return this.command == "display"; + } + + public function isDebug():Bool { + return this.debug; + } + + public function isRelease():Bool { + return !isDebug(); + } + + public function getHaxedef(name:String):Null { + return this.haxedefs.get(name); + } + + public function setHaxedef(name:String, ?value:String):Void { + if (value == null) value = ""; + + this.haxedefs.set(name, value); + } + + public function unsetHaxedef(name:String):Void { + this.haxedefs.remove(name); + } + + public function getDefine(name:String):Null { + return this.defines.get(name); + } + + public function hasDefine(name:String):Bool { + return this.defines.exists(name); + } + + /** + * Add a library to the list of dependencies for the project. + * @param name The name of the library to add. + * @param version The version of the library to add. Optional. + */ + public function addHaxelib(name:String, version:String = ""):Void { + this.haxelibs.push(new Haxelib(name, version)); + } + + /** + * Add a `haxeflag` to the project. + */ + public function addHaxeFlag(value:String):Void { + this.haxeflags.push(value); + } + + /** + * Call a Haxe build macro. + */ + public function addHaxeMacro(value:String):Void { + addHaxeFlag('--macro ${value}'); + } + + /** + * Add an icon to the project. + * @param icon The path to the icon. + * @param size The size of the icon. Optional. + */ + public function addIcon(icon:String, ?size:Int):Void { + this.icons.push(new Icon(icon, size)); + } + + /** + * Add an asset to the game build. + * @param path The path the asset is located at. + * @param rename The path the asset should be placed. + * @param library The asset library to add the asset to. `null` = "default" + * @param embed Whether to embed the asset in the executable. + */ + public function addAsset(path:String, ?rename:String, ?library:String, embed:Bool = false):Void { + // path, rename, type, embed, setDefaults + var asset = new Asset(path, rename, null, embed, true); + @:nullSafety(Off) + { + asset.library = library ?? "default"; + } + this.assets.push(asset); + } + + /** + * Add an entire path of assets to the game build. + * @param path The path the assets are located at. + * @param rename The path the assets should be placed. + * @param library The asset library to add the assets to. `null` = "default" + * @param include An optional array to include specific asset names. + * @param exclude An optional array to exclude specific asset names. + * @param embed Whether to embed the assets in the executable. + */ + public function addAssetPath(path:String, ?rename:String, library:String, ?include:Array, ?exclude:Array, embed:Bool = false):Void { + // Argument parsing. + if (path == "") return; + + if (include == null) include = []; + + if (exclude == null) exclude = []; + + var targetPath = rename ?? path; + if (targetPath != "") targetPath += "/"; + + // Validate path. + if (!sys.FileSystem.exists(path)) { + error('Could not find asset path "${path}".'); + } else if (!sys.FileSystem.isDirectory(path)) { + error('Could not parse asset path "${path}", expected a directory.'); + } else { + // info(' Found asset path "${path}".'); + } + + for (file in sys.FileSystem.readDirectory(path)) { + if (sys.FileSystem.isDirectory('${path}/${file}')) { + // Attempt to recursively add all assets in the directory. + if (this.filter(file, ["*"], exclude)) { + addAssetPath('${path}/${file}', '${targetPath}${file}', library, include, exclude, embed); + } + } else { + if (this.filter(file, include, exclude)) { + addAsset('${path}/${file}', '${targetPath}${file}', library, embed); + } + } + } + } + + /** + * Add an asset library to the game build. + * @param name The name of the library. + * @param embed + * @param preload + */ + public function addAssetLibrary(name:String, embed:Bool = false, preload:Bool = false):Void { + // sourcePath, name, type, embed, preload, generate, prefix + var sourcePath = ''; + this.libraries.push(new Library(sourcePath, name, null, embed, preload, false, "")); + } + + // + // PROCESS FUNCTIONS + // + + /** + * A CLI command to run a command in the shell. + */ + public function buildCLICommand(cmd:String):CLICommand { + return CommandHelper.fromSingleString(cmd); + } + + /** + * A CLI command to run a Haxe script via `--interp`. + */ + public function buildHaxeCLICommand(path:String):CLICommand { + return CommandHelper.interpretHaxe(path); + } + + public function getGitCommit():String { + // Cannibalized from GitCommit.hx + var process = new sys.io.Process('git', ['rev-parse', 'HEAD']); + if (process.exitCode() != 0) { + var message = process.stderr.readAll().toString(); + error('[ERROR] Could not determine current git commit; is this a proper Git repository?'); + } + + var commitHash:String = process.stdout.readLine(); + var commitHashSplice:String = commitHash.substr(0, 7); + + process.close(); + + return commitHashSplice; + } + + public function getGitBranch():String { + // Cannibalized from GitCommit.hx + var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + + if (branchProcess.exitCode() != 0) { + var message = branchProcess.stderr.readAll().toString(); + error('Could not determine current git branch; is this a proper Git repository?'); + } + + var branchName:String = branchProcess.stdout.readLine(); + + branchProcess.close(); + + return branchName; + } + + public function getGitModified():Bool { + var branchProcess = new sys.io.Process('git', ['status', '--porcelain']); + + if (branchProcess.exitCode() != 0) { + var message = branchProcess.stderr.readAll().toString(); + error('Could not determine current git status; is this a proper Git repository?'); + } + + var output:String = ''; + try { + output = branchProcess.stdout.readLine(); + } catch (e) { + if (e.message == 'Eof') { + // Do nothing. + // Eof = No output. + } else { + // Rethrow other exceptions. + throw e; + } + } + + branchProcess.close(); + + return output.length > 0; + } + + // + // LOGGING FUNCTIONS + // + + /** + * Display an error message. This should stop the build process. + */ + public function error(message:String):Void { + Log.error('${message}'); + } + + /** + * Display an info message. This should not interfere with the build process. + */ + public function info(message:String):Void { + if(command != "display") { Log.info('[INFO] ${message}'); } + } +} + +/** + * An object representing a feature flag, which can be enabled or disabled. + * Includes features such as automatic generation of compile defines and inversion. + */ +abstract FeatureFlag(String) { + static final INVERSE_PREFIX:String = "NO_"; + + public function new(input:String) { + this = input; + } + + @:from + public static function fromString(input:String):FeatureFlag { + return new FeatureFlag(input); + } + + /** + * Enable/disable a feature flag if it is unset, and handle the inverse flag. + * Doesn't override a feature flag that was set explicitly. + * @param enableByDefault Whether to enable this feature flag if it is unset. + */ + public function apply(project:Project, enableByDefault:Bool = false):Void { + // TODO: Name this function better? + + if (isEnabled(project)) { + // If this flag was already enabled, disable the inverse. + project.info('Enabling feature flag ${this}'); + getInverse().disable(project, false); + } else if (getInverse().isEnabled(project)) { + // If the inverse flag was already enabled, disable this flag. + project.info('Disabling feature flag ${this}'); + disable(project, false); + } else { + if (enableByDefault) { + // Enable this flag if it was unset, and disable the inverse. + project.info('Enabling feature flag ${this}'); + enable(project, true); + } else { + // Disable this flag if it was unset, and enable the inverse. + project.info('Disabling feature flag ${this}'); + disable(project, true); + } + } + } + + /** + * Enable this feature flag by setting the appropriate compile define. + * + * @param project The project to modify. + * @param andInverse Also disable the feature flag's inverse. + */ + public function enable(project:Project, andInverse:Bool = true) { + project.setHaxedef(this, ""); + if (andInverse) { + getInverse().disable(project, false); + } + } + + /** + * Disable this feature flag by removing the appropriate compile define. + * + * @param project The project to modify. + * @param andInverse Also enable the feature flag's inverse. + */ + public function disable(project:Project, andInverse:Bool = true) { + project.unsetHaxedef(this); + if (andInverse) { + getInverse().enable(project, false); + } + } + + /** + * Query if this feature flag is enabled. + * @param project The project to query. + */ + public function isEnabled(project:Project):Bool { + // Check both Haxedefs and Defines for this flag. + return project.haxedefs.exists(this) || project.defines.exists(this); + } + + /** + * Query if this feature flag's inverse is enabled. + */ + public function isDisabled(project:Project):Bool { + return getInverse().isEnabled(project); + } + + /** + * Return the inverse of this feature flag. + * @return A new feature flag that is the inverse of this one. + */ + public function getInverse():FeatureFlag { + if (this.startsWith(INVERSE_PREFIX)) { + return this.substring(INVERSE_PREFIX.length); + } + return INVERSE_PREFIX + this; + } +} diff --git a/rfc/chart-format/chartformat-7.jsonc b/rfc/chart-format/chartformat-7.jsonc new file mode 100644 index 00000000..1bd88de9 --- /dev/null +++ b/rfc/chart-format/chartformat-7.jsonc @@ -0,0 +1,1300 @@ +/********** + * This is the seventh iteration of a chart format proposal workshopped by Eric and Emma. + * + * The chart format is split into several files. + */ + +/********** + * Playable: `gameplay/songs//_metadata.json` + * Non-playable: `core/music/_metadata.json` + * + * This is the first file the game searches for when loading a song. + * It provides important information such as readable name and credits, + * information on how to read the song such as BPM and time signatures, + * and custom data used by mods. + * + * This file is mandatory for playable songs, + * and is optional (but useful) for non-playable songs such as menu music. + */ +{ + /** + * The file version. + * Patch version increments (2.0.x) should only be used for bug fixes. + * Minor version increments (2.x.0) should be used for new features. + * Major version increments (x.0.0) should be used for breaking changes, + * and should include a migration function such that older charts can still be used. + * + * An additional suffix (2.0.0-custom) should be used if the chart uses additional data + * or changes that can only be utilized by a mod or engine. + * The base game will ignore this suffix, and the chart ought to function as expected in this case. + * Other engines may choose to either accomodate or ignore the additional suffix. + */ + "version": "2.0.0", + + /** + * The canonical name of the song in question and its artist. + * This is only supposed to be used to show the proper name of the song, and not for anything to do with loading files! + * In the future, this will need to be changed to a localization key. + */ + "songName": "Philly Nice", + "artist": "Kawai Sprite", + + /** + * timeFormat specifies what kind of timestamps will be used by timeChanges, events, and notes (see chart.json). + * The supported formats are `ticks, ms, and float`. `ticks` will be used in this document for demonstration. + * + * ticks: + * If the chosen format is `ticks`, an extra value called `divisions` needs to be specified as well. + * + * float: + * If the chosen format is `float`, timestamps are based around quarter notes. + * A timestamp of 4.0 is equal to one whole note. + * + * ms: + * If the chosen format is `ms`, timestamps are based on hard millisecond time. + * This means notes are entirely independent of BPM, and cannot be exactly converted from one to another if needed. + * + * Every format is converted to `ms` when the song is loaded in for playing. + * These options mostly exist to allow more flexibility while creating a chart. + * + * If the song is not playable, and there are no BPM or time signature changes, + * this value has no effect and can be excluded. + * @default "ms" + */ + "timeFormat": "ticks", + "divisions": 96, + + /** + * timeChanges is an array of objects that specify changes in BPM and time signature. It should always be sorted by `t`. + * + * The first element in the list is used to initialize the BPM and time signature, and its `t` is ignored. + * To ensure that it's at the top of the list, the first element should ideally have a `t` of 0 or below. + * + * - t: Timestamp in specified `timeFormat`. + * - b: Time in beats (int). The game will calculate further beat values based on this one, so it can do it in a simple linear fashion. + * - bpm: Quarter notes per minute (float). Cannot be empty in the first element of the list, but otherwise it's optional, + * and defaults to the value of the previous element. + * - n: Time signature numerator (int). Optional, defaults to 4. + * - d: Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + * - bt: Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. Optional, defaults to 4. + */ + "timeChanges": [ + {"t": -1, "b": 0, "bpm": 175, "n": 4, "d": 4, "bt": [4, 4, 4, 4]} + ], + + /** + * Whether the song should loop or not. Only relevant for non-playable songs. + * TODO: Add properties for loopStart and loopEnd, once Flixel supports this functionality + */ + "loop": false, + + /** + * playData contains any information that is ONLY relevant to playable songs. + * It is therefore excluded when the metadata file is describing a non-playable song. + * + * The metadata file should include any information that is needed by the game before loading the chart, + * such as available difficulties and the characters/stages used. + */ + "playData": { + /** + * Informs the game that there is a secondary metadata/chart file pair for this song. + * + * For example, with the song variation `erect`, the game would search for + * `data/songs//erect_metadata.json` and load it, and later load `data/songs//erect_chart.json` if relevant. + * + * Any difficulties defined in the other metadata files will use + * the secondary file's values for `playableChars`, `stage`, etc. + * + * Additionally, the audio files that the game loads for the instrumental and vocals + * will also change, from `songs//Inst.ogg` to `songs///Inst.ogg`. + */ + "songVariations": ["erect"], + + /** + * The available difficulties for this song. + * The game should not allow attempting to load difficulties that are not specified in this array. + * + * Any custom difficulties should automatically work. + */ + "difficulties": ["easy", "normal", "hard"], + + /** + * The list of allowed playable characters, for this song and set of difficulties. + * The keys define the set of playable characters, + * with the values definining what other characters are to be loaded alongside them. + * If no character or `null` is given, there will be no character loaded for that specific role. + * + * - g: The supporting character (ex. gf). + * - o: The opponent character. + * - i: The instrumental track to use. Default is used if not specified. + */ + "playableChars": { + "bf": {"g": "gf", "o": "pico"}, + "pico": { "g": "nene", "o": "bf", "i": "Pico"}, + "tankman": { "o": "pico", "f": false} + }, + + /** + * The stage this song is played on. See `data/stages/`. + * This information is relevant to the loading state, so stage assets can reliably be loaded ahead of time. + * @default "stage" + */ + "stage": "phillyTrain", + + /** + * The note skin used by the song. + * Noteskin would have its own format, which also specifies the number of notes on the strumline. + * @default "default" + */ + "noteSkin": "default" + + /** + * Additional information for use in menus can be added here as needed. + * TODO: How do we allow mods to access this info? + */ + }, + // Not used by anything, but just a note to keep this value free so you can keep track of tool versions to help with troubleshooting. + "generatedBy": "FNF SongConverter v69" +} + +/***** + * `data/songs//chart.json` + * This is the file which contains the actual chart data, as loaded and used for gameplay. + */ +{ + /** + * The file version. See above for more information. + */ + "version": "2.0.0", + + /** + * The scroll speed used for each difficulty. + * scrollSpeed can be one of the following: + * - A single number; the chosen scroll speed will be used for all difficulties in this chart file. + * - An object; the keys of the object are the difficulties, and the values are the scroll speeds. + * The "default" key is also supported, and will be used if no scroll speed is specified for a specific difficulty. + */ + "scrollSpeed": { + "easy": 1, + "normal": 1.3, + "hard": 2, + "default": 1.3 + }, + + /** + * The list of song events in this chart. + * + * Song events represent behavior which occurs at a specific time in the song. + * - t: A number providing timestamp at which the event triggers, in the `timeFormat` specified in the metadata file. + * - e: A string specifying the event type. See below for more info. + * - v: The (optional) value for this event. Its type depends on the associated event (it could be a number, bool, string, array, object...) + * + * This list is assumed to be in timestamp order, and unexpected behavior may occur if it is not. + * + * This list will be iterated through before the song is loaded, allowing for modules to mutate them before the song starts. + * It also serves to allow for pre-loading certain assets that may be added to the game mid-song through events. + * + * Many event types will be supported, all with the goal of keeping the game's logic dynamic and synchronized with the song. + * Common actions (such as triggering a specific animation at a certain time in the song) should be supported through this mechanism, + * without having to create any additional scripts. + * + * Several possible ideas for event types include: + * - "FocusCamera": Sets which character the camera is currently focused on. + * This would fully replace "mustHitSection", which is great because it drives me insane. + * - "PlayAnimation": Forces a character to play a specific animation. + * - "SetIdleSuffix": Sets the suffix to be added to a character's idle animation. + * For example, if the value is "-alt", the character will play the "idle-alt" or "danceLeft-alt" animations, as appropriate. + * - "SetScrollSpeed": Multiply the current difficulty's scroll speed by the given value. + * For example, if this difficulty has a scroll speed of 1.3, and the event specifies a value of 1.5, the scroll speed will be set to 1.95. + * - "ScriptEvent": Fires a script event, which is received by all scripts, including character, stage, song, and module scripts. + * This should be more than sufficient for mods to implement custom events. + */ + "events": [ + {"t":0,"e":"FocusCamera","v":1}, + {"t":2304,"e":"FocusCamera","v":0}, + {"t":3840,"e":"FocusCamera","v":1}, + {"t":5376,"e":"FocusCamera","v":0}, + {"t":6912,"e":"FocusCamera","v":1}, + {"t":8448,"e":"FocusCamera","v":0}, + {"t":9984,"e":"FocusCamera","v":1}, + {"t":11520,"e":"FocusCamera","v":0}, + {"t":13056,"e":"FocusCamera","v":1}, + {"t":14592,"e":"FocusCamera","v":0}, + {"t":16128,"e":"FocusCamera","v":1}, + {"t":17664,"e":"FocusCamera","v":0}, + {"t":19200,"e":"FocusCamera","v":1}, + {"t":20736,"e":"FocusCamera","v":0}, + {"t":22272,"e":"FocusCamera","v":1}, + {"t":23808,"e":"FocusCamera","v":0} + ], + + /** + * The actual playable charts for each difficulty. + * The keys are the difficulty names, and the values are arrays of NoteData objects. + * Notes are key/value objects, rather than arrays, to allow for easier manipulation. + * Each difficulty's chart is assumed to be in timestamp order, and unexpected behavior may occur if it is not. + * Note that optional keys should be scrubbed to save space. + * + * - t: Timestamp in the timeFormat specified in the metadata file. + * - d: Index on the strumline (i.e. direction). + * The `strumlineSize` value is determined by the song's `noteSkin`. + * Performing `floor(d / strumlineSize)` specifies which strumline the note appears on. + * (with 0 being the player, 1 being the opponent, etc.). + * Performing `d % strumlineSize` specifies the actual note direction (index) on the strumline. + * - l: Hold length in specified timeFormat. If none is given, no sustain trail is created. + * - k: Kind of this note. If unspecified, defaults to `"normal"`. + * This can allow the note to either include custom behavior defined in a module script, + * or have a custom appearance defined by the noteSkin (or both). + * - el: Editor layer (index) which this note is placed on. + * Optional, defaults to 0. + * The user can use the UI to toggle visibility of notes placed on different layers. + * - ep: Editor pattern child note. + * Optional. + * If specified, this note is in the chart because it is part of a placed pattern. + * The value is the ID of the pattern the note was copied from. + * The final chart should be able to be fully recreated by deleting all notes with non-empty `ep`, + * then applying the pattern placement data from the `editor` object. + */ + "notes": { + "easy": [ + {"t":816,"d":6}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":2016,"d":6}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2352,"d":2}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3936,"d":6}, + {"t":4032,"d":4}, + {"t":4320,"d":7}, + {"t":4416,"d":6}, + {"t":4560,"d":5}, + {"t":4704,"d":7}, + {"t":4800,"d":4}, + {"t":4896,"d":7}, + {"t":4992,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":7}, + {"t":5472,"d":2}, + {"t":5568,"d":0}, + {"t":5856,"d":3}, + {"t":5952,"d":2}, + {"t":6096,"d":1}, + {"t":6240,"d":3}, + {"t":6336,"d":0}, + {"t":6432,"d":3}, + {"t":6528,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":3}, + {"t":6912,"d":6}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7392,"d":7}, + {"t":7488,"d":6}, + {"t":7632,"d":5}, + {"t":7776,"d":7}, + {"t":7872,"d":4}, + {"t":7968,"d":7}, + {"t":8064,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":7}, + {"t":8448,"d":2}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8928,"d":3}, + {"t":9024,"d":2}, + {"t":9168,"d":1}, + {"t":9312,"d":3}, + {"t":9408,"d":0}, + {"t":9504,"d":3}, + {"t":9600,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":3}, + {"t":9984,"d":6}, + {"t":10176,"d":6}, + {"t":10272,"d":4}, + {"t":10368,"d":6}, + {"t":10560,"d":6}, + {"t":10656,"d":4}, + {"t":10752,"d":6}, + {"t":10944,"d":6}, + {"t":11040,"d":4}, + {"t":11136,"d":6}, + {"t":11328,"d":6}, + {"t":11424,"d":4}, + {"t":11520,"d":2}, + {"t":11712,"d":2}, + {"t":11808,"d":0}, + {"t":11904,"d":2}, + {"t":12096,"d":2}, + {"t":12192,"d":0}, + {"t":12288,"d":2}, + {"t":12480,"d":2}, + {"t":12576,"d":0}, + {"t":12672,"d":2}, + {"t":12864,"d":2}, + {"t":12960,"d":0}, + {"t":13056,"d":6}, + {"t":13248,"d":6}, + {"t":13344,"d":4}, + {"t":13440,"d":6}, + {"t":13632,"d":6}, + {"t":13728,"d":4}, + {"t":13824,"d":6}, + {"t":14016,"d":6}, + {"t":14112,"d":4}, + {"t":14208,"d":7}, + {"t":14400,"d":6}, + {"t":14496,"d":4}, + {"t":14592,"d":2}, + {"t":14784,"d":2}, + {"t":14880,"d":0}, + {"t":14976,"d":2}, + {"t":15168,"d":2}, + {"t":15264,"d":0}, + {"t":15360,"d":2}, + {"t":15552,"d":2}, + {"t":15648,"d":0}, + {"t":15744,"d":3}, + {"t":15936,"d":2}, + {"t":16032,"d":0}, + {"t":16128,"d":7}, + {"t":16320,"d":6}, + {"t":16512,"d":6}, + {"t":16608,"d":7}, + {"t":16704,"d":6}, + {"t":16896,"d":7}, + {"t":17088,"d":6}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17376,"d":4}, + {"t":17472,"d":6}, + {"t":17568,"d":4}, + {"t":17664,"d":3}, + {"t":17856,"d":2}, + {"t":18048,"d":2}, + {"t":18144,"d":3}, + {"t":18240,"d":2}, + {"t":18432,"d":3}, + {"t":18624,"d":2}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18912,"d":0}, + {"t":19008,"d":2}, + {"t":19104,"d":0}, + {"t":19200,"d":6}, + {"t":19392,"d":6}, + {"t":19584,"d":4}, + {"t":19776,"d":6}, + {"t":19968,"d":7}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20352,"d":7}, + {"t":20544,"d":6}, + {"t":20640,"d":4}, + {"t":20736,"d":2}, + {"t":20928,"d":2}, + {"t":21120,"d":0}, + {"t":21312,"d":2}, + {"t":21504,"d":3}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21888,"d":3}, + {"t":22080,"d":2}, + {"t":22176,"d":0}, + {"t":22272,"d":6}, + {"t":22464,"d":6}, + {"t":22560,"d":4}, + {"t":22656,"d":6}, + {"t":22848,"d":6}, + {"t":22944,"d":4}, + {"t":23040,"d":6}, + {"t":23232,"d":6}, + {"t":23328,"d":4}, + {"t":23424,"d":7}, + {"t":23616,"d":6}, + {"t":23712,"d":4}, + {"t":23808,"d":2}, + {"t":24000,"d":2}, + {"t":24096,"d":0}, + {"t":24192,"d":2}, + {"t":24384,"d":2}, + {"t":24480,"d":0}, + {"t":24576,"d":2}, + {"t":24768,"d":2}, + {"t":24864,"d":0}, + {"t":24960,"d":3}, + {"t":25152,"d":2}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ], + "normal": [ + {"t":816,"d":6}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2352,"d":2}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3936,"d":6}, + {"t":4032,"d":4}, + {"t":4128,"d":7}, + {"t":4176,"d":6}, + {"t":4320,"d":7}, + {"t":4416,"d":6}, + {"t":4512,"d":4}, + {"t":4560,"d":5}, + {"t":4656,"d":4}, + {"t":4704,"d":7}, + {"t":4800,"d":4}, + {"t":4896,"d":7}, + {"t":4992,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":6}, + {"t":5280,"d":7}, + {"t":5472,"d":2}, + {"t":5568,"d":0}, + {"t":5664,"d":3}, + {"t":5712,"d":2}, + {"t":5856,"d":3}, + {"t":5952,"d":2}, + {"t":6048,"d":0}, + {"t":6096,"d":1}, + {"t":6192,"d":0}, + {"t":6240,"d":3}, + {"t":6336,"d":0}, + {"t":6432,"d":3}, + {"t":6528,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":2}, + {"t":6816,"d":3}, + {"t":6912,"d":6}, + {"t":6960,"d":6}, + {"t":7008,"d":6}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7248,"d":6}, + {"t":7392,"d":7}, + {"t":7488,"d":6}, + {"t":7584,"d":4}, + {"t":7632,"d":5}, + {"t":7728,"d":4}, + {"t":7776,"d":7}, + {"t":7824,"d":7}, + {"t":7872,"d":4}, + {"t":7920,"d":4}, + {"t":7968,"d":7}, + {"t":8016,"d":7}, + {"t":8064,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":6}, + {"t":8352,"d":7}, + {"t":8448,"d":2}, + {"t":8496,"d":2}, + {"t":8544,"d":2}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8784,"d":2}, + {"t":8928,"d":3}, + {"t":9024,"d":2}, + {"t":9120,"d":0}, + {"t":9168,"d":1}, + {"t":9264,"d":0}, + {"t":9312,"d":3}, + {"t":9360,"d":3}, + {"t":9408,"d":0}, + {"t":9456,"d":0}, + {"t":9504,"d":3}, + {"t":9552,"d":3}, + {"t":9600,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":2}, + {"t":9888,"d":3}, + {"t":9984,"d":6}, + {"t":10176,"d":6}, + {"t":10224,"d":7}, + {"t":10272,"d":4}, + {"t":10320,"d":7}, + {"t":10368,"d":6}, + {"t":10560,"d":6}, + {"t":10608,"d":7}, + {"t":10656,"d":4}, + {"t":10704,"d":5}, + {"t":10752,"d":6}, + {"t":10944,"d":6}, + {"t":10992,"d":7}, + {"t":11040,"d":4}, + {"t":11088,"d":7}, + {"t":11136,"d":6}, + {"t":11328,"d":6}, + {"t":11376,"d":7}, + {"t":11424,"d":4}, + {"t":11472,"d":4}, + {"t":11520,"d":2}, + {"t":11712,"d":2}, + {"t":11760,"d":3}, + {"t":11808,"d":0}, + {"t":11856,"d":3}, + {"t":11904,"d":2}, + {"t":12096,"d":2}, + {"t":12144,"d":3}, + {"t":12192,"d":0}, + {"t":12240,"d":1}, + {"t":12288,"d":2}, + {"t":12480,"d":2}, + {"t":12528,"d":3}, + {"t":12576,"d":0}, + {"t":12624,"d":3}, + {"t":12672,"d":2}, + {"t":12864,"d":2}, + {"t":12912,"d":3}, + {"t":12960,"d":0}, + {"t":13008,"d":0}, + {"t":13056,"d":6}, + {"t":13152,"d":5}, + {"t":13248,"d":6}, + {"t":13296,"d":7}, + {"t":13344,"d":4}, + {"t":13392,"d":7}, + {"t":13440,"d":6}, + {"t":13536,"d":6}, + {"t":13632,"d":6}, + {"t":13680,"d":7}, + {"t":13728,"d":4}, + {"t":13776,"d":5}, + {"t":13824,"d":6}, + {"t":13920,"d":5}, + {"t":14016,"d":6}, + {"t":14064,"d":7}, + {"t":14112,"d":4}, + {"t":14160,"d":7}, + {"t":14208,"d":6}, + {"t":14304,"d":6}, + {"t":14400,"d":6}, + {"t":14448,"d":7}, + {"t":14496,"d":4}, + {"t":14544,"d":4}, + {"t":14592,"d":2}, + {"t":14688,"d":1}, + {"t":14784,"d":2}, + {"t":14832,"d":3}, + {"t":14880,"d":0}, + {"t":14928,"d":3}, + {"t":14976,"d":2}, + {"t":15072,"d":2}, + {"t":15168,"d":2}, + {"t":15216,"d":3}, + {"t":15264,"d":0}, + {"t":15312,"d":1}, + {"t":15360,"d":2}, + {"t":15456,"d":1}, + {"t":15552,"d":2}, + {"t":15600,"d":3}, + {"t":15648,"d":0}, + {"t":15696,"d":3}, + {"t":15744,"d":2}, + {"t":15840,"d":2}, + {"t":15936,"d":2}, + {"t":15984,"d":3}, + {"t":16032,"d":0}, + {"t":16080,"d":0}, + {"t":16128,"d":7}, + {"t":16224,"d":5}, + {"t":16320,"d":6}, + {"t":16464,"d":4}, + {"t":16512,"d":6}, + {"t":16608,"d":7}, + {"t":16704,"d":6}, + {"t":16848,"d":5}, + {"t":16896,"d":7}, + {"t":16992,"d":5}, + {"t":17088,"d":6}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17376,"d":4}, + {"t":17472,"d":6}, + {"t":17568,"d":4}, + {"t":17616,"d":6}, + {"t":17664,"d":3}, + {"t":17760,"d":1}, + {"t":17856,"d":2}, + {"t":18000,"d":0}, + {"t":18048,"d":2}, + {"t":18144,"d":3}, + {"t":18240,"d":2}, + {"t":18384,"d":1}, + {"t":18432,"d":3}, + {"t":18528,"d":1}, + {"t":18624,"d":2}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18912,"d":0}, + {"t":19008,"d":2}, + {"t":19104,"d":0}, + {"t":19152,"d":2}, + {"t":19200,"d":6}, + {"t":19296,"d":5}, + {"t":19392,"d":6}, + {"t":19488,"d":5}, + {"t":19584,"d":4}, + {"t":19680,"d":5}, + {"t":19776,"d":6}, + {"t":19872,"d":6}, + {"t":19968,"d":7}, + {"t":20064,"d":4}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20352,"d":7}, + {"t":20448,"d":6}, + {"t":20544,"d":6}, + {"t":20592,"d":7}, + {"t":20640,"d":4}, + {"t":20688,"d":5}, + {"t":20736,"d":2}, + {"t":20832,"d":1}, + {"t":20928,"d":2}, + {"t":21024,"d":1}, + {"t":21120,"d":0}, + {"t":21216,"d":1}, + {"t":21312,"d":2}, + {"t":21408,"d":2}, + {"t":21504,"d":3}, + {"t":21600,"d":0}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21888,"d":3}, + {"t":21984,"d":2}, + {"t":22080,"d":2}, + {"t":22128,"d":3}, + {"t":22176,"d":0}, + {"t":22224,"d":1}, + {"t":22272,"d":6}, + {"t":22368,"d":5}, + {"t":22464,"d":6}, + {"t":22512,"d":7}, + {"t":22560,"d":4}, + {"t":22608,"d":7}, + {"t":22656,"d":6}, + {"t":22752,"d":6}, + {"t":22848,"d":6}, + {"t":22896,"d":7}, + {"t":22944,"d":4}, + {"t":22992,"d":5}, + {"t":23040,"d":6}, + {"t":23136,"d":5}, + {"t":23232,"d":6}, + {"t":23280,"d":7}, + {"t":23328,"d":4}, + {"t":23376,"d":7}, + {"t":23424,"d":6}, + {"t":23520,"d":6}, + {"t":23616,"d":6}, + {"t":23664,"d":7}, + {"t":23712,"d":4}, + {"t":23760,"d":4}, + {"t":23808,"d":2}, + {"t":23904,"d":1}, + {"t":24000,"d":2}, + {"t":24048,"d":3}, + {"t":24096,"d":0}, + {"t":24144,"d":3}, + {"t":24192,"d":2}, + {"t":24288,"d":2}, + {"t":24384,"d":2}, + {"t":24432,"d":3}, + {"t":24480,"d":0}, + {"t":24528,"d":1}, + {"t":24576,"d":2}, + {"t":24672,"d":1}, + {"t":24768,"d":2}, + {"t":24816,"d":3}, + {"t":24864,"d":0}, + {"t":24912,"d":3}, + {"t":24960,"d":2}, + {"t":25056,"d":2}, + {"t":25152,"d":2}, + {"t":25200,"d":3}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ], + "hard": [ + {"t":816,"d":6}, + {"t":864,"d":4}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1248,"d":4}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1632,"d":4}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":1968,"d":7}, + {"t":2016,"d":6}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2256,"d":4}, + {"t":2352,"d":2}, + {"t":2400,"d":0}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2784,"d":0}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3168,"d":0}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3504,"d":3}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3888,"d":6}, + {"t":3936,"d":4}, + {"t":4032,"d":4}, + {"t":4128,"d":7}, + {"t":4176,"d":5}, + {"t":4272,"d":7}, + {"t":4320,"d":4}, + {"t":4416,"d":6}, + {"t":4512,"d":4}, + {"t":4560,"d":5}, + {"t":4656,"d":4}, + {"t":4704,"d":5}, + {"t":4752,"d":7}, + {"t":4800,"d":5}, + {"t":4848,"d":4}, + {"t":4896,"d":5}, + {"t":4944,"d":7}, + {"t":4968,"d":4}, + {"t":4992,"d":7}, + {"t":5040,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":6}, + {"t":5280,"d":6}, + {"t":5328,"d":7}, + {"t":5424,"d":2}, + {"t":5472,"d":0}, + {"t":5568,"d":0}, + {"t":5664,"d":3}, + {"t":5712,"d":1}, + {"t":5808,"d":3}, + {"t":5856,"d":0}, + {"t":5952,"d":2}, + {"t":6048,"d":0}, + {"t":6096,"d":1}, + {"t":6192,"d":0}, + {"t":6240,"d":1}, + {"t":6288,"d":3}, + {"t":6336,"d":1}, + {"t":6384,"d":0}, + {"t":6432,"d":1}, + {"t":6480,"d":3}, + {"t":6504,"d":0}, + {"t":6528,"d":3}, + {"t":6576,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":2}, + {"t":6816,"d":2}, + {"t":6864,"d":3}, + {"t":6912,"d":6}, + {"t":6960,"d":6}, + {"t":7008,"d":4}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7248,"d":5}, + {"t":7344,"d":7}, + {"t":7392,"d":4}, + {"t":7488,"d":6}, + {"t":7584,"d":4}, + {"t":7632,"d":5}, + {"t":7728,"d":4}, + {"t":7776,"d":5}, + {"t":7824,"d":7}, + {"t":7872,"d":5}, + {"t":7920,"d":4}, + {"t":7968,"d":5}, + {"t":8016,"d":7}, + {"t":8040,"d":4}, + {"t":8064,"d":7}, + {"t":8112,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":6}, + {"t":8352,"d":6}, + {"t":8400,"d":7}, + {"t":8448,"d":2}, + {"t":8496,"d":2}, + {"t":8544,"d":0}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8784,"d":1}, + {"t":8880,"d":3}, + {"t":8928,"d":0}, + {"t":9024,"d":2}, + {"t":9120,"d":0}, + {"t":9168,"d":1}, + {"t":9264,"d":0}, + {"t":9312,"d":1}, + {"t":9360,"d":3}, + {"t":9408,"d":1}, + {"t":9456,"d":0}, + {"t":9504,"d":1}, + {"t":9552,"d":3}, + {"t":9576,"d":0}, + {"t":9600,"d":3}, + {"t":9648,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":2}, + {"t":9888,"d":2}, + {"t":9936,"d":3}, + {"t":9984,"d":6}, + {"t":10032,"d":7}, + {"t":10080,"d":5}, + {"t":10128,"d":4}, + {"t":10176,"d":6}, + {"t":10224,"d":7}, + {"t":10272,"d":4}, + {"t":10320,"d":7}, + {"t":10368,"d":6}, + {"t":10416,"d":7}, + {"t":10464,"d":6}, + {"t":10512,"d":4}, + {"t":10560,"d":6}, + {"t":10608,"d":7}, + {"t":10656,"d":4}, + {"t":10704,"d":5}, + {"t":10752,"d":6}, + {"t":10800,"d":7}, + {"t":10848,"d":5}, + {"t":10896,"d":4}, + {"t":10944,"d":6}, + {"t":10992,"d":7}, + {"t":11040,"d":4}, + {"t":11088,"d":7}, + {"t":11136,"d":6}, + {"t":11184,"d":7}, + {"t":11232,"d":6}, + {"t":11280,"d":4}, + {"t":11328,"d":6}, + {"t":11376,"d":7}, + {"t":11424,"d":4}, + {"t":11472,"d":4}, + {"t":11520,"d":2}, + {"t":11568,"d":3}, + {"t":11616,"d":1}, + {"t":11664,"d":0}, + {"t":11712,"d":2}, + {"t":11760,"d":3}, + {"t":11808,"d":0}, + {"t":11856,"d":3}, + {"t":11904,"d":2}, + {"t":11952,"d":3}, + {"t":12000,"d":2}, + {"t":12048,"d":0}, + {"t":12096,"d":2}, + {"t":12144,"d":3}, + {"t":12192,"d":0}, + {"t":12240,"d":1}, + {"t":12288,"d":2}, + {"t":12336,"d":3}, + {"t":12384,"d":1}, + {"t":12432,"d":0}, + {"t":12480,"d":2}, + {"t":12528,"d":3}, + {"t":12576,"d":0}, + {"t":12624,"d":3}, + {"t":12672,"d":2}, + {"t":12720,"d":3}, + {"t":12768,"d":2}, + {"t":12816,"d":0}, + {"t":12864,"d":2}, + {"t":12912,"d":3}, + {"t":12960,"d":0}, + {"t":13008,"d":0}, + {"t":13056,"d":6}, + {"t":13104,"d":7}, + {"t":13152,"d":5}, + {"t":13200,"d":4}, + {"t":13248,"d":6}, + {"t":13296,"d":7}, + {"t":13344,"d":4}, + {"t":13392,"d":7}, + {"t":13440,"d":6}, + {"t":13488,"d":7}, + {"t":13536,"d":6}, + {"t":13584,"d":4}, + {"t":13632,"d":6}, + {"t":13680,"d":7}, + {"t":13728,"d":4}, + {"t":13776,"d":4}, + {"t":13824,"d":6}, + {"t":13872,"d":7}, + {"t":13920,"d":5}, + {"t":13968,"d":4}, + {"t":14016,"d":6}, + {"t":14064,"d":7}, + {"t":14112,"d":4}, + {"t":14160,"d":7}, + {"t":14208,"d":6}, + {"t":14256,"d":7}, + {"t":14304,"d":6}, + {"t":14352,"d":4}, + {"t":14400,"d":6}, + {"t":14448,"d":7}, + {"t":14496,"d":4}, + {"t":14544,"d":4}, + {"t":14592,"d":2}, + {"t":14640,"d":3}, + {"t":14688,"d":1}, + {"t":14736,"d":0}, + {"t":14784,"d":2}, + {"t":14832,"d":3}, + {"t":14880,"d":0}, + {"t":14928,"d":3}, + {"t":14976,"d":2}, + {"t":15024,"d":3}, + {"t":15072,"d":2}, + {"t":15120,"d":0}, + {"t":15168,"d":2}, + {"t":15216,"d":3}, + {"t":15264,"d":0}, + {"t":15312,"d":0}, + {"t":15360,"d":2}, + {"t":15408,"d":3}, + {"t":15456,"d":1}, + {"t":15504,"d":0}, + {"t":15552,"d":2}, + {"t":15600,"d":3}, + {"t":15648,"d":0}, + {"t":15696,"d":3}, + {"t":15744,"d":2}, + {"t":15792,"d":3}, + {"t":15840,"d":2}, + {"t":15888,"d":0}, + {"t":15936,"d":2}, + {"t":15984,"d":3}, + {"t":16032,"d":0}, + {"t":16080,"d":0}, + {"t":16128,"d":7}, + {"t":16176,"d":7}, + {"t":16224,"d":5}, + {"t":16272,"d":5}, + {"t":16320,"d":6}, + {"t":16368,"d":6}, + {"t":16464,"d":4}, + {"t":16512,"d":6}, + {"t":16560,"d":7}, + {"t":16584,"d":4}, + {"t":16608,"d":7}, + {"t":16656,"d":7}, + {"t":16704,"d":6}, + {"t":16752,"d":4}, + {"t":16800,"d":4}, + {"t":16848,"d":5}, + {"t":16896,"d":7}, + {"t":16944,"d":7}, + {"t":16968,"d":4}, + {"t":16992,"d":7}, + {"t":17040,"d":5}, + {"t":17088,"d":6}, + {"t":17136,"d":6}, + {"t":17184,"d":4}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17328,"d":7}, + {"t":17376,"d":4}, + {"t":17424,"d":7}, + {"t":17472,"d":6}, + {"t":17568,"d":5}, + {"t":17616,"d":7}, + {"t":17664,"d":3}, + {"t":17712,"d":3}, + {"t":17760,"d":1}, + {"t":17808,"d":1}, + {"t":17856,"d":2}, + {"t":17904,"d":2}, + {"t":18000,"d":0}, + {"t":18048,"d":2}, + {"t":18096,"d":3}, + {"t":18120,"d":0}, + {"t":18144,"d":3}, + {"t":18192,"d":3}, + {"t":18240,"d":2}, + {"t":18288,"d":0}, + {"t":18336,"d":0}, + {"t":18384,"d":1}, + {"t":18432,"d":3}, + {"t":18480,"d":3}, + {"t":18504,"d":0}, + {"t":18528,"d":3}, + {"t":18576,"d":1}, + {"t":18624,"d":2}, + {"t":18672,"d":2}, + {"t":18720,"d":0}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18864,"d":3}, + {"t":18912,"d":0}, + {"t":18960,"d":3}, + {"t":19008,"d":2}, + {"t":19104,"d":1}, + {"t":19152,"d":3}, + {"t":19200,"d":6}, + {"t":19296,"d":5}, + {"t":19344,"d":7}, + {"t":19392,"d":6}, + {"t":19488,"d":5}, + {"t":19536,"d":7}, + {"t":19584,"d":4}, + {"t":19632,"d":7}, + {"t":19680,"d":5}, + {"t":19728,"d":7}, + {"t":19776,"d":6}, + {"t":19872,"d":6}, + {"t":19920,"d":6}, + {"t":19968,"d":7}, + {"t":20016,"d":5}, + {"t":20064,"d":4}, + {"t":20112,"d":7}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20304,"d":4}, + {"t":20352,"d":7}, + {"t":20400,"d":7}, + {"t":20448,"d":6}, + {"t":20496,"d":4}, + {"t":20544,"d":6}, + {"t":20592,"d":7}, + {"t":20640,"d":4}, + {"t":20688,"d":5}, + {"t":20736,"d":2}, + {"t":20832,"d":1}, + {"t":20880,"d":3}, + {"t":20928,"d":2}, + {"t":21024,"d":1}, + {"t":21072,"d":3}, + {"t":21120,"d":0}, + {"t":21168,"d":3}, + {"t":21216,"d":1}, + {"t":21264,"d":3}, + {"t":21312,"d":2}, + {"t":21408,"d":2}, + {"t":21456,"d":2}, + {"t":21504,"d":3}, + {"t":21552,"d":1}, + {"t":21600,"d":0}, + {"t":21648,"d":3}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21840,"d":0}, + {"t":21888,"d":3}, + {"t":21936,"d":3}, + {"t":21984,"d":2}, + {"t":22032,"d":0}, + {"t":22080,"d":2}, + {"t":22128,"d":3}, + {"t":22176,"d":0}, + {"t":22224,"d":1}, + {"t":22272,"d":6}, + {"t":22320,"d":7}, + {"t":22368,"d":5}, + {"t":22416,"d":4}, + {"t":22464,"d":6}, + {"t":22512,"d":7}, + {"t":22560,"d":4}, + {"t":22608,"d":7}, + {"t":22656,"d":6}, + {"t":22704,"d":7}, + {"t":22752,"d":6}, + {"t":22800,"d":4}, + {"t":22848,"d":6}, + {"t":22896,"d":7}, + {"t":22944,"d":4}, + {"t":22992,"d":4}, + {"t":23040,"d":6}, + {"t":23088,"d":7}, + {"t":23136,"d":5}, + {"t":23184,"d":4}, + {"t":23232,"d":6}, + {"t":23280,"d":7}, + {"t":23328,"d":4}, + {"t":23376,"d":7}, + {"t":23424,"d":6}, + {"t":23472,"d":7}, + {"t":23520,"d":6}, + {"t":23568,"d":4}, + {"t":23616,"d":6}, + {"t":23664,"d":7}, + {"t":23712,"d":4}, + {"t":23760,"d":4}, + {"t":23808,"d":2}, + {"t":23856,"d":3}, + {"t":23904,"d":1}, + {"t":23952,"d":0}, + {"t":24000,"d":2}, + {"t":24048,"d":3}, + {"t":24096,"d":0}, + {"t":24144,"d":3}, + {"t":24192,"d":2}, + {"t":24240,"d":3}, + {"t":24288,"d":2}, + {"t":24336,"d":0}, + {"t":24384,"d":2}, + {"t":24432,"d":3}, + {"t":24480,"d":0}, + {"t":24528,"d":0}, + {"t":24576,"d":2}, + {"t":24624,"d":3}, + {"t":24672,"d":1}, + {"t":24720,"d":0}, + {"t":24768,"d":2}, + {"t":24816,"d":3}, + {"t":24864,"d":0}, + {"t":24912,"d":3}, + {"t":24960,"d":2}, + {"t":25008,"d":3}, + {"t":25056,"d":2}, + {"t":25104,"d":0}, + {"t":25152,"d":2}, + {"t":25200,"d":3}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ] + }, + + /** + * Data used only by the chart editor. + * This object is optional, and the chart file ought to be fully playable when it is excluded. + */ + "editor": { + /** + * An map of patterns which exist for this chart. + * You can browse through these using the interface on the right side, + * place them in the chart in multiple places, create them from an existing selection, + * and double click to enter the pattern for editing. + * + * Editing a pattern will affect the grouped notes in all locations that the pattern was placed. + * If a user destroys a pattern from the UI, they will be prompted to either + * ungroup the notes (leaving them in the chart) or remove the grouped notes from the chart. + * + * The key for the map is a randomized unique ID for the pattern, used to quickly reference it elsewhere in the chart. + */ + "patternDefinitions": { + "a7be4f8": { + /** + * A label to identify the pattern. + * Should be customizable by the user, and does not need to be unique (the key for the map keeps patterns separate) + */ + "label": "Secondary Motif", + /** + * A color to highlight the grouped notes with in the UI. + * Should be customizable by the user (HaxeUI has a color picker, yay!). + */ + "color": "#FF3333", + /** + * The notes placed in the pattern. + * These are of the same format as the main `notes` object, with the exception that + * editor attributes (such as layer and pattern child) are not allowed. + * + * `t` acts as an offset with 0 representing the timestamp the pattern was placed. + */ + "notes": [ + {"t":300, "d":4}, + {"t":600, "d":2}, + {"t":900, "d":1},- + {"t":1300, "d":3}, + ] + } + }, + + /** + * Defines where in the chart that patterns are currently placed. + * Add one key for each difficulty in the chart. + * + * When placing a pattern in a chart, an element should be added to the appropriate array below, + * then the pattern should be fetched, and the pattern notes (with `t = pattern time + offset) + * copied to the chart, with `ep` set so the chart editor knows the notes are part of a pattern. + */ + "patternPlacements": { + "easy": [], + "normal": [ + { + "t": 3000, + "p": "a7be4f8", + "f": false, + "el": 2 + } + ], + "hard": [ + { + "t": 3000, + "p": "a7be4f8", + "f": false, + "el": 2 + } + ] + }, + + /** + * Metadata about the layers in the chart. + * The first layer (0) is always named "Default" and it cannot be rearranged or deleted. + * If there are no other layers in the chart, this array will be empty. + */ + "layers": [ + { + /** + * The name this note layer has in the interface. + */ + "label": "Test" + } + ] + }, + + // Not used by anything, but just a note to keep this value free so you can keep track of tool versions to help with troubleshooting. + "generatedBy": "FNF SongConverter v69" +} diff --git a/source/Main.hx b/source/Main.hx new file mode 100644 index 00000000..e1141394 --- /dev/null +++ b/source/Main.hx @@ -0,0 +1,148 @@ +package; + +import flixel.FlxGame; +import flixel.FlxState; +import funkin.Preferences; +import funkin.util.logging.CrashHandler; +import funkin.ui.debug.MemoryCounter; +import funkin.save.Save; +import haxe.ui.Toolkit; +import openfl.display.FPS; +import openfl.display.Sprite; +import openfl.events.Event; +import openfl.Lib; +import openfl.media.Video; +import openfl.net.NetStream; +import funkin.util.WindowUtil; + +/** + * The main class which initializes HaxeFlixel and starts the game in its initial state. + */ +class Main extends Sprite +{ + var gameWidth:Int = 1280; // Width of the game in pixels (might be less / more in actual pixels depending on your zoom). + var gameHeight:Int = 720; // Height of the game in pixels (might be less / more in actual pixels depending on your zoom). + var initialState:Class = funkin.InitState; // The FlxState the game starts with. + var zoom:Float = -1; // If -1, zoom is automatically calculated to fit the window dimensions. + var skipSplash:Bool = true; // Whether to skip the flixel splash screen that appears in release mode. + var startFullscreen:Bool = false; // Whether to start the game in fullscreen on desktop targets + + // You can pretty much ignore everything from here on - your code should go in your states. + + public static function main():Void + { + // We need to make the crash handler LITERALLY FIRST so nothing EVER gets past it. + CrashHandler.initialize(); + CrashHandler.queryStatus(); + + Lib.current.addChild(new Main()); + } + + public function new() + { + super(); + + // Initialize custom logging. + haxe.Log.trace = funkin.util.logging.AnsiTrace.trace; + funkin.util.logging.AnsiTrace.traceBF(); + + // Load mods to override assets. + // TODO: Replace with loadEnabledMods() once the user can configure the mod list. + funkin.modding.PolymodHandler.loadAllMods(); + + if (stage != null) + { + init(); + } + else + { + addEventListener(Event.ADDED_TO_STAGE, init); + } + } + + function init(?event:Event):Void + { + if (hasEventListener(Event.ADDED_TO_STAGE)) + { + removeEventListener(Event.ADDED_TO_STAGE, init); + } + + setupGame(); + } + + var video:Video; + var netStream:NetStream; + var overlay:Sprite; + + /** + * A frame counter displayed at the top left. + */ + public static var fpsCounter:FPS; + + /** + * A RAM counter displayed at the top left. + */ + public static var memoryCounter:MemoryCounter; + + function setupGame():Void + { + initHaxeUI(); + + // addChild gets called by the user settings code. + fpsCounter = new FPS(10, 3, 0xFFFFFF); + + #if !html5 + // addChild gets called by the user settings code. + // TODO: disabled on HTML5 (todo: find another method that works?) + memoryCounter = new MemoryCounter(10, 13, 0xFFFFFF); + #end + + // George recommends binding the save before FlxGame is created. + Save.load(); + + // Don't call anything from the preferences until the save is loaded! + #if web + // set this variable (which is a function) from the lime version at lime/_internal/backend/html5/HTML5Application.hx + // The framerate cap will more thoroughly initialize via Preferences in InitState.hx + funkin.Preferences.lockedFramerateFunction = untyped js.Syntax.code("window.requestAnimationFrame"); + #end + + WindowUtil.setVSyncMode(funkin.Preferences.vsyncMode); + + + var game:FlxGame = new FlxGame(gameWidth, gameHeight, initialState, Preferences.framerate, Preferences.framerate, skipSplash, startFullscreen); + + // FlxG.game._customSoundTray wants just the class, it calls new from + // create() in there, which gets called when it's added to the stage + // which is why it needs to be added before addChild(game) here + @:privateAccess + game._customSoundTray = funkin.ui.options.FunkinSoundTray; + + addChild(game); + + #if FEATURE_DEBUG_FUNCTIONS + game.debugger.interaction.addTool(new funkin.util.TrackerToolButtonUtil()); + #end + + #if hxcpp_debug_server + trace('hxcpp_debug_server is enabled! You can now connect to the game with a debugger.'); + #else + trace('hxcpp_debug_server is disabled! This build does not support debugging.'); + #end + } + + function initHaxeUI():Void + { + // Calling this before any HaxeUI components get used is important: + // - It initializes the theme styles. + // - It scans the class path and registers any HaxeUI components. + Toolkit.init(); + Toolkit.theme = 'dark'; // don't be cringe + // Toolkit.theme = 'light'; // embrace cringe + Toolkit.autoScale = false; + // Don't focus on UI elements when they first appear. + haxe.ui.focus.FocusManager.instance.autoFocus = false; + funkin.input.Cursor.registerHaxeUICursors(); + haxe.ui.tooltips.ToolTipManager.defaultDelay = 200; + } +} diff --git a/source/Postbuild.hx b/source/Postbuild.hx new file mode 100644 index 00000000..405d0400 --- /dev/null +++ b/source/Postbuild.hx @@ -0,0 +1,40 @@ +package source; // Yeah, I know... + +import sys.FileSystem; +import sys.io.File; + +/** + * A script which executes after the game is built. + */ +class Postbuild +{ + static inline final BUILD_TIME_FILE:String = '.build_time'; + + static function main():Void + { + printBuildTime(); + } + + static function printBuildTime():Void + { + // get buildEnd before fs operations since they are blocking + var end:Float = Sys.time(); + if (FileSystem.exists(BUILD_TIME_FILE)) + { + var fi:sys.io.FileInput = File.read(BUILD_TIME_FILE); + var start:Float = fi.readDouble(); + fi.close(); + + sys.FileSystem.deleteFile(BUILD_TIME_FILE); + + var buildTime:Float = roundToTwoDecimals(end - start); + + trace('Build took: ${buildTime} seconds'); + } + } + + static function roundToTwoDecimals(value:Float):Float + { + return Math.round(value * 100) / 100; + } +} diff --git a/source/Prebuild.hx b/source/Prebuild.hx new file mode 100644 index 00000000..0dc6dd40 --- /dev/null +++ b/source/Prebuild.hx @@ -0,0 +1,56 @@ +package source; // Yeah, I know... + +import sys.io.File; + +/** + * A script which executes before the game is built. + */ +class Prebuild +{ + static inline final BUILD_TIME_FILE:String = '.build_time'; + + static final NG_CREDS_PATH:String = './source/funkin/api/newgrounds/NewgroundsCredentials.hx'; + + static final NG_CREDS_TEMPLATE:String = "package funkin.api.newgrounds; + +class NewgroundsCredentials +{ + public static final APP_ID:String = #if API_NG_APP_ID haxe.macro.Compiler.getDefine(\"API_NG_APP_ID\") #else 'INSERT APP ID HERE' #end; + public static final ENCRYPTION_KEY:String = #if API_NG_ENC_KEY haxe.macro.Compiler.getDefine(\"API_NG_ENC_KEY\") #else 'INSERT ENCRYPTION KEY HERE' #end; +}"; + + static function main():Void + { + trace('Building...'); + + saveBuildTime(); + + buildCredsFile(); + } + + static function saveBuildTime():Void + { + var fo:sys.io.FileOutput = File.write(BUILD_TIME_FILE); + var now:Float = Sys.time(); + fo.writeDouble(now); + fo.close(); + } + + static function buildCredsFile():Void + { + #if sys + if (sys.FileSystem.exists(NG_CREDS_PATH)) + { + trace('NewgroundsCredentials.hx already exists, skipping.'); + } + else + { + trace('Creating NewgroundsCredentials.hx...'); + + var fileContents:String = NG_CREDS_TEMPLATE; + + sys.io.File.saveContent(NG_CREDS_PATH, fileContents); + } + #end + } +} diff --git a/source/cpp/vm/tracy/TracyProfiler.hx b/source/cpp/vm/tracy/TracyProfiler.hx new file mode 100644 index 00000000..1bedd521 --- /dev/null +++ b/source/cpp/vm/tracy/TracyProfiler.hx @@ -0,0 +1,102 @@ +/* + * Pulled from Tracey profiler PR + * @see https://github.com/HaxeFoundation/haxe/pull/11772 + */ + +package cpp.vm.tracy; + +#if (!HXCPP_TRACY) +#error "This class cannot be used without -D HXCPP_TRACY" +#end +enum abstract PlotFormatType(cpp.UInt8) from cpp.UInt8 to cpp.UInt8 +{ + var Number = 0; + var Memory = 1; + var Percentage = 2; +} + +@:include('hx/TelemetryTracy.h') +extern class Native_TracyProfiler +{ + /** + Mark a frame. Call this at the end of each frame loop. + **/ + @:native('::__hxcpp_tracy_framemark') + public static function frameMark():Void; + + /** + Print a message into Tracy's log. + **/ + @:native('::__hxcpp_tracy_message') + public static function message(_msg:String, ?_color:Int = 0x000000):Void; + + /** + Tracy can collect additional information about the profiled application, + which will be available in the trace description. + This can include data such as the source repository revision, + the application’s environment (dev/prod), etc. + **/ + @:native('::__hxcpp_tracy_message_app_info') + public static function messageAppInfo(_info:String):Void; + + /** + Plot a named value to tracy. This will generate a graph in the profiler for you. + **/ + @:native('::__hxcpp_tracy_plot') + public static function plot(_name:String, _val:cpp.Float32):Void; + + /** + Configure how values are plotted and displayed. + **/ + @:native('::__hxcpp_tracy_plot_config') + public static function plotConfig(_name:String, _format:PlotFormatType, ?_step:Bool = false, ?_fill:Bool = false, ?_color:Int = 0x000000):Void; + + /** + Set a name for the current thread this function is called in. Supply an optional groupHint so threads become grouped in Tracy's UI. + **/ + @:native('::__hxcpp_tracy_set_thread_name_and_group') + public static function setThreadName(_name:String, ?_groupHint:Int = 1):Void; + + /** + Create a custom named scoped zone in your code. + **/ + @:native('HXCPP_TRACY_ZONE') + public static function zoneScoped(_name:String):Void; +} + +#if (scriptable || cppia) +class Cppia_TracyProfiler +{ + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.frameMark) + public static function frameMark() + Native_TracyProfiler.frameMark(); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.message) + public static function message(_msg:String, ?_color:Int = 0x000000) + Native_TracyProfiler.message(_msg, _color); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.messageAppInfo) + public static function messageAppInfo(_info:String) + Native_TracyProfiler.messageAppInfo(_info); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.plot) + public static function plot(_name:String, _val:Float) + Native_TracyProfiler.plot(_name, _val); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.plotConfig) + public static function plotConfig(_name:String, _format:PlotFormatType, ?_step:Bool = false, ?_fill:Bool = false, ?_color:Int = 0x000000) + Native_TracyProfiler.plotConfig(_name, _format, _step, _fill, _color); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.setThreadName) + public static function setThreadName(_name:String, ?_groupHint:Int = 1) + Native_TracyProfiler.setThreadName(_name, _groupHint); + + @:inheritDoc(cpp.vm.tracy.Native_TracyProfiler.zoneScoped) + public static function zoneScoped(_name:String) + Native_TracyProfiler.zoneScoped(_name); +} + +typedef TracyProfiler = Cppia_TracyProfiler; +#else +typedef TracyProfiler = Native_TracyProfiler; +#end diff --git a/source/funkin/Assets.hx b/source/funkin/Assets.hx new file mode 100644 index 00000000..f77e3ea3 --- /dev/null +++ b/source/funkin/Assets.hx @@ -0,0 +1,177 @@ +package funkin; + +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 +{ + /** + * Get the file system path for an asset + * @param path The asset path to load from, relative to the assets folder + * @return The path to the asset on the file system + */ + public static function getPath(path:String):String + { + return openfl.utils.Assets.getPath(path); + } + + /** + * Load bytes from an asset + * May cause stutters or throw an error if the asset is not cached + * @param path The asset path to load from + * @return The byte contents of the file + */ + public static function getBytes(path:String):haxe.io.Bytes + { + return openfl.utils.Assets.getBytes(path); + } + + /** + * Load bytes from an asset asynchronously + * @param path The asset path to load from + * @return A future which promises to return the byte contents of the file + */ + public static function loadBytes(path:String):Future + { + return openfl.utils.Assets.loadBytes(path); + } + + /** + * Load text from an asset. + * May cause stutters or throw an error if the asset is not cached + * @param path The asset path to load from + * @return The text contents of the file + */ + public static function getText(path:String):String + { + return openfl.utils.Assets.getText(path); + } + + /** + * Load text from an asset asynchronously + * @param path The asset path to load from + * @return A future which promises to return the text contents of the file + */ + public static function loadText(path:String):Future + { + return openfl.utils.Assets.loadText(path); + } + + /** + * Load a Sound file from an asset + * May cause stutters or throw an error if the asset is not cached + * @param path The asset path to load from + * @return The loaded sound + */ + public static function getSound(path:String):openfl.media.Sound + { + return openfl.utils.Assets.getSound(path); + } + + /** + * Load a Sound file from an asset asynchronously + * @param path The asset path to load from + * @return A future which promises to return the loaded sound + */ + public static function loadSound(path:String):Future + { + return openfl.utils.Assets.loadSound(path); + } + + /** + * Load a Sound file from an asset, with optimizations specific to long-duration music + * May cause stutters or throw an error if the asset is not cached + * @param path The asset path to load from + * @return The loaded sound + */ + public static function getMusic(path:String):openfl.media.Sound + { + return openfl.utils.Assets.getMusic(path); + } + + /** + * Load a Sound file from an asset asynchronously, with optimizations specific to long-duration music + * @param path The asset path to load from + * @return A future which promises to return the loaded sound + */ + public static function loadMusic(path:String):Future + { + return openfl.utils.Assets.loadMusic(path); + } + + /** + * Load a Bitmap from an asset + * May cause stutters or throw an error if the asset is not cached + * @param path The asset path to load from + * @return The loaded Bitmap image + */ + public static function getBitmapData(path:String):openfl.display.BitmapData + { + return openfl.utils.Assets.getBitmapData(path); + } + + /** + * Load a Bitmap from an asset asynchronously + * @param path The asset path to load from + * @return The future which promises to return the loaded Bitmap image + */ + public static function loadBitmapData(path:String):Future + { + return openfl.utils.Assets.loadBitmapData(path); + } + + /** + * Determines whether the given asset of the given type exists. + * @param path The asset path to check + * @param type The asset type to check + * @return Whether the asset exists + */ + public static function exists(path:String, ?type:openfl.utils.AssetType):Bool + { + return openfl.utils.Assets.exists(path, type); + } + + /** + * Retrieve a list of all assets of the given type + * @param type The asset type to check + * @return A list of asset paths + */ + public static function list(?type:openfl.utils.AssetType):Array + { + return openfl.utils.Assets.list(type); + } + + /** + * Checks if a library with the given name exists. + * @param name The name to check. + * @return Whether or not the library exists. + */ + public static function hasLibrary(name:String):Bool + { + return openfl.utils.Assets.hasLibrary(name); + } + + /** + * Retrieves a library with the given name. + * @param name The name of the library to get. + * @return The library with the given name. + */ + public static function getLibrary(name:String):lime.utils.AssetLibrary + { + return openfl.utils.Assets.getLibrary(name); + } + + /** + * Loads a library with the given name. + * @param name The name of the library to load. + * @return An `AssetLibary` object. + */ + public static function loadLibrary(name:String):Future + { + return openfl.utils.Assets.loadLibrary(name); + } +} diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx new file mode 100644 index 00000000..803b9e1b --- /dev/null +++ b/source/funkin/Conductor.hx @@ -0,0 +1,694 @@ +package funkin; + +import funkin.util.Constants; +import flixel.util.FlxSignal; +import flixel.math.FlxMath; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.SongDataUtils; +import funkin.save.Save; +import haxe.Timer; +import flixel.sound.FlxSound; + +/** + * A core class which handles musical timing throughout the game, + * both in gameplay and in menus. + */ +@:nullSafety +class Conductor +{ + // onBeatHit is called every quarter note + // onStepHit is called every sixteenth note + // 4/4 = 4 beats per measure = 16 steps per measure + // 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second + // 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second + // 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second + // 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second + // 3/4 = 3 beats per measure = 12 steps per measure + // (IDENTICAL TO 4/4 but shorter measure length) + // 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second + // 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second + // 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second + // 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second + // 7/8 = 3.5 beats per measure = 14 steps per measure + + /** + * The current instance of the Conductor. + * If one doesn't currently exist, a new one will be created. + * + * You can also do stuff like store a reference to the Conductor and pass it around or temporarily replace it, + * or have a second Conductor running at the same time, or other weird stuff like that if you need to. + */ + public static var instance(get, never):Conductor; + + static var _instance:Null = null; + + /** + * Signal fired when the current static Conductor instance advances to a new measure. + */ + public static var measureHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when THIS Conductor instance advances to a new measure. + * TODO: This naming sucks but we can't make a static and instance field with the same name! + */ + public var onMeasureHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the current Conductor instance advances to a new beat. + */ + public static var beatHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when THIS Conductor instance advances to a new beat. + * TODO: This naming sucks but we can't make a static and instance field with the same name! + */ + public var onBeatHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the current Conductor instance advances to a new step. + */ + public static var stepHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when THIS Conductor instance advances to a new step. + * TODO: This naming sucks but we can't make a static and instance field with the same name! + */ + public var onStepHit(default, null):FlxSignal = new FlxSignal(); + + /** + * The list of time changes in the song. + * There should be at least one time change (at the beginning of the song) to define the BPM. + */ + var timeChanges:Array = []; + + /** + * The most recent time change for the current song position. + */ + public var currentTimeChange(default, null):Null; + + /** + * The current position in the song in milliseconds. + * Update this every frame based on the audio position using `Conductor.instance.update()`. + */ + public var songPosition(default, null):Float = 0; + + var prevTimestamp:Float = 0; + var prevTime:Float = 0; + + /** + * Beats per minute of the current song at the current time. + */ + public var bpm(get, never):Float; + + function get_bpm():Float + { + if (bpmOverride != null) return bpmOverride; + + if (currentTimeChange == null) return Constants.DEFAULT_BPM; + + return currentTimeChange.bpm; + } + + /** + * Beats per minute of the current song at the start time. + */ + public var startingBPM(get, never):Float; + + function get_startingBPM():Float + { + if (bpmOverride != null) return bpmOverride; + + var timeChange = timeChanges[0]; + if (timeChange == null) return Constants.DEFAULT_BPM; + + return timeChange.bpm; + } + + /** + * The current value set by `forceBPM`. + * If false, BPM is determined by time changes. + */ + var bpmOverride:Null = null; + + /** + * Duration of a measure in milliseconds. Calculated based on bpm. + */ + public var measureLengthMs(get, never):Float; + + function get_measureLengthMs():Float + { + return beatLengthMs * timeSignatureNumerator; + } + + /** + * Duration of a beat (quarter note) in milliseconds. Calculated based on bpm. + */ + public var beatLengthMs(get, never):Float; + + function get_beatLengthMs():Float + { + // Tied directly to BPM. + return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC); + } + + /** + * Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm. + */ + public var stepLengthMs(get, never):Float; + + function get_stepLengthMs():Float + { + return beatLengthMs / timeSignatureNumerator; + } + + /** + * The numerator for the current time signature (the `3` in `3/4`). + */ + public var timeSignatureNumerator(get, never):Int; + + function get_timeSignatureNumerator():Int + { + if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM; + + return currentTimeChange.timeSignatureNum; + } + + /** + * The denominator for the current time signature (the `4` in `3/4`). + */ + public var timeSignatureDenominator(get, never):Int; + + function get_timeSignatureDenominator():Int + { + if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN; + + return currentTimeChange.timeSignatureDen; + } + + /** + * Current position in the song, in measures. + */ + public var currentMeasure(default, null):Int = 0; + + /** + * Current position in the song, in beats. + */ + public var currentBeat(default, null):Int = 0; + + /** + * Current position in the song, in steps. + */ + public var currentStep(default, null):Int = 0; + + /** + * Current position in the song, in measures and fractions of a measure. + */ + public var currentMeasureTime(default, null):Float = 0; + + /** + * Current position in the song, in beats and fractions of a measure. + */ + public var currentBeatTime(default, null):Float = 0; + + /** + * Current position in the song, in steps and fractions of a step. + */ + public var currentStepTime(default, null):Float = 0; + + /** + * An offset tied to the current chart file to compensate for a delay in the instrumental. + */ + public var instrumentalOffset:Float = 0; + + /** + * The instrumental offset, in terms of steps. + */ + public var instrumentalOffsetSteps(get, never):Float; + + function get_instrumentalOffsetSteps():Float + { + var startingStepLengthMs:Float = ((Constants.SECS_PER_MIN / startingBPM) * Constants.MS_PER_SEC) / timeSignatureNumerator; + + return instrumentalOffset / startingStepLengthMs; + } + + /** + * An offset tied to the file format of the audio file being played. + */ + public var formatOffset:Float = 0; + + /** + * An offset set by the user to compensate for input lag. + * No matter if you're using a local conductor or not, this always loads + * to/from the save file + */ + public var inputOffset(get, set):Int; + + /** + * An offset set by the user to compensate for audio/visual lag + * No matter if you're using a local conductor or not, this always loads + * to/from the save file + */ + public var audioVisualOffset(get, set):Int; + + function get_inputOffset():Int + { + return Save?.instance?.options?.inputOffset ?? 0; + } + + function set_inputOffset(value:Int):Int + { + Save.instance.options.inputOffset = value; + Save.instance.flush(); + return Save.instance.options.inputOffset; + } + + function get_audioVisualOffset():Int + { + return Save?.instance?.options?.audioVisualOffset ?? 0; + } + + function set_audioVisualOffset(value:Int):Int + { + Save.instance.options.audioVisualOffset = value; + Save.instance.flush(); + return Save.instance.options.audioVisualOffset; + } + + public var combinedOffset(get, never):Float; + + function get_combinedOffset():Float + { + return instrumentalOffset + audioVisualOffset + inputOffset; + } + + /** + * The number of beats in a measure. May be fractional depending on the time signature. + */ + public var beatsPerMeasure(get, never):Float; + + function get_beatsPerMeasure():Float + { + // NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure + return stepsPerMeasure / Constants.STEPS_PER_BEAT; + } + + /** + * The number of steps in a measure. + * TODO: I don't think this can be fractional? + */ + public var stepsPerMeasure(get, never):Int; + + function get_stepsPerMeasure():Int + { + // TODO: Is this always an integer? + return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT); + } + + /** + * Reset the Conductor, replacing the current instance with a fresh one. + */ + public static function reset():Void + { + set_instance(new Conductor()); + } + + static function dispatchMeasureHit():Void + { + Conductor.measureHit.dispatch(); + } + + static function dispatchBeatHit():Void + { + Conductor.beatHit.dispatch(); + } + + static function dispatchStepHit():Void + { + Conductor.stepHit.dispatch(); + } + + static function setupSingleton(input:Conductor):Void + { + input.onMeasureHit.add(dispatchMeasureHit); + + input.onBeatHit.add(dispatchBeatHit); + + input.onStepHit.add(dispatchStepHit); + } + + static function clearSingleton(input:Conductor):Void + { + input.onMeasureHit.remove(dispatchMeasureHit); + + input.onBeatHit.remove(dispatchBeatHit); + + input.onStepHit.remove(dispatchStepHit); + } + + static function get_instance():Conductor + { + if (Conductor._instance == null) set_instance(new Conductor()); + if (Conductor._instance == null) throw "Could not initialize singleton Conductor!"; + return Conductor._instance; + } + + static function set_instance(instance:Conductor):Conductor + { + // Use _instance in here to avoid recursion + if (Conductor._instance != null) clearSingleton(Conductor._instance); + + Conductor._instance = instance; + + if (Conductor._instance != null) setupSingleton(Conductor._instance); + + return Conductor._instance; + } + + /** + * The constructor. + */ + public function new() {} + + /** + * Forcibly defines the current BPM of the song. + * Useful for things like the chart editor that need to manipulate BPM in real time. + * + * Set to null to reset to the BPM defined by the timeChanges. + * + * WARNING: Avoid this for things like setting the BPM of the title screen music, + * you should have a metadata file for it instead. + * We should probably deprecate this in the future. + */ + public function forceBPM(?bpm:Float):Void + { + if (bpm != null) + { + trace('[CONDUCTOR] Forcing BPM to ${bpm}'); + } + else + { + trace('[CONDUCTOR] Resetting BPM to default'); + } + + this.bpmOverride = bpm; + } + + /** + * Update the conductor with the current song position. + * BPM, current step, etc. will be re-calculated based on the song position. + * + * @param songPosition The current position in the song in milliseconds. + * Leave blank to use the FlxG.sound.music position. + * @param applyOffsets If it should apply the instrumentalOffset + formatOffset + audioVisualOffset + */ + public function update(?songPos:Float, applyOffsets:Bool = true, forceDispatch:Bool = false) + { + var currentTime:Float = (FlxG.sound.music != null) ? FlxG.sound.music.time : 0.0; + var currentLength:Float = (FlxG.sound.music != null) ? FlxG.sound.music.length : 0.0; + + if (songPos == null) + { + songPos = currentTime; + } + + // Take into account instrumental and file format song offsets. + songPos += applyOffsets ? (combinedOffset) : 0; + + var oldMeasure:Float = this.currentMeasure; + var oldBeat:Float = this.currentBeat; + var oldStep:Float = this.currentStep; + + // If the song is playing, limit the song position to the length of the song or beginning of the song. + if (FlxG.sound.music != null && FlxG.sound.music.playing) + { + this.songPosition = Math.min(currentLength, Math.max(0, songPos)); + } + else + { + this.songPosition = songPos; + } + + // Set the song position we are at (for purposes of calculating note positions, etc). + + currentTimeChange = timeChanges[0]; + if (this.songPosition > 0.0) + { + for (i in 0...timeChanges.length) + { + if (this.songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; + + if (this.songPosition < timeChanges[i].timeStamp) break; + } + } + + if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null) + { + trace('WARNING: Conductor is broken, timeChanges is empty.'); + } + else if (currentTimeChange != null && this.songPosition > 0.0) + { + // roundDecimal prevents representing 8 as 7.9999999 + this.currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * Constants.STEPS_PER_BEAT) + + (this.songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6); + this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; + this.currentMeasureTime = currentStepTime / stepsPerMeasure; + this.currentStep = Math.floor(currentStepTime); + this.currentBeat = Math.floor(currentBeatTime); + this.currentMeasure = Math.floor(currentMeasureTime); + } + else + { + // Assume a constant BPM equal to the forced value. + this.currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4); + this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; + this.currentMeasureTime = currentStepTime / stepsPerMeasure; + this.currentStep = Math.floor(currentStepTime); + this.currentBeat = Math.floor(currentBeatTime); + this.currentMeasure = Math.floor(currentMeasureTime); + } + + // FlxSignals are really cool. + if (currentStep != oldStep) + { + this.onStepHit.dispatch(); + } + + if (currentBeat != oldBeat) + { + this.onBeatHit.dispatch(); + } + + if (currentMeasure != oldMeasure) + { + this.onMeasureHit.dispatch(); + } + + // only update the timestamp if songPosition actually changed + // which it doesn't do every frame! + if (prevTime != this.songPosition) + { + // Update the timestamp for use in-between frames + prevTime = this.songPosition; + prevTimestamp = Std.int(Timer.stamp() * 1000); + } + } + + /** + * Can be called in-between frames, usually for input related things + * that can potentially get processed on exact milliseconds/timestmaps. + * If you need song position, use `Conductor.instance.songPosition` instead + * for use in update() related functions. + * @param soundToCheck Which FlxSound object to check, defaults to FlxG.sound.music if no input + * @return Float + */ + public function getTimeWithDiff(?soundToCheck:FlxSound):Float + { + if (soundToCheck == null) soundToCheck = FlxG.sound.music; + // trace(this.songPosition); + + @:privateAccess + this.songPosition = soundToCheck._channel.position; + // return this.songPosition + (Std.int(Timer.stamp() * 1000) - prevTimestamp); + // trace("\t--> " + this.songPosition); + return this.songPosition; + } + + /** + * Apply the `SongTimeChange` data from the song metadata to this Conductor. + * @param songTimeChanges The SongTimeChanges. + */ + public function mapTimeChanges(songTimeChanges:Array):Void + { + timeChanges = []; + + // Sort in place just in case it's out of order. + SongDataUtils.sortTimeChanges(songTimeChanges); + + for (songTimeChange in songTimeChanges) + { + // TODO: Maybe handle this different? + // Do we care about BPM at negative timestamps? + // Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`. + if (songTimeChange.timeStamp < 0.0) songTimeChange.timeStamp = 0.0; + + if (songTimeChange.timeStamp <= 0.0) + { + songTimeChange.beatTime = 0.0; + } + else + { + // Calculate the beat time of this timestamp. + songTimeChange.beatTime = 0.0; + + if (songTimeChange.timeStamp > 0.0 && timeChanges.length > 0) + { + var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1]; + songTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime + + ((songTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC), + 4); + } + } + + timeChanges.push(songTimeChange); + } + + if (timeChanges.length > 0) + { + trace('Done mapping time changes: ${timeChanges}'); + } + + // Update currentStepTime + this.update(this.songPosition, false); + } + + /** + * Given a time in milliseconds, return a time in steps. + * @param ms The time in milliseconds. + * @return The time in steps. + */ + public function getTimeInSteps(ms:Float):Float + { + if (timeChanges.length == 0) + { + // Assume a constant BPM equal to the forced value. + return Math.floor(ms / stepLengthMs); + } + else + { + var resultStep:Float = 0; + + var lastTimeChange:SongTimeChange = timeChanges[0]; + for (timeChange in timeChanges) + { + if (ms >= timeChange.timeStamp) + { + lastTimeChange = timeChange; + resultStep = lastTimeChange.beatTime * Constants.STEPS_PER_BEAT; + } + else + { + // This time change is after the requested time. + break; + } + } + + var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; + var resultFractionalStep:Float = (ms - lastTimeChange.timeStamp) / lastStepLengthMs; + resultStep += resultFractionalStep; + + return resultStep; + } + } + + /** + * Given a time in steps and fractional steps, return a time in milliseconds. + * @param stepTime The time in steps. + * @return The time in milliseconds. + */ + public function getStepTimeInMs(stepTime:Float):Float + { + if (timeChanges.length == 0) + { + // Assume a constant BPM equal to the forced value. + return stepTime * stepLengthMs; + } + else + { + var resultMs:Float = 0; + + var lastTimeChange:SongTimeChange = timeChanges[0]; + for (timeChange in timeChanges) + { + if (stepTime >= timeChange.beatTime * Constants.STEPS_PER_BEAT) + { + lastTimeChange = timeChange; + resultMs = lastTimeChange.timeStamp; + } + else + { + // This time change is after the requested time. + break; + } + } + + var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; + resultMs += (stepTime - lastTimeChange.beatTime * Constants.STEPS_PER_BEAT) * lastStepLengthMs; + + return resultMs; + } + } + + /** + * Given a time in beats and fractional beats, return a time in milliseconds. + * @param beatTime The time in beats. + * @return The time in milliseconds. + */ + public function getBeatTimeInMs(beatTime:Float):Float + { + if (timeChanges.length == 0) + { + // Assume a constant BPM equal to the forced value. + return beatTime * stepLengthMs * Constants.STEPS_PER_BEAT; + } + else + { + var resultMs:Float = 0; + + var lastTimeChange:SongTimeChange = timeChanges[0]; + for (timeChange in timeChanges) + { + if (beatTime >= timeChange.beatTime) + { + lastTimeChange = timeChange; + resultMs = lastTimeChange.timeStamp; + } + else + { + // This time change is after the requested time. + break; + } + } + + var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; + resultMs += (beatTime - lastTimeChange.beatTime) * lastStepLengthMs * Constants.STEPS_PER_BEAT; + + return resultMs; + } + } + + /** + * Adds Conductor fields to the Flixel debugger variable display. + * @param conductorToUse The conductor to use. Defaults to `Conductor.instance`. + */ + public static function watchQuick(?target:Conductor):Void + { + if (target == null) target = Conductor.instance; + + FlxG.watch.addQuick('songPosition', target.songPosition); + FlxG.watch.addQuick('bpm', target.bpm); + FlxG.watch.addQuick('currentMeasureTime', target.currentMeasureTime); + FlxG.watch.addQuick('currentBeatTime', target.currentBeatTime); + FlxG.watch.addQuick('currentStepTime', target.currentStepTime); + } +} diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx new file mode 100644 index 00000000..94f41cea --- /dev/null +++ b/source/funkin/Highscore.hx @@ -0,0 +1,99 @@ +package funkin; + +/** + * A core class which handles tracking score and combo for the current song. + */ +class Highscore +{ + /** + * Keeps track of notes hit for the current song + * and how accurate you were with each note (bad, missed, shit, etc.) + */ + public static var tallies:Tallies = new Tallies(); + + /** + * Keeps track of notes hit for the current WEEK / level + * for use with storymode, or likely any other "playlist" esque option + */ + public static var talliesLevel:Tallies = new Tallies(); + + /** + * Produces a new Tallies object which represents the sum of two existing Tallies + * @param newTally The first tally + * @param baseTally The second tally + * @return The combined tally + */ + public static function combineTallies(newTally:Tallies, baseTally:Tallies):Tallies + { + var combinedTally:Tallies = new Tallies(); + combinedTally.missed = newTally.missed + baseTally.missed; + combinedTally.shit = newTally.shit + baseTally.shit; + combinedTally.bad = newTally.bad + baseTally.bad; + combinedTally.good = newTally.good + baseTally.good; + combinedTally.sick = newTally.sick + baseTally.sick; + combinedTally.totalNotes = newTally.totalNotes + baseTally.totalNotes; + combinedTally.totalNotesHit = newTally.totalNotesHit + baseTally.totalNotesHit; + + // Current combo = use most recent. + combinedTally.combo = newTally.combo; + // Max combo = use maximum value. + combinedTally.maxCombo = Std.int(Math.max(newTally.maxCombo, baseTally.maxCombo)); + + return combinedTally; + } +} + +@:forward +abstract Tallies(RawTallies) +{ + public function new() + { + this = + { + combo: 0, + missed: 0, + shit: 0, + bad: 0, + good: 0, + sick: 0, + totalNotes: 0, + totalNotesHit: 0, + maxCombo: 0, + score: 0, + isNewHighscore: false + } + } +} + +/** + * A structure object containing the data for highscore tallies. + */ +typedef RawTallies = +{ + var combo:Int; + + /** + * How many notes you let scroll by. + */ + var missed:Int; + + var shit:Int; + var bad:Int; + var good:Int; + var sick:Int; + var maxCombo:Int; + + var score:Int; + + var isNewHighscore:Bool; + + /** + * How many notes total that you hit. (NOT how many notes total in the song!) + */ + var totalNotesHit:Int; + + /** + * How many notes in the current chart + */ + var totalNotes:Int; +} diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx new file mode 100644 index 00000000..57ad2dd0 --- /dev/null +++ b/source/funkin/InitState.hx @@ -0,0 +1,493 @@ +package funkin; + +import flixel.addons.transition.FlxTransitionableState; +import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond; +import flixel.addons.transition.TransitionData; +import flixel.FlxSprite; +import flixel.FlxState; +import flixel.graphics.FlxGraphic; +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import flixel.system.debug.log.LogStyle; +import flixel.util.FlxColor; +import funkin.data.dialogue.conversation.ConversationRegistry; +import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry; +import funkin.data.dialogue.speaker.SpeakerRegistry; +import funkin.data.freeplay.album.AlbumRegistry; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.freeplay.style.FreeplayStyleRegistry; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.data.song.SongRegistry; +import funkin.data.stickers.StickerRegistry; +import funkin.data.event.SongEventRegistry; +import funkin.data.stage.StageRegistry; +import funkin.data.story.level.LevelRegistry; +import funkin.modding.module.ModuleHandler; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.notes.notekind.NoteKindManager; +import funkin.play.PlayStatePlaylist; +import funkin.ui.debug.charting.ChartEditorState; +import funkin.ui.title.TitleState; +import funkin.ui.transition.LoadingState; +import funkin.util.CLIUtil; +import funkin.util.CLIUtil.CLIParams; +import funkin.util.macro.MacroUtil; +import funkin.util.TimerUtil; +import funkin.util.TrackerUtil; +import funkin.util.WindowUtil; +import openfl.display.BitmapData; +#if FEATURE_DISCORD_RPC +import funkin.api.discord.DiscordClient; +#end +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.NewgroundsClient; +#end + +/** + * A core class which performs initialization of the game. + * The initialization state has several functions: + * - Calls code to set up the game, including loading saves and parsing game data. + * - Chooses whether to start via debug or via launching normally. + * + * It should not contain any sprites or rendering. + */ +class InitState extends FlxState +{ + /** + * Perform a bunch of game setup, then immediately transition to the title screen. + */ + public override function create():Void + { + // Setup a bunch of important Flixel stuff. + setupShit(); + + // Load player options from save data. + // Flixel has already loaded the save data, so we can just use it. + Preferences.init(); + + // Load controls from save data. + PlayerSettings.init(); + + startGame(); + } + + /** + * Setup a bunch of important Flixel stuff. + */ + function setupShit():Void + { + // + // GAME SETUP + // + + // Setup window events (like callbacks for onWindowClose) + // and fullscreen keybind setup + WindowUtil.initWindowEvents(); + // Disable the thing on Windows where it tries to send a bug report to Microsoft because why do they care? + WindowUtil.disableCrashHandler(); + + #if FEATURE_DEBUG_TRACY + funkin.util.WindowUtil.initTracy(); + #end + + // This ain't a pixel art game! (most of the time) + FlxSprite.defaultAntialiasing = true; + + // Disable default keybinds for volume (we manually control volume in MusicBeatState with custom binds) + FlxG.sound.volumeUpKeys = []; + FlxG.sound.volumeDownKeys = []; + FlxG.sound.muteKeys = []; + + // A small jumpstart to the soundtray, it usually sets itself to inactive (somewhere...) + // but that makes our soundtray not show up on init if we have the game muted. + // We set it to active so it at least calls it's update function once (see FlxGame.onEnterFrame(), it's called there) + // and also see FunkinSoundTray.update() to see what we do and how we check if we are muted or not + FlxG.game.soundTray.active = true; + + // 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(); + + // + // FLIXEL TRANSITIONS + // + + // Diamond Transition + var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond); + diamond.persist = true; + diamond.destroyOnNoUse = false; + + // NOTE: tileData is ignored if TransitionData.type is FADE instead of TILES. + var tileData:TransitionTileData = {asset: diamond, width: 32, height: 32}; + + FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), tileData, + new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); + FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), tileData, + new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4)); + // Don't play transition in when entering the title state. + FlxTransitionableState.skipNextTransIn = true; + + // + // NEWGROUNDS API SETUP + // + #if FEATURE_NEWGROUNDS + NewgroundsClient.instance.init(); + #end + + // + // DISCORD API SETUP + // + #if FEATURE_DISCORD_RPC + DiscordClient.instance.init(); + + lime.app.Application.current.onExit.add(function(exitCode) { + DiscordClient.instance.shutdown(); + }); + #end + + // + // ANDROID SETUP + // + #if android + FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK]; + #end + + // + // FLIXEL PLUGINS + // + // Plugins provide a useful interface for globally active Flixel objects, + // that receive update events regardless of the current state. + // TODO: Move scripted Module behavior to a Flixel plugin. + #if FEATURE_DEBUG_FUNCTIONS + funkin.util.plugins.MemoryGCPlugin.initialize(); + #end + #if FEATURE_SCREENSHOTS + funkin.util.plugins.ScreenshotPlugin.initialize(); + #end + funkin.util.plugins.NewgroundsMedalPlugin.initialize(); + funkin.util.plugins.EvacuateDebugPlugin.initialize(); + funkin.util.plugins.ForceCrashPlugin.initialize(); + funkin.util.plugins.ReloadAssetsDebugPlugin.initialize(); + funkin.util.plugins.VolumePlugin.initialize(); + funkin.util.plugins.WatchPlugin.initialize(); + + // + // GAME DATA PARSING + // + + // NOTE: Registries must be imported and not referenced with fully qualified names, + // to ensure build macros work properly. + trace('Parsing game data...'); + var perfStart:Float = TimerUtil.start(); + SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry. + SongRegistry.instance.loadEntries(); + LevelRegistry.instance.loadEntries(); + NoteStyleRegistry.instance.loadEntries(); + PlayerRegistry.instance.loadEntries(); + ConversationRegistry.instance.loadEntries(); + DialogueBoxRegistry.instance.loadEntries(); + SpeakerRegistry.instance.loadEntries(); + FreeplayStyleRegistry.instance.loadEntries(); + AlbumRegistry.instance.loadEntries(); + StageRegistry.instance.loadEntries(); + StickerRegistry.instance.loadEntries(); + + // TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers and more prone to syntax errors. + // Move it to use a BaseRegistry. + CharacterDataParser.loadCharacterCache(); + + NoteKindManager.loadScripts(); + + ModuleHandler.buildModuleCallbacks(); + ModuleHandler.loadModuleCache(); + ModuleHandler.callOnCreate(); + + funkin.input.Cursor.hide(); + + funkin.multiplayer.MultiplayerPlugin.initialize(); + + trace('Parsing game data took: ${TimerUtil.ms(perfStart)}'); + } + + /** + * Start the game. + * + * By default, moves to the `TitleState`. + * But based on compile defines, the game can start immediately on a specific song, + * or immediately in a specific debug menu. + */ + function startGame():Void + { + #if SONG + // -DSONG=bopeebo + startSong(defineSong(), defineDifficulty()); + #elseif LEVEL + // -DLEVEL=week1 -DDIFFICULTY=hard + startLevel(defineLevel(), defineDifficulty()); + #elseif FREEPLAY + // -DFREEPLAY + FlxG.switchState(() -> new funkin.ui.freeplay.FreeplayState()); + #elseif DIALOGUE + // -DDIALOGUE + FlxG.switchState(() -> new funkin.ui.debug.dialogue.ConversationDebugState()); + #elseif ANIMATE + // -DANIMATE + FlxG.switchState(() -> new funkin.ui.debug.anim.FlxAnimateTest()); + #elseif WAVEFORM + // -DWAVEFORM + FlxG.switchState(() -> new funkin.ui.debug.WaveformTestState()); + #elseif CHARTING + // -DCHARTING + FlxG.switchState(() -> new funkin.ui.debug.charting.ChartEditorState()); + #elseif STAGEBUILD + // -DSTAGEBUILD + FlxG.switchState(() -> new funkin.ui.debug.stage.StageBuilderState()); + #elseif RESULTS + // -DRESULTS + FlxG.switchState(() -> new funkin.play.ResultState( + { + storyMode: true, + title: "Cum Song Erect by Kawai Sprite", + songId: "cum", + characterId: "pico", + difficultyId: "hard", + isNewHighscore: true, + scoreData: + { + score: 1_234_567, + tallies: + { + sick: 130, + good: 60, + bad: 69, + shit: 69, + missed: 69, + combo: 69, + maxCombo: 69, + totalNotesHit: 140, + totalNotes: 240 + } + // 2400 total notes = 7% = LOSS + // 275 total notes = 69% = NICE + // 240 total notes = 79% = GOOD + // 230 total notes = 82% = GREAT + // 210 total notes = 91% = EXCELLENT + // 190 total notes = PERFECT + }, + })); + #elseif ANIMDEBUG + // -DANIMDEBUG + FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState()); + #elseif LATENCY + // -DLATENCY + FlxG.switchState(() -> new funkin.LatencyState()); + #else + startGameNormally(); + #end + } + + /** + * Start the game by moving to the title state and play the game as normal. + */ + function startGameNormally():Void + { + var params:CLIParams = CLIUtil.processArgs(); + trace('Command line args: ${params}'); + + if (params.chart.shouldLoadChart) + { + FlxG.switchState(() -> new ChartEditorState( + { + fnfcTargetPath: params.chart.chartPath, + })); + } + else + { + FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu')); + FlxG.switchState(() -> new TitleState()); + } + } + + /** + * Start the game by directly loading into a specific song. + * @param songId + * @param difficultyId + */ + function startSong(songId:String, difficultyId:String = 'normal'):Void + { + var songData:funkin.play.song.Song = funkin.data.song.SongRegistry.instance.fetchEntry(songId); + + if (songData == null) + { + startGameNormally(); + return; + } + + // TODO: Rework loading behavior so we don't have to do this. + switch (songId) + { + case 'tutorial' | 'bopeebo' | 'fresh' | 'dadbattle': + Paths.setCurrentLevel('week1'); + PlayStatePlaylist.campaignId = 'week1'; + case 'spookeez' | 'south' | 'monster': + Paths.setCurrentLevel('week2'); + PlayStatePlaylist.campaignId = 'week2'; + case 'pico' | 'philly-nice' | 'blammed': + Paths.setCurrentLevel('week3'); + PlayStatePlaylist.campaignId = 'week3'; + case 'high' | 'satin-panties' | 'milf': + Paths.setCurrentLevel('week4'); + PlayStatePlaylist.campaignId = 'week4'; + case 'cocoa' | 'eggnog' | 'winter-horrorland': + Paths.setCurrentLevel('week5'); + PlayStatePlaylist.campaignId = 'week5'; + case 'senpai' | 'roses' | 'thorns': + Paths.setCurrentLevel('week6'); + PlayStatePlaylist.campaignId = 'week6'; + case 'ugh' | 'guns' | 'stress': + Paths.setCurrentLevel('week7'); + PlayStatePlaylist.campaignId = 'week7'; + case 'darnell' | 'lit-up' | '2hot' | 'blazin': + Paths.setCurrentLevel('weekend1'); + PlayStatePlaylist.campaignId = 'weekend1'; + } + + LoadingState.loadPlayState( + { + targetSong: songData, + targetDifficulty: difficultyId, + }); + } + + /** + * Start the game by directly loading into a specific story mode level. + * @param levelId + * @param difficultyId + */ + function startLevel(levelId:String, difficultyId:String = 'normal'):Void + { + var currentLevel:funkin.ui.story.Level = funkin.data.story.level.LevelRegistry.instance.fetchEntry(levelId); + + if (currentLevel == null) + { + startGameNormally(); + return; + } + + // TODO: Rework loading behavior so we don't have to do this. + Paths.setCurrentLevel(levelId); + PlayStatePlaylist.campaignId = levelId; + + PlayStatePlaylist.playlistSongIds = currentLevel.getSongs(); + PlayStatePlaylist.isStoryMode = true; + PlayStatePlaylist.campaignScore = 0; + + var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); + + var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId); + + LoadingState.loadPlayState( + { + targetSong: targetSong, + targetDifficulty: difficultyId, + }); + } + + function setupFlixelDebug():Void + { + // + // FLIXEL DEBUG SETUP + // + #if FEATURE_DEBUG_FUNCTIONS + trace('Initializing Flixel debugger...'); + + #if !debug + // Make errors less annoying on release builds. + LogStyle.ERROR.openConsole = false; + LogStyle.ERROR.errorSound = null; + #end + + // Make errors and warnings less annoying. + LogStyle.WARNING.openConsole = false; + LogStyle.WARNING.errorSound = null; + + // Disable using ~ to open the console (we use that for the Editor menu) + FlxG.debugger.toggleKeys = [F2]; + TrackerUtil.initTrackers(); + // Adds an additional Close Debugger button. + // This big obnoxious white button is for MOBILE, so that you can press it + // easily with your finger when debug bullshit pops up during testing lol! + FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() { + FlxG.debugger.visible = false; + + // Make errors and warnings less annoying. + // Forcing this always since I have never been happy to have the debugger to pop up + LogStyle.ERROR.openConsole = false; + LogStyle.ERROR.errorSound = null; + LogStyle.WARNING.openConsole = false; + LogStyle.WARNING.errorSound = null; + }); + + // Adds a red button to the debugger. + // This pauses the game AND the music! This ensures the Conductor stops. + FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() { + if (FlxG.vcr.paused) + { + FlxG.vcr.resume(); + + for (snd in FlxG.sound.list) + { + snd.resume(); + } + + FlxG.sound.music.resume(); + } + else + { + FlxG.vcr.pause(); + + for (snd in FlxG.sound.list) + { + snd.pause(); + } + + FlxG.sound.music.pause(); + } + }); + + // Adds a blue button to the debugger. + // This skips forward in the song. + FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() { + FlxG.game.debugger.vcr.onStep(); + + for (snd in FlxG.sound.list) + { + snd.pause(); + snd.time += FlxG.elapsed * 1000; + } + + FlxG.sound.music.pause(); + FlxG.sound.music.time += FlxG.elapsed * 1000; + }); + #end + } + + function defineSong():String + { + return MacroUtil.getDefine('SONG'); + } + + function defineLevel():String + { + return MacroUtil.getDefine('LEVEL'); + } + + function defineDifficulty():String + { + return MacroUtil.getDefine('DIFFICULTY'); + } +} diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx new file mode 100644 index 00000000..ae77ac2d --- /dev/null +++ b/source/funkin/Paths.hx @@ -0,0 +1,177 @@ +package funkin; + +import flixel.graphics.frames.FlxAtlasFrames; +import openfl.utils.AssetType; + +/** + * A core class which handles determining asset paths. + */ +class Paths +{ + static var currentLevel:Null = null; + + public static function setCurrentLevel(name:Null):Void + { + if (name == null) + { + currentLevel = null; + } + else + { + currentLevel = name.toLowerCase(); + } + } + + public static function stripLibrary(path:String):String + { + var parts:Array = path.split(':'); + if (parts.length < 2) return path; + return parts[1]; + } + + public static function getLibrary(path:String):String + { + var parts:Array = path.split(':'); + if (parts.length < 2) return 'preload'; + return parts[0]; + } + + static function getPath(file:String, type:AssetType, library:Null):String + { + if (library != null) return getLibraryPath(file, library); + + if (currentLevel != null) + { + var levelPath:String = getLibraryPathForce(file, currentLevel); + if (Assets.exists(levelPath, type)) return levelPath; + } + + var levelPath:String = getLibraryPathForce(file, 'shared'); + if (Assets.exists(levelPath, type)) return levelPath; + + return getPreloadPath(file); + } + + public static function getLibraryPath(file:String, library = 'preload'):String + { + return if (library == 'preload' || library == 'default') getPreloadPath(file); else getLibraryPathForce(file, library); + } + + static inline function getLibraryPathForce(file:String, library:String):String + { + return '$library:assets/$library/$file'; + } + + static inline function getPreloadPath(file:String):String + { + return 'assets/$file'; + } + + public static function file(file:String, type:AssetType = TEXT, ?library:String):String + { + return getPath(file, type, library); + } + + public static function animateAtlas(path:String, ?library:String):String + { + return getLibraryPath('images/$path', library); + } + + public static function txt(key:String, ?library:String):String + { + return getPath('data/$key.txt', TEXT, library); + } + + public static function frag(key:String, ?library:String):String + { + return getPath('shaders/$key.frag', TEXT, library); + } + + public static function vert(key:String, ?library:String):String + { + return getPath('shaders/$key.vert', TEXT, library); + } + + public static function xml(key:String, ?library:String):String + { + return getPath('data/$key.xml', TEXT, library); + } + + public static function json(key:String, ?library:String):String + { + return getPath('data/$key.json', TEXT, library); + } + + public static function sound(key:String, ?library:String):String + { + return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library); + } + + public static function soundRandom(key:String, min:Int, max:Int, ?library:String):String + { + return sound(key + FlxG.random.int(min, max), library); + } + + public static function music(key:String, ?library:String):String + { + return getPath('music/$key.${Constants.EXT_SOUND}', MUSIC, library); + } + + public static function videos(key:String, ?library:String):String + { + return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library ?? 'videos'); + } + + public static function voices(song:String, ?suffix:String = ''):String + { + if (suffix == null) suffix = ''; // no suffix, for a sorta backwards compatibility with older-ish voice files + + return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}'; + } + + /** + * Gets the path to an `Inst.mp3/ogg` song instrumental from songs:assets/songs/`song`/ + * @param song name of the song to get instrumental for + * @param suffix any suffix to add to end of song name, used for `-erect` variants usually + * @param withExtension if it should return with the audio file extension `.mp3` or `.ogg`. + * @return String + */ + public static function inst(song:String, ?suffix:String = '', ?withExtension:Bool = true):String + { + var ext:String = withExtension ? '.${Constants.EXT_SOUND}' : ''; + return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix$ext'; + } + + public static function image(key:String, ?library:String):String + { + return getPath('images/$key.png', IMAGE, library); + } + + public static function font(key:String):String + { + return 'assets/fonts/$key'; + } + + public static function ui(key:String, ?library:String):String + { + return xml('ui/$key', library); + } + + public static function getSparrowAtlas(key:String, ?library:String):FlxAtlasFrames + { + return FlxAtlasFrames.fromSparrow(image(key, library), file('images/$key.xml', library)); + } + + public static function getPackerAtlas(key:String, ?library:String):FlxAtlasFrames + { + return FlxAtlasFrames.fromSpriteSheetPacker(image(key, library), file('images/$key.txt', library)); + } +} + +enum abstract PathsFunction(String) +{ + var MUSIC; + var INST; + var VOICES; + var SOUND; +} diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx new file mode 100644 index 00000000..45629eb8 --- /dev/null +++ b/source/funkin/PlayerSettings.hx @@ -0,0 +1,176 @@ +package funkin; + +import funkin.save.Save; +import funkin.input.Controls; +import funkin.input.PreciseInputManager; +import flixel.input.gamepad.FlxGamepad; +import flixel.util.FlxSignal.FlxTypedSignal; + +/** + * A core class which represents the current player(s) and their controls and other configuration. + */ +class PlayerSettings +{ + // TODO: Finish implementation of second player. + public static var numPlayers(default, null) = 0; + public static var numAvatars(default, null) = 0; + public static var player1(default, null):PlayerSettings; + public static var player2(default, null):PlayerSettings; + + public static var onAvatarAdd(default, null) = new FlxTypedSignalVoid>(); + public static var onAvatarRemove(default, null) = new FlxTypedSignalVoid>(); + + /** + * The player number associated with this settings object. + */ + public var id(default, null):Int; + + /** + * The controls handler for this player. + */ + public var controls(default, null):Controls; + + /** + * Return the PlayerSettings for the given player number, or `null` if that player isn't active. + * + * @param id The player number this represents. + * @return The PlayerSettings for the given player number, or `null` if that player isn't active. + */ + public static function get(id:Int):Null + { + return switch (id) + { + case 1: player1; + case 2: player2; + default: null; + }; + } + + /** + * Initialize the PlayerSettings singletons for each player. + */ + public static function init():Void + { + if (player1 == null) + { + player1 = new PlayerSettings(1); + ++numPlayers; + } + + FlxG.gamepads.deviceConnected.add(onGamepadAdded); + + var numGamepads = FlxG.gamepads.numActiveGamepads; + for (i in 0...numGamepads) + { + var gamepad = FlxG.gamepads.getByID(i); + if (gamepad != null) onGamepadAdded(gamepad); + } + } + + /** + * Forcibly destroy the PlayerSettings singletons for each player. + */ + public static function reset():Void + { + player1 = null; + player2 = null; + numPlayers = 0; + } + + /** + * Callback invoked when a gamepad is added. + * @param gamepad The gamepad that was added. + */ + static function onGamepadAdded(gamepad:FlxGamepad):Void + { + // TODO: Make this detect and handle multiple players + player1.addGamepad(gamepad); + } + + /** + * @param id The player number this represents. This was refactored to START AT `1`. + */ + function new(id:Int) + { + trace('loading player settings for id: $id'); + + this.id = id; + this.controls = new Controls('player$id', None); + + addKeyboard(); + } + + function addKeyboard():Void + { + var useDefault:Bool = true; + if (Save.instance.hasControls(id, Keys)) + { + var keyControlData = Save.instance.getControls(id, Keys); + trace('Loading keyboard control scheme from user save'); + useDefault = false; + controls.fromSaveData(keyControlData, Keys); + } + else + { + useDefault = true; + } + + if (useDefault) + { + trace('Loading default keyboard control scheme'); + controls.setKeyboardScheme(Solo); + } + + PreciseInputManager.instance.initializeKeys(controls); + } + + /** + * Called after an FlxGamepad has been detected. + * @param gamepad The gamepad that was detected. + */ + function addGamepad(gamepad:FlxGamepad):Void + { + var useDefault = true; + if (Save.instance.hasControls(id, Gamepad(gamepad.id))) + { + var padControlData = Save.instance.getControls(id, Gamepad(gamepad.id)); + trace('Loading gamepad control scheme from user save'); + useDefault = false; + controls.addGamepadWithSaveData(gamepad.id, padControlData); + } + else + { + useDefault = true; + } + + if (useDefault) + { + trace('Loading default gamepad control scheme'); + controls.addDefaultGamepad(gamepad.id); + } + PreciseInputManager.instance.initializeButtons(controls, gamepad); + } + + /** + * Save this player's controls to the game's persistent save. + */ + public function saveControls():Void + { + var keyData = controls.createSaveData(Keys); + if (keyData != null) + { + trace('Saving keyboard control scheme to user save'); + Save.instance.setControls(id, Keys, keyData); + } + + if (controls.gamepadsAdded.length > 0) + { + var padData = controls.createSaveData(Gamepad(controls.gamepadsAdded[0])); + if (padData != null) + { + trace('Saving gamepad control scheme to user save'); + Save.instance.setControls(id, Gamepad(controls.gamepadsAdded[0]), padData); + } + } + } +} diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx new file mode 100644 index 00000000..ce5aba6d --- /dev/null +++ b/source/funkin/Preferences.hx @@ -0,0 +1,428 @@ +package funkin; + +import funkin.save.Save; +import funkin.util.WindowUtil; + +/** + * A core class which provides a store of user-configurable, globally relevant values. + */ +class Preferences +{ + /** + * FPS + * @default `60` + */ + public static var framerate(get, set):Int; + + static function get_framerate():Int + { + #if web + return 60; + #else + return Save?.instance?.options?.framerate ?? 60; + #end + } + + static function set_framerate(value:Int):Int + { + #if web + return 60; + #else + var save:Save = Save.instance; + save.options.framerate = value; + save.flush(); + FlxG.updateFramerate = value; + FlxG.drawFramerate = value; + return value; + #end + } + + /** + * Whether some particularly foul language is displayed. + * @default `true` + */ + public static var naughtyness(get, set):Bool; + + static function get_naughtyness():Bool + { + return Save?.instance?.options?.naughtyness; + } + + static function set_naughtyness(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.naughtyness = value; + save.flush(); + return value; + } + + /** + * If enabled, the strumline is at the bottom of the screen rather than the top. + * @default `false` + */ + public static var downscroll(get, set):Bool; + + static function get_downscroll():Bool + { + return Save?.instance?.options?.downscroll; + } + + static function set_downscroll(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.downscroll = value; + save.flush(); + return value; + } + + /** + * If disabled, flashing lights in the main menu and other areas will be less intense. + * @default `true` + */ + public static var flashingLights(get, set):Bool; + + static function get_flashingLights():Bool + { + return Save?.instance?.options?.flashingLights ?? true; + } + + static function set_flashingLights(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.flashingLights = value; + save.flush(); + return value; + } + + /** + * If disabled, the camera bump synchronized to the beat. + * @default `false` + */ + public static var zoomCamera(get, set):Bool; + + static function get_zoomCamera():Bool + { + return Save?.instance?.options?.zoomCamera; + } + + static function set_zoomCamera(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.zoomCamera = value; + save.flush(); + return value; + } + + /** + * If enabled, an FPS and memory counter will be displayed even if this is not a debug build. + * @default `false` + */ + public static var debugDisplay(get, set):Bool; + + static function get_debugDisplay():Bool + { + return Save?.instance?.options?.debugDisplay; + } + + static function set_debugDisplay(value:Bool):Bool + { + if (value != Save.instance.options.debugDisplay) + { + toggleDebugDisplay(value); + } + + var save = Save.instance; + save.options.debugDisplay = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will automatically pause when tabbing out. + * @default `true` + */ + public static var autoPause(get, set):Bool; + + static function get_autoPause():Bool + { + return Save?.instance?.options?.autoPause ?? true; + } + + static function set_autoPause(value:Bool):Bool + { + if (value != Save.instance.options.autoPause) FlxG.autoPause = value; + + var save:Save = Save.instance; + save.options.autoPause = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will automatically launch in fullscreen on startup. + * @default `true` + */ + public static var autoFullscreen(get, set):Bool; + + static function get_autoFullscreen():Bool + { + return Save?.instance?.options?.autoFullscreen ?? true; + } + + static function set_autoFullscreen(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.autoFullscreen = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will utilize VSync (or adaptive VSync) on startup. + * @default `OFF` + */ + public static var vsyncMode(get, set):lime.ui.WindowVSyncMode; + + static function get_vsyncMode():lime.ui.WindowVSyncMode + { + var value = Save?.instance?.options?.vsyncMode ?? "Off"; + + return switch (value) + { + case "Off": + lime.ui.WindowVSyncMode.OFF; + case "On": + lime.ui.WindowVSyncMode.ON; + case "Adaptive": + lime.ui.WindowVSyncMode.ADAPTIVE; + default: + lime.ui.WindowVSyncMode.OFF; + }; + } + + static function set_vsyncMode(value:lime.ui.WindowVSyncMode):lime.ui.WindowVSyncMode + { + var string; + + switch (value) + { + case lime.ui.WindowVSyncMode.OFF: + string = "Off"; + case lime.ui.WindowVSyncMode.ON: + string = "On"; + case lime.ui.WindowVSyncMode.ADAPTIVE: + string = "Adaptive"; + default: + string = "Off"; + }; + + WindowUtil.setVSyncMode(value); + + var save:Save = Save.instance; + save.options.vsyncMode = string; + save.flush(); + return value; + } + + public static var unlockedFramerate(get, set):Bool; + + static function get_unlockedFramerate():Bool + { + return Save?.instance?.options?.unlockedFramerate; + } + + static function set_unlockedFramerate(value:Bool):Bool + { + if (value != Save.instance.options.unlockedFramerate) + { + #if web + toggleFramerateCap(value); + #end + } + + var save:Save = Save.instance; + save.options.unlockedFramerate = value; + save.flush(); + return value; + } + + #if web + // We create a haxe version of this just for readability. + // We use these to override `window.requestAnimationFrame` in Javascript to uncap the framerate / "animation" request rate + // Javascript is crazy since u can just do stuff like that lol + + public static function unlockedFramerateFunction(callback, element) + { + var currTime = Date.now().getTime(); + var timeToCall = 0; + var id = js.Browser.window.setTimeout(function() { + callback(currTime + timeToCall); + }, timeToCall); + return id; + } + + // Lime already implements their own little framerate cap, so we can just use that + // This also gets set in the init function in Main.hx, since we need to definitely override it + public static var lockedFramerateFunction = untyped js.Syntax.code("window.requestAnimationFrame"); + #end + + /** + * If >0, the game will display a semi-opaque background under the notes. + * `0` for no background, `100` for solid black if you're freaky like that + * @default `0` + */ + public static var strumlineBackgroundOpacity(get, set):Int; + + static function get_strumlineBackgroundOpacity():Int + { + return (Save?.instance?.options?.strumlineBackgroundOpacity ?? 0); + } + + static function set_strumlineBackgroundOpacity(value:Int):Int + { + var save:Save = Save.instance; + save.options.strumlineBackgroundOpacity = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will hide the mouse when taking a screenshot. + * @default `true` + */ + public static var shouldHideMouse(get, set):Bool; + + static function get_shouldHideMouse():Bool + { + return Save?.instance?.options?.screenshot?.shouldHideMouse ?? true; + } + + static function set_shouldHideMouse(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.screenshot.shouldHideMouse = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will show a preview after taking a screenshot. + * @default `true` + */ + public static var fancyPreview(get, set):Bool; + + static function get_fancyPreview():Bool + { + return Save?.instance?.options?.screenshot?.fancyPreview ?? true; + } + + static function set_fancyPreview(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.screenshot.fancyPreview = value; + save.flush(); + return value; + } + + /** + * If enabled, the game will show the preview only after a screenshot is saved. + * @default `true` + */ + public static var previewOnSave(get, set):Bool; + + static function get_previewOnSave():Bool + { + return Save?.instance?.options?.screenshot?.previewOnSave ?? true; + } + + static function set_previewOnSave(value:Bool):Bool + { + var save:Save = Save.instance; + save.options.screenshot.previewOnSave = value; + save.flush(); + return value; + } + + /** + * The game will save any screenshots taken to this format. + * @default `PNG` + */ + public static var saveFormat(get, set):Any; + + static function get_saveFormat():Any + { + return Save?.instance?.options?.screenshot?.saveFormat ?? 'PNG'; + } + + static function set_saveFormat(value):Any + { + var save:Save = Save.instance; + save.options.screenshot.saveFormat = value; + save.flush(); + return value; + } + + /** + * The game will save JPEG screenshots with this quality percentage. + * @default `80` + */ + public static var jpegQuality(get, set):Int; + + static function get_jpegQuality():Int + { + return Save?.instance?.options?.screenshot?.jpegQuality ?? 80; + } + + static function set_jpegQuality(value:Int):Int + { + var save:Save = Save.instance; + save.options.screenshot.jpegQuality = value; + save.flush(); + return value; + } + + /** + * Loads the user's preferences from the save data and apply them. + */ + public static function init():Void + { + // Apply the autoPause setting (enables automatic pausing on focus lost). + FlxG.autoPause = Preferences.autoPause; + // WindowUtil.setVSyncMode(Preferences.vsyncMode); + // Apply the debugDisplay setting (enables the FPS and RAM display). + toggleDebugDisplay(Preferences.debugDisplay); + #if web + toggleFramerateCap(Preferences.unlockedFramerate); + #end + // Apply the autoFullscreen setting (launches the game in fullscreen automatically) + FlxG.fullscreen = Preferences.autoFullscreen; + } + + static function toggleFramerateCap(unlocked:Bool):Void + { + #if web + var framerateFunction = unlocked ? unlockedFramerateFunction : lockedFramerateFunction; + untyped js.Syntax.code("window.requestAnimationFrame = framerateFunction;"); + #end + } + + static function toggleDebugDisplay(show:Bool):Void + { + if (show) + { + // Enable the debug display. + FlxG.stage.addChild(Main.fpsCounter); + #if !html5 + FlxG.stage.addChild(Main.memoryCounter); + #end + } + else + { + // Disable the debug display. + FlxG.stage.removeChild(Main.fpsCounter); + #if !html5 + FlxG.stage.removeChild(Main.memoryCounter); + #end + } + } +} diff --git a/source/funkin/api/discord/DiscordClient.hx b/source/funkin/api/discord/DiscordClient.hx new file mode 100644 index 00000000..b0cb3180 --- /dev/null +++ b/source/funkin/api/discord/DiscordClient.hx @@ -0,0 +1,207 @@ +package funkin.api.discord; + +#if FEATURE_DISCORD_RPC +import hxdiscord_rpc.Discord; +import hxdiscord_rpc.Types.DiscordButton; +import hxdiscord_rpc.Types.DiscordEventHandlers; +import hxdiscord_rpc.Types.DiscordRichPresence; +import hxdiscord_rpc.Types.DiscordUser; +import sys.thread.Thread; + +class DiscordClient +{ + static final CLIENT_ID:String = "816168432860790794"; + + public static var instance(get, never):DiscordClient; + static var _instance:Null = null; + + static function get_instance():DiscordClient + { + if (DiscordClient._instance == null) _instance = new DiscordClient(); + if (DiscordClient._instance == null) throw "Could not initialize singleton DiscordClient!"; + return DiscordClient._instance; + } + + var handlers:DiscordEventHandlers; + + private function new() + { + trace('[DISCORD] Initializing event handlers...'); + + handlers = DiscordEventHandlers.create(); + + handlers.ready = cpp.Function.fromStaticFunction(onReady); + handlers.disconnected = cpp.Function.fromStaticFunction(onDisconnected); + handlers.errored = cpp.Function.fromStaticFunction(onError); + } + + public function init():Void + { + trace('[DISCORD] Initializing connection...'); + + // Discord.initialize(CLIENT_ID, handlers, true, null); + Discord.Initialize(CLIENT_ID, cpp.RawPointer.addressOf(handlers), 1, null); + + createDaemon(); + } + + var daemon:Thread = null; + + function createDaemon():Void + { + daemon = Thread.create(doDaemonWork); + } + + function doDaemonWork():Void + { + while (true) + { + trace('[DISCORD] Performing client update...'); + + #if DISCORD_DISABLE_IO_THREAD + Discord.updateConnection(); + #end + + Discord.runCallbacks(); + Sys.sleep(2); + } + } + + public function shutdown():Void + { + trace('[DISCORD] Shutting down...'); + + Discord.shutdown(); + } + + public function setPresence(params:DiscordClientPresenceParams):Void + { + trace('[DISCORD] Updating presence... (${params})'); + + Discord.updatePresence(buildPresence(params)); + } + + function buildPresence(params:DiscordClientPresenceParams):DiscordRichPresence + { + var presence = DiscordRichPresence.create(); + + // Presence should always be playing the game. + presence.type = DiscordActivityType_Playing; + + // Text when hovering over the large image. We just leave this as the game name. + presence.largeImageText = "Friday Night Funkin'"; + + // State should be generally what the person is doing, like "In the Menus" or "Pico (Pico Mix) [Freeplay Hard]" + presence.state = cast(params.state, Null); + // Details should be what the person is specifically doing, including stuff like timestamps (maybe something like "03:24 elapsed"). + presence.details = cast(params.details, Null); + + // The large image displaying what the user is doing. + // This should probably be album art. + // IMPORTANT NOTE: This can be an asset key uploaded to Discord's developer panel OR any URL you like. + presence.largeImageKey = cast(params.largeImageKey, Null) ?? "album-volume1"; + + trace('[DISCORD] largeImageKey: ${presence.largeImageKey}'); + + // TODO: Make this use the song's album art. + // presence.largeImageKey = "icon"; + // presence.largeImageKey = "https://f4.bcbits.com/img/a0746694746_16.jpg"; + + // The small inset image for what the user is doing. + // This can be the opponent's health icon? + // NOTE: Like largeImageKey, this can be a URL, or an asset key. + presence.smallImageKey = cast(params.smallImageKey, Null); + + // NOTE: In previous versions, this showed as "Elapsed", but now shows as playtime and doesn't look good + // presence.startTimestamp = time - 10; + // presence.endTimestamp = time + 30; + + final button1:DiscordButton = DiscordButton.create(); + button1.label = "Play on Web"; + button1.url = Constants.URL_NEWGROUNDS; + presence.buttons[0] = button1; + + final button2:DiscordButton = DiscordButton.create(); + button2.label = "Download"; + button2.url = Constants.URL_ITCH; + presence.buttons[1] = button2; + + return presence; + } + + // TODO: WHAT THE FUCK get this pointer bullfuckery out of here + private static function onReady(request:cpp.RawConstPointer):Void + { + trace('[DISCORD] Client has connected!'); + + final username:String = request[0].username; + final globalName:String = request[0].username; + final discriminator:Int = Std.parseInt(request[0].discriminator); + + if (discriminator != 0) + { + trace('[DISCORD] User: ${username}#${discriminator} (${globalName})'); + } + else + { + trace('[DISCORD] User: @${username} (${globalName})'); + } + } + + private static function onDisconnected(errorCode:Int, message:cpp.ConstCharStar):Void + { + trace('[DISCORD] Client has disconnected! ($errorCode) "${cast (message, String)}"'); + } + + private static function onError(errorCode:Int, message:cpp.ConstCharStar):Void + { + trace('[DISCORD] Client has received an error! ($errorCode) "${cast (message, String)}"'); + } + + // public var type(get, set):DiscordActivityType; + // public var state(get, set):String; + // public var details(get, set):String; + // public var startTimestamp(get, set):Int; + // public var endTimestamp(get, set):Int; + // public var largeImageKey(get, set):String; + // public var largeImageText(get, set):String; + // public var smallImageKey(get, set):String; + // public var smallImageText(get, set):String; + // + // + // public var partyId(get, set) + // public var partySize(get, set) + // public var partyMax(get, set) + // public var partyPrivacy(get, set) + // + // public var buttons(get, set) + // + // public var matchSecret(get, set) + // public var joinSecret(get, set) + // public var spectateSecret(get, set) +} + +typedef DiscordClientPresenceParams = +{ + /** + * The first row of text below the game title. + */ + var state:String; + + /** + * The second row of text below the game title. + * Use `null` to display no text. + */ + var details:Null; + + /** + * A large, 4-row high image to the left of the content. + */ + var ?largeImageKey:String; + + /** + * A small, inset image to the bottom right of `largeImageKey`. + */ + var ?smallImageKey:String; +} +#end diff --git a/source/funkin/api/newgrounds/Events.hx b/source/funkin/api/newgrounds/Events.hx new file mode 100644 index 00000000..f54a607c --- /dev/null +++ b/source/funkin/api/newgrounds/Events.hx @@ -0,0 +1,106 @@ +package funkin.api.newgrounds; + +import io.newgrounds.Call.CallOutcome; +import io.newgrounds.NG; +import io.newgrounds.objects.events.Outcome; +import io.newgrounds.objects.events.Result; + +/** + * Use Newgrounds to perform basic telemetry. Ignore if not logged in to Newgrounds. + */ +@:nullSafety +class Events +{ + // Only allow letters, numbers, spaces, dashes, and underscores. + static final EVENT_NAME_REGEX:EReg = ~/[^a-zA-Z0-9 -_]/g; + + public static function logEvent(eventName:String):Void + { + #if (FEATURE_NEWGROUNDS && FEATURE_NEWGROUNDS_EVENTS) + if (NewgroundsClient.instance.isLoggedIn()) + { + var eventHandler = NG.core.calls.event; + + if (eventHandler != null) + { + var sanitizedEventName = EVENT_NAME_REGEX.replace(eventName, ''); + var outcomeHandler = onEventLogged.bind(sanitizedEventName, _); + eventHandler.logEvent(sanitizedEventName).addOutcomeHandler(outcomeHandler).send(); + } + } + #end + } + + static function onEventLogged(eventName:String, outcome:CallOutcome) + { + switch (outcome) + { + case SUCCESS(data): + trace('[NEWGROUNDS] Logged event: ${data.eventName}'); + case FAIL(outcome): + switch (outcome) + { + case HTTP(error): + trace('[NEWGROUNDS] HTTP error while logging event: ${error}'); + case RESPONSE(error): + trace('[NEWGROUNDS] Response error (${error.code}) while logging event: ${error.message}'); + case RESULT(error): + switch (error.code) + { + case 103: // Invalid custom event name + trace('[NEWGROUNDS] Invalid custom event name: ${eventName}'); + default: + trace('[NEWGROUNDS] Result error (${error.code}) while logging event: ${error.message}'); + } + } + } + } + + public static inline function logStartGame():Void + { + logEvent('start-game'); + } + + public static inline function logStartSong(songId:String, variation:String):Void + { + logEvent('start-song_${songId}-${variation}'); + } + + public static inline function logFailSong(songId:String, variation:String):Void + { + logEvent('blueballs_${songId}-${variation}'); + } + + public static inline function logCompleteSong(songId:String, variation:String):Void + { + logEvent('complete-song_${songId}-${variation}'); + } + + public static inline function logStartLevel(levelId:String):Void + { + logEvent('start-level_${levelId}'); + } + + public static inline function logCompleteLevel(levelId:String):Void + { + logEvent('complete-level_${levelId}'); + } + + public static inline function logEarnRank(rankName:String):Void + { + logEvent('earn-rank_${rankName}'); + } + + public static inline function logWatchCartoon():Void + { + logEvent('watch-cartoon'); + } + + // Note there is already a loadReferral call for the merch link + // and that gets logged as an event! + + public static inline function logOpenCredits():Void + { + logEvent('open-credits'); + } +} diff --git a/source/funkin/api/newgrounds/Leaderboards.hx b/source/funkin/api/newgrounds/Leaderboards.hx new file mode 100644 index 00000000..5c7e5e73 --- /dev/null +++ b/source/funkin/api/newgrounds/Leaderboards.hx @@ -0,0 +1,395 @@ +package funkin.api.newgrounds; + +#if FEATURE_NEWGROUNDS +import io.newgrounds.Call.CallError; +import io.newgrounds.objects.ScoreBoard as LeaderboardData; +import io.newgrounds.objects.events.Outcome; +import io.newgrounds.utils.ScoreBoardList; + +class Leaderboards +{ + public static function listLeaderboardData():Map + { + var leaderboardList:Null = NewgroundsClient.instance.leaderboards; + if (leaderboardList == null) + { + trace('[NEWGROUNDS] Not logged in, cannot fetch medal data!'); + return []; + } + else + { + var result:Map = []; + + for (leaderboardId in leaderboardList.keys()) + { + var leaderboardData = leaderboardList.get(leaderboardId); + if (leaderboardData == null) continue; + + // A little hacky, but it works. + result.set(cast leaderboardId, leaderboardData); + } + + return result; + } + } + + /** + * Submit a score to Newgrounds. + * @param leaderboard The leaderboard to submit to. + * @param score The score to submit. + * @param tag An optional tag to attach to the score. + */ + public static function submitScore(leaderboard:Leaderboard, score:Int, ?tag:String):Void + { + // Silently reject submissions for unknown leaderboards. + if (leaderboard == Leaderboard.Unknown) return; + + if (NewgroundsClient.instance.isLoggedIn()) + { + var leaderboardList = NewgroundsClient.instance.leaderboards; + if (leaderboardList == null) return; + + var leaderboardData:Null = leaderboardList.get(leaderboard.getId()); + if (leaderboardData != null) + { + leaderboardData.postScore(score, function(outcome:Outcome):Void { + switch (outcome) + { + case SUCCESS: + trace('[NEWGROUNDS] Submitted score!'); + case FAIL(error): + trace('[NEWGROUNDS] Failed to submit score!'); + trace(error); + } + }); + } + } + } + + /** + * Submit a score for a Story Level to Newgrounds. + */ + public static function submitLevelScore(levelId:String, difficultyId:String, score:Int):Void + { + var tag = '${difficultyId}'; + Leaderboards.submitScore(Leaderboard.getLeaderboardByLevel(levelId), score, tag); + } + + /** + * Submit a score for a song to Newgrounds. + */ + public static function submitSongScore(songId:String, difficultyId:String, score:Int):Void + { + var tag = '${difficultyId}'; + Leaderboards.submitScore(Leaderboard.getLeaderboardBySong(songId, difficultyId), score, tag); + } +} +#end + +enum abstract Leaderboard(Int) +{ + /** + * Represents an undefined or invalid leaderboard. + */ + var Unknown = -1; + + // + // STORY LEVELS + // + var StoryWeek1 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14239 #else 9615 #end; + var StoryWeek2 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14240 #else 9616 #end; + var StoryWeek3 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14242 #else 9767 #end; + var StoryWeek4 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14241 #else 9866 #end; + var StoryWeek5 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14243 #else 9956 #end; + var StoryWeek6 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14244 #else 9957 #end; + var StoryWeek7 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14245 #else 14682 #end; + var StoryWeekend1 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14237 #else 14683 #end; + + // + // SONGS + // + // Tutorial + var Tutorial = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14249 #else 14684 #end; + + // Week 1 + var Bopeebo = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14246 #else 9603 #end; + var BopeeboErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14685 #end; + var BopeeboPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14686 #end; + var Fresh = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14247 #else 9602 #end; + var FreshErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14687 #end; + var FreshPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14688 #end; + var DadBattle = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 14248 #else 9605 #end; + var DadBattleErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14689 #end; + var DadBattlePicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14690 #end; + + // Week 2 + var Spookeez = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9604 #end; + var SpookeezErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14691 #end; + var SpookeezPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14692 #end; + var South = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9606 #end; + var SouthErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14693 #end; + var SouthPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14694 #end; + var Monster = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14703 #end; + + // Week 3 + var Pico = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9766 #end; + var PicoErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14695 #end; + var PicoPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14696 #end; + var PhillyNice = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9769 #end; + var PhillyNiceErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14697 #end; + var PhillyNicePicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14698 #end; + var Blammed = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9768 #end; + var BlammedErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14704 #end; + var BlammedPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14705 #end; + + // Week 4 + var SatinPanties = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9869 #end; + var SatinPantiesErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14701 #end; + var High = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9867 #end; + var HighErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14699 #end; + var MILF = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9868 #end; + + // Week 5 + var Cocoa = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14706 #end; + var CocoaErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14707 #end; + var CocoaPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14708 #end; + var Eggnog = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14709 #end; + var EggnogErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14711 #end; + var EggnogPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14710 #end; + var WinterHorrorland = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14712 #end; + + // Week 6 + var Senpai = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9958 #end; + var SenpaiErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14713 #end; + var SenpaiPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14716 #end; + var Roses = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9959 #end; + var RosesErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14714 #end; + var RosesPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14717 #end; + var Thorns = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 9960 #end; + var ThornsErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14715 #end; + + // Week 7 + var Ugh = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14718 #end; + var UghErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14722 #end; + var UghPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14721 #end; + var Guns = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14719 #end; + var GunsPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14723 #end; + var Stress = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14720 #end; + var StressPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14724 #end; + + // Weekend 1 + var Darnell = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14725 #end; + var DarnellErect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14727 #end; + var DarnellBFMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14726 #end; + var LitUp = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14728 #end; + var LitUpBFMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14729 #end; + var TwoHot = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14730 #end; // Variable names can't start with a number! + var Blazin = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 1000000 #else 14731 #end; + + public function getId():Int + { + return this; + } + + /** + * Get the leaderboard for a given level and difficulty. + * @param levelId The ID for the story level. + * @param difficulty The current difficulty. + * @return The Leaderboard ID for the given level and difficulty. + */ + public static function getLeaderboardByLevel(levelId:String):Leaderboard + { + switch (levelId) + { + case "week1": + return StoryWeek1; + case "week2": + return StoryWeek2; + case "week3": + return StoryWeek3; + case "week4": + return StoryWeek4; + case "week5": + return StoryWeek5; + case "week6": + return StoryWeek6; + case "week7": + return StoryWeek7; + case "weekend1": + return StoryWeekend1; + default: + return Unknown; + } + } + + /** + * Get the leaderboard for a given level and difficulty. + * @param levelId The ID for the story level. + * @param difficulty The current difficulty, suffixed with the variation, like `easy-pico` or `nightmare`. + * @return The Leaderboard ID for the given level and difficulty. + */ + public static function getLeaderboardBySong(songId:String, difficulty:String):Leaderboard + { + var variation = Constants.DEFAULT_VARIATION; + var difficultyParts = difficulty.split('-'); + + if (difficultyParts.length >= 2) + { + variation = difficultyParts[difficultyParts.length - 1]; + } + else if (Constants.DEFAULT_DIFFICULTY_LIST_ERECT.contains(difficulty)) + { + variation = "erect"; + } + + switch (variation) + { + case "pico": + switch (songId) + { + case "bopeebo": + return BopeeboPicoMix; + case "fresh": + return FreshPicoMix; + case "dadbattle": + return DadBattlePicoMix; + case "spookeez": + return SpookeezPicoMix; + case "south": + return SouthPicoMix; + case "pico": + return PicoPicoMix; + case "philly-nice": + return PhillyNicePicoMix; + case "blammed": + return BlammedPicoMix; + case "cocoa": + return CocoaPicoMix; + case "eggnog": + return EggnogPicoMix; + case "senpai": + return SenpaiPicoMix; + case "roses": + return RosesPicoMix; + case "ugh": + return UghPicoMix; + case "guns": + return GunsPicoMix; + case "stress": + return StressPicoMix; + default: + return Unknown; + } + case "bf": + switch (songId) + { + case "darnell": + return DarnellBFMix; + case "litup": + return LitUpBFMix; + default: + return Unknown; + } + case "erect": + switch (songId) + { + case "bopeebo": + return BopeeboErect; + case "fresh": + return FreshErect; + case "dadbattle": + return DadBattleErect; + case "spookeez": + return SpookeezErect; + case "south": + return SouthErect; + case "pico": + return PicoErect; + case "philly-nice": + return PhillyNiceErect; + case "blammed": + return BlammedErect; + case "satin-panties": + return SatinPantiesErect; + case "high": + return HighErect; + case "cocoa": + return CocoaErect; + case "eggnog": + return EggnogErect; + case "senpai": + return SenpaiErect; + case "roses": + return RosesErect; + case "thorns": + return ThornsErect; + case "ugh": + return UghErect; + case "darnell": + return DarnellErect; + default: + return Unknown; + } + case "default": + switch (songId) + { + case "tutorial": + return Tutorial; + case "bopeebo": + return Bopeebo; + case "fresh": + return Fresh; + case "dadbattle": + return DadBattle; + case "spookeez": + return Spookeez; + case "south": + return South; + case "monster": + return Monster; + case "pico": + return Pico; + case "philly-nice": + return PhillyNice; + case "blammed": + return Blammed; + case "satin-panties": + return SatinPanties; + case "high": + return High; + case "milf": + return MILF; + case "cocoa": + return Cocoa; + case "eggnog": + return Eggnog; + case "winter-horrorland": + return WinterHorrorland; + case "senpai": + return Senpai; + case "roses": + return Roses; + case "thorns": + return Thorns; + case "ugh": + return Ugh; + case "guns": + return Guns; + case "stress": + return Stress; + case "darnell": + return Darnell; + case "litup": + return LitUp; + case "2hot": + return TwoHot; + case "blazin": + return Blazin; + default: + return Unknown; + } + default: + return Unknown; + } + } +} diff --git a/source/funkin/api/newgrounds/Medals.hx b/source/funkin/api/newgrounds/Medals.hx new file mode 100644 index 00000000..0559a004 --- /dev/null +++ b/source/funkin/api/newgrounds/Medals.hx @@ -0,0 +1,347 @@ +package funkin.api.newgrounds; + +#if FEATURE_NEWGROUNDS +import io.newgrounds.objects.Medal as MedalData; +import funkin.util.plugins.NewgroundsMedalPlugin; +import flixel.graphics.FlxGraphic; +import openfl.display.BitmapData; +import io.newgrounds.utils.MedalList; +import haxe.Json; + +class Medals +{ + public static var medalJSON:Array = []; + + public static function listMedalData():Map + { + var medalList = NewgroundsClient.instance.medals; + + if (medalList == null) + { + trace('[NEWGROUNDS] Not logged in, cannot fetch medal data!'); + return []; + } + else + { + // TODO: Why do I have to do this, @:nullSafety is fucked up + var result:Map = []; + + for (medalId in medalList.keys()) + { + var medalData = medalList.get(medalId); + if (medalData == null) continue; + + // A little hacky, but it works. + result.set(cast medalId, medalData); + } + + return result; + } + } + + public static function award(medal:Medal):Void + { + if (NewgroundsClient.instance.isLoggedIn()) + { + var medalList = NewgroundsClient.instance.medals; + @:privateAccess + if (medalList == null || medalList._map == null) return; + + var medalData:Null = medalList.get(medal.getId()); + @:privateAccess + if (medalData == null || medalData._data == null) + { + trace('[NEWGROUNDS] Could not retrieve data for medal: ${medal}'); + return; + } + else if (!medalData.unlocked) + { + trace('[NEWGROUNDS] Awarding medal (${medal}).'); + medalData.sendUnlock(); + + // Play the medal unlock animation, but only if the user has not already unlocked it. + #if html5 + // Web builds support parsing the bitmap data from the URL directly. + BitmapData.loadFromFile("https:" + medalData.icon).onComplete(function(bmp:BitmapData) { + var medalGraphic = FlxGraphic.fromBitmapData(bmp); + medalGraphic.persist = true; + NewgroundsMedalPlugin.play(medalData.value, medalData.name, medalGraphic); + }); + #else + if ((medalJSON?.length ?? 0) == 0) loadMedalJSON(); + // We have to use a medal image from the game files. We use a Base64 encoded image that NG spits out. + // TODO: Wait, don't they give us the medal icon? + + var localMedalData:Null = medalJSON.filter(function(jsonMedal) { + #if FEATURE_NEWGROUNDS_TESTING_MEDALS + return medal == jsonMedal.idTest; + #else + return medal == jsonMedal.id; + #end + })[0]; + + if (localMedalData == null) throw "You forgot to encode a Base64 image for medal: " + medal; + + var str:String = localMedalData.icon; + // Lime/OpenFL parses it without the included prefix stuff, so we remove it. + str = str.replace("data:image/png;base64,", "").trim(); + var bitmapData = BitmapData.fromBase64(str, "image/png"); + var medalGraphic:Null = null; + if (str != null) + { + medalGraphic = FlxGraphic.fromBitmapData(bitmapData); + medalGraphic.persist = true; + } + + NewgroundsMedalPlugin.play(medalData.value, medalData.name, medalGraphic); + #end + } + else + { + trace('[NEWGROUNDS] User already has medal (${medal}).'); + } + } + else + { + trace('[NEWGROUNDS] Attempted to award medal (${medal}), but not logged into Newgrounds.'); + } + } + + public static function loadMedalJSON():Void + { + var jsonPath = Paths.json('medals'); + + var jsonString = Assets.getText(jsonPath); + + var parser = new json2object.JsonParser>(); + parser.ignoreUnknownVariables = false; + trace('[NEWGROUNDS] Parsing local medal data...'); + parser.fromJson(jsonString, jsonPath); + + if (parser.errors.length > 0) + { + trace('[NEWGROUNDS] Failed to parse local medal data!'); + for (error in parser.errors) + funkin.data.DataError.printError(error); + medalJSON = []; + } + else + { + medalJSON = parser.value; + } + } + + public static function awardStoryLevel(id:String):Void + { + switch (id) + { + case 'tutorial': + Medals.award(Medal.StoryTutorial); + case 'week1': + Medals.award(Medal.StoryWeek1); + case 'week2': + Medals.award(Medal.StoryWeek2); + case 'week3': + Medals.award(Medal.StoryWeek3); + case 'week4': + Medals.award(Medal.StoryWeek4); + case 'week5': + Medals.award(Medal.StoryWeek5); + case 'week6': + Medals.award(Medal.StoryWeek6); + case 'week7': + Medals.award(Medal.StoryWeek7); + case 'weekend1': + Medals.award(Medal.StoryWeekend1); + default: + trace('[NEWGROUNDS] Story level does not have a medal! (${id}).'); + } + } +} +#end + +/** + * Represents NG Medal data in a JSON format. + */ +typedef MedalJSON = +{ + /** + * Medal ID to use for release builds + */ + var id:Int; + + /** + * Medal ID to use for testing builds + */ + var idTest:Int; + + /** + * The English name for the medal + */ + var name:String; + + /** + * The English name for the medal + */ + var icon:String; +} + +enum abstract Medal(Int) from Int to Int +{ + /** + * Represents an undefined or invalid medal. + */ + var Unknown = -1; + + /** + * I Said Funkin'! + * Start the game for the first time. + */ + var StartGame = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80894 #else 60960 #end; + + /** + * That's How You Do It! + * Beat Tutoria l in Story Mode (on any difficulty). + */ + var StoryTutorial = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80906 #else 83647 #end; + + /** + * More Like Daddy Queerest + * Beat Week 1 in Story Mode (on any difficulty). + */ + var StoryWeek1 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80899 #else 60961 #end; + + /** + * IT IS THE SPOOKY MONTH + * Beat Week 2 in Story Mode (on any difficulty). + */ + var StoryWeek2 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80900 #else 83648 #end; + + /** + * Pico Funny + * Beat Week 3 in Story Mode (on any difficulty). + */ + var StoryWeek3 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80901 #else 83649 #end; + + /** + * Mommy Must Murder + * Beat Week 4 in Story Mode (on any difficulty). + */ + var StoryWeek4 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80902 #else 83650 #end; + + /** + * Yule Tide Joy + * Beat Week 5 in Story Mode (on any difficulty). + */ + var StoryWeek5 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80903 #else 83651 #end; + + /** + * A Visual Novelty + * Beat Week 6 in Story Mode (on any difficulty). + */ + var StoryWeek6 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80904 #else 83652 #end; + + /** + * I <3 JohnnyUtah + * Beat Week 7 in Story Mode (on any difficulty). + */ + var StoryWeek7 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80905 #else 83653 #end; + + /** + * Yo, Really Think So? + * Beat Weekend 1 in Story Mode (on any difficulty). + */ + var StoryWeekend1 = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80907 #else 83654 #end; + + /** + * Stay Funky + * Press TAB in Freeplay and unlock your first character. + */ + var CharSelect = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 83633 #else 83655 #end; + + /** + * A Challenger Appears + * Beat any Pico remix in Freeplay (on any difficulty). + */ + var FreeplayPicoMix = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80910 #else 83656 #end; + + /** + * De-Stressing + * Beat Stress (Pico Mix) in Freeplay (on Normal difficulty or higher). + */ + var FreeplayStressPico = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 83629 #else 83657 #end; + + /** + * L + * Earn a Loss rating on any song (on any difficulty). + */ + var LossRating = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80915 #else 83658 #end; + + /** + * Getting Freaky + * Earn a Perfect rating on any song on Hard difficulty or higher. + * NOTE: Should also be awarded for a Gold Perfect because otherwise that would be annoying. + */ + var PerfectRatingHard = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80908 #else 83659 #end; + + /** + * You Should Drink More Water + * Earn a Golden Perfect rating on any song on Hard difficulty or higher. + */ + var GoldPerfectRatingHard = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80909 #else 83660 #end; + + /** + * Harder Than Hard + * Beat any Erect remix in Freeplay on Erect or Nightmare difficulty. + */ + var ErectDifficulty = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80911 #else 83661 #end; + + /** + * The Rap God + * Earn a Gold Perfect rating on any song on Nightmare difficulty. + */ + var GoldPerfectRatingNightmare = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80912 #else 83662 #end; + + /** + * Just like the game! + * Get freaky on a Friday. + * NOTE: You must beat at least one song on any difficulty. + */ + var FridayNight = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80913 #else 61034 #end; + + /** + * Nice + * Earn a rating of EXACTLY 69% (good luck). + */ + var Nice = #if FEATURE_NEWGROUNDS_TESTING_MEDALS 80914 #else 83646 #end; + + public function getId():Int + { + return this; + } + + public static function getMedalByStoryLevel(levelId:String):Medal + { + switch (levelId) + { + case "week1": + return StoryWeek1; + case "week2": + return StoryWeek2; + case "week3": + return StoryWeek3; + case "week4": + return StoryWeek4; + case "week5": + return StoryWeek5; + case "week6": + return StoryWeek6; + case "week7": + return StoryWeek7; + case "weekend1": + return StoryWeekend1; + default: + return Unknown; + } + } +} diff --git a/source/funkin/api/newgrounds/NewgroundsClient.hx b/source/funkin/api/newgrounds/NewgroundsClient.hx new file mode 100644 index 00000000..36ab563a --- /dev/null +++ b/source/funkin/api/newgrounds/NewgroundsClient.hx @@ -0,0 +1,334 @@ +package funkin.api.newgrounds; + +import funkin.save.Save; +import funkin.api.newgrounds.Medals.Medal; +#if FEATURE_NEWGROUNDS +import io.newgrounds.Call.CallError; +import io.newgrounds.NG; +import io.newgrounds.NGLite; +import io.newgrounds.NGLite.LoginOutcome; +import io.newgrounds.NGLite.LoginFail; +import io.newgrounds.objects.events.Outcome; +import io.newgrounds.utils.MedalList; +import io.newgrounds.utils.ScoreBoardList; +import io.newgrounds.objects.User; + +@:nullSafety +class NewgroundsClient +{ + public static var instance(get, never):NewgroundsClient; + static var _instance:Null = null; + + static function get_instance():NewgroundsClient + { + if (NewgroundsClient._instance == null) _instance = new NewgroundsClient(); + if (NewgroundsClient._instance == null) throw "Could not initialize singleton NewgroundsClient!"; + return NewgroundsClient._instance; + } + + public var user(get, never):Null; + public var medals(get, never):Null; + public var leaderboards(get, never):Null; + + private function new() + { + trace('[NEWGROUNDS] Initializing client...'); + + #if FEATURE_NEWGROUNDS_DEBUG + trace('[NEWGROUNDS] App ID: ${NewgroundsCredentials.APP_ID}'); + trace('[NEWGROUNDS] Encryption Key: ${NewgroundsCredentials.ENCRYPTION_KEY}'); + #end + + if (!hasValidCredentials()) + { + FlxG.log.warn("Tried to initialize Newgrounds client, but credentials are invalid!"); + return; + } + + var debug = #if FEATURE_NEWGROUNDS_DEBUG true #else false #end; + NG.create(NewgroundsCredentials.APP_ID, getSessionId(), debug, onLoginResolved); + NG.core.setupEncryption(NewgroundsCredentials.ENCRYPTION_KEY); + } + + public function init() + { + if (NG.core == null) return; + + trace('[NEWGROUNDS] Setting up connection...'); + + #if FEATURE_NEWGROUNDS_DEBUG + NG.core.verbose = true; + #end + + NG.core.onLogin.add(onLoginSuccessful); + + if (NG.core.attemptingLogin) + { + // Session ID was valid and we should be logged in soon. + trace('[NEWGROUNDS] Waiting for existing login!'); + } + else + { + #if FEATURE_NEWGROUNDS_AUTOLOGIN + // Attempt an automatic login. + trace('[NEWGROUNDS] Attempting new login immediately!'); + this.autoLogin(); + #else + trace('[NEWGROUNDS] Not logged in, you have to login manually!'); + #end + } + } + + /** + * Attempt to log into Newgrounds and create a session ID. + * @param onSuccess An optional callback for when the login is successful. + * @param onError An optional callback for when the login fails. + */ + public function login(?onSuccess:Void->Void, ?onError:Void->Void):Void + { + if (NG.core == null) + { + FlxG.log.warn("No Newgrounds client initialized! Are your credentials invalid?"); + return; + } + + if (onSuccess != null && onError != null) + { + NG.core.requestLogin(onLoginResolvedWithCallbacks.bind(_, onSuccess, onError)); + } + else + { + NG.core.requestLogin(onLoginResolved); + } + } + + public function autoLogin(?onSuccess:Void->Void, ?onError:Void->Void):Void + { + if (NG.core == null) + { + FlxG.log.warn("No Newgrounds client initialized! Are your credentials invalid?"); + return; + } + + var dummyPassport:String->Void = function(_) { + // just a dummy passport, so we don't create a popup + // otherwise `NG.core.requestLogin()` will automatically attempt to open a tab at the beginning of the game + // users should go to the Options Menu to login to NG + // we cancel the request, so we can call it later + NG.core.cancelLoginRequest(); + }; + + if (onSuccess != null && onError != null) + { + NG.core.requestLogin(onLoginResolvedWithCallbacks.bind(_, onSuccess, onError), dummyPassport); + } + else + { + NG.core.requestLogin(onLoginResolved, dummyPassport); + } + } + + /** + * Log out of Newgrounds and invalidate the current session. + * @param onSuccess An optional callback for when the logout is successful. + */ + public function logout(?onSuccess:Void->Void, ?onError:Void->Void):Void + { + if (NG.core != null) + { + if (onSuccess != null && onError != null) + { + NG.core.logOut(onLogoutResolvedWithCallbacks.bind(_, onSuccess, onError)); + } + else + { + NG.core.logOut(onLogoutResolved); + } + } + + Save.instance.ngSessionId = null; + } + + /** + * @return `true` if the user is logged in to Newgrounds. + */ + public function isLoggedIn():Bool + { + #if FEATURE_NEWGROUNDS + return NG.core != null && NG.core.loggedIn; + #else + return false; + #end + } + + /** + * @returns `false` if either the app ID or the encryption key is invalid. + */ + static function hasValidCredentials():Bool + { + return !(NewgroundsCredentials.APP_ID == null + || NewgroundsCredentials.APP_ID == "" + || NewgroundsCredentials.APP_ID.contains(" ") + || NewgroundsCredentials.ENCRYPTION_KEY == null + || NewgroundsCredentials.ENCRYPTION_KEY == "" + || NewgroundsCredentials.ENCRYPTION_KEY.contains(" ")); + } + + function onLoginResolved(outcome:LoginOutcome):Void + { + switch (outcome) + { + case SUCCESS: + onLoginSuccessful(); + case FAIL(result): + onLoginFailed(result); + } + } + + function onLoginResolvedWithCallbacks(outcome:LoginOutcome, onSuccess:Void->Void, onError:Void->Void):Void + { + onLoginResolved(outcome); + + switch (outcome) + { + case SUCCESS: + onSuccess(); + case FAIL(result): + onError(); + } + } + + function onLogoutResolved(outcome:Outcome):Void + { + switch (outcome) + { + case SUCCESS: + onLogoutSuccessful(); + case FAIL(result): + onLogoutFailed(result); + } + } + + function onLogoutResolvedWithCallbacks(outcome:Outcome, onSuccess:Void->Void, onError:Void->Void):Void + { + onLogoutResolved(outcome); + + switch (outcome) + { + case SUCCESS: + onSuccess(); + case FAIL(result): + onError(); + } + } + + function onLoginSuccessful():Void + { + if (NG.core == null) return; + + trace('[NEWGROUNDS] Login successful!'); + + // Persist the session ID. + Save.instance.ngSessionId = NG.core.sessionId; + + trace('[NEWGROUNDS] Submitting medal request...'); + NG.core.requestMedals(onFetchedMedals); + + trace('[NEWGROUNDS] Submitting leaderboard request...'); + NG.core.scoreBoards.loadList(onFetchedLeaderboards); + } + + function onLoginFailed(result:LoginFail):Void + { + switch (result) + { + case CANCELLED(type): + switch (type) + { + case PASSPORT: + trace('[NEWGROUNDS] Login cancelled by passport website.'); + case MANUAL: + trace('[NEWGROUNDS] Login cancelled by application.'); + default: + trace('[NEWGROUNDS] Login cancelled by unknown source.'); + } + case ERROR(error): + switch (error) + { + case HTTP(error): + trace('[NEWGROUNDS] Login failed due to HTTP error: ${error}'); + case RESPONSE(error): + trace('[NEWGROUNDS] Login failed due to response error: ${error.message} (${error.code})'); + case RESULT(error): + trace('[NEWGROUNDS] Login failed due to result error: ${error.message} (${error.code})'); + default: + trace('[NEWGROUNDS] Login failed due to unknown error: ${error}'); + } + default: + trace('[NEWGROUNDS] Login failed due to unknown reason.'); + } + } + + function onLogoutSuccessful():Void + { + trace('[NEWGROUNDS] Logout successful!'); + } + + function onLogoutFailed(result:CallError):Void + { + switch (result) + { + case HTTP(error): + trace('[NEWGROUNDS] Logout failed due to HTTP error: ${error}'); + case RESPONSE(error): + trace('[NEWGROUNDS] Logout failed due to response error: ${error.message} (${error.code})'); + case RESULT(error): + trace('[NEWGROUNDS] Logout failed due to result error: ${error.message} (${error.code})'); + default: + trace('[NEWGROUNDS] Logout failed due to unknown error: ${result}'); + } + } + + function onFetchedMedals(outcome:Outcome):Void + { + trace('[NEWGROUNDS] Fetched medals!'); + } + + function onFetchedLeaderboards(outcome:Outcome):Void + { + trace('[NEWGROUNDS] Fetched leaderboards!'); + + // trace(funkin.api.newgrounds.Leaderboards.listLeaderboardData()); + } + + function get_user():Null + { + if (NG.core == null || !this.isLoggedIn()) return null; + return NG.core.user; + } + + function get_medals():Null + { + if (NG.core == null || !this.isLoggedIn()) return null; + return NG.core.medals; + } + + function get_leaderboards():Null + { + if (NG.core == null || !this.isLoggedIn()) return null; + return NG.core.scoreBoards; + } + + static function getSessionId():Null + { + #if js + // We can fetch the session ID from the URL. + var result:Null = NGLite.getSessionId(); + if (result != null) return result; + #end + + // We have to fetch the session ID from the save file. + return Save.instance.ngSessionId; + } +} +#end diff --git a/source/funkin/api/newgrounds/Referral.hx b/source/funkin/api/newgrounds/Referral.hx new file mode 100644 index 00000000..7fa38a51 --- /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/audio/FlxStreamSound.hx b/source/funkin/audio/FlxStreamSound.hx new file mode 100644 index 00000000..62d3f6a1 --- /dev/null +++ b/source/funkin/audio/FlxStreamSound.hx @@ -0,0 +1,46 @@ +package funkin.audio; + +import flash.media.Sound; +import flixel.sound.FlxSound; +import flixel.system.FlxAssets.FlxSoundAsset; +import openfl.Assets; +#if (openfl >= "8.0.0") +import openfl.utils.AssetType; +#end + +/** + * a FlxSound that just overrides loadEmbedded to allow for "streamed" sounds to load with better performance! + */ +class FlxStreamSound extends FlxSound +{ + public function new() + { + super(); + } + + override public function loadEmbedded(EmbeddedSound:FlxSoundAsset, Looped:Bool = false, AutoDestroy:Bool = false, ?OnComplete:Void->Void):FlxSound + { + if (EmbeddedSound == null) return this; + + cleanup(true); + + if ((EmbeddedSound is Sound)) + { + _sound = EmbeddedSound; + } + else if ((EmbeddedSound is Class)) + { + _sound = Type.createInstance(EmbeddedSound, []); + } + else if ((EmbeddedSound is String)) + { + if (Assets.exists(EmbeddedSound, AssetType.SOUND) + || Assets.exists(EmbeddedSound, AssetType.MUSIC)) _sound = Assets.getMusic(EmbeddedSound); + else + FlxG.log.error('Could not find a Sound asset with an ID of \'$EmbeddedSound\'.'); + } + + // NOTE: can't pull ID3 info from embedded sound currently + return init(Looped, AutoDestroy, OnComplete); + } +} diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx new file mode 100644 index 00000000..fb1f8aa1 --- /dev/null +++ b/source/funkin/audio/FunkinSound.hx @@ -0,0 +1,699 @@ +package funkin.audio; + +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.math.FlxMath; +import flixel.sound.FlxSound; +import flixel.system.FlxAssets.FlxSoundAsset; +import flixel.tweens.FlxTween; +import flixel.util.FlxSignal.FlxTypedSignal; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.data.song.SongData.SongMusicData; +import funkin.data.song.SongRegistry; +import funkin.util.tools.ICloneable; +import funkin.util.flixel.sound.FlxPartialSound; +import funkin.Paths.PathsFunction; +import lime.app.Promise; +import lime.media.AudioSource; +import openfl.events.Event; +import openfl.media.Sound; +import openfl.media.SoundChannel; +import openfl.media.SoundMixer; + +#if (openfl >= "8.0.0") +#end + +/** + * A FlxSound which adds additional functionality: + * - Delayed playback via negative song position. + * - Easy functions for immediate playback and recycling. + */ +@:nullSafety +class FunkinSound extends FlxSound implements ICloneable +{ + static final MAX_VOLUME:Float = 1.0; + + /** + * An FlxSignal which is dispatched when the volume changes. + */ + public static var onVolumeChanged(get, never):FlxTypedSignalVoid>; + + static var _onVolumeChanged:NullVoid>> = null; + + static function get_onVolumeChanged():FlxTypedSignalVoid> + { + if (_onVolumeChanged == null) + { + _onVolumeChanged = new FlxTypedSignalVoid>(); + FlxG.sound.volumeHandler = function(volume:Float) { + _onVolumeChanged.dispatch(volume); + } + } + return _onVolumeChanged; + } + + /** + * Using `FunkinSound.load` will override a dead instance from here rather than creating a new one, if possible! + */ + static var pool(default, null):FlxTypedGroup = new FlxTypedGroup(); + + /** + * Calculate the current time of the sound. + * NOTE: You need to `add()` the sound to the scene for `update()` to increment the time. + */ + // + public var muted(default, set):Bool = false; + + function set_muted(value:Bool):Bool + { + if (value == muted) return value; + muted = value; + updateTransform(); + return value; + } + + override function set_volume(value:Float):Float + { + // Uncap the volume. + _volume = FlxMath.bound(value, 0.0, MAX_VOLUME); + updateTransform(); + return _volume; + } + + public var paused(get, never):Bool; + + function get_paused():Bool + { + return this._paused; + } + + public var isPlaying(get, never):Bool; + + function get_isPlaying():Bool + { + return this.playing || this._shouldPlay; + } + + /** + * Waveform data for this sound. + * This is lazily loaded, so it will be built the first time it is accessed. + */ + public var waveformData(get, never):WaveformData; + + var _waveformData:Null = null; + + function get_waveformData():WaveformData + { + if (_waveformData == null) + { + _waveformData = WaveformDataParser.interpretFlxSound(this); + if (_waveformData == null) throw 'Could not interpret waveform data!'; + } + return _waveformData; + } + + /** + * If true, the game will forcefully add this sound's channel to the list of playing sounds. + */ + public var important:Bool = false; + + /** + * Are we in a state where the song should play but time is negative? + */ + var _shouldPlay:Bool = false; + + /** + * For debug purposes. + */ + var _label:String = "unknown"; + + /** + * Whether we received a focus lost event. + */ + var _lostFocus:Bool = false; + + public function new() + { + super(); + } + + public override function update(elapsedSec:Float) + { + if (!playing && !_shouldPlay) return; + + if (_time < 0) + { + var elapsedMs = elapsedSec * Constants.MS_PER_SEC; + _time += elapsedMs; + if (_time >= 0) + { + super.play(); + _shouldPlay = false; + } + } + else + { + super.update(elapsedSec); + + @:privateAccess + { + if (important && _channel != null && !SoundMixer.__soundChannels.contains(_channel)) + { + SoundMixer.__soundChannels.push(_channel); + } + } + } + } + + public function togglePlayback():FunkinSound + { + if (playing) + { + pause(); + } + else + { + resume(); + } + return this; + } + + public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound + { + if (!exists) return this; + + if (forceRestart) + { + cleanup(false, true); + } + else if (playing) + { + return this; + } + + if (startTime < 0) + { + this.active = true; + this._shouldPlay = true; + this._time = startTime; + this.endTime = endTime; + return this; + } + else + { + if (_paused) + { + resume(); + } + else + { + startSound(startTime); + } + + this.endTime = endTime; + return this; + } + } + + public override function pause():FunkinSound + { + if (_shouldPlay) + { + // This sound will eventually play, but is still at a negative timestamp. + // Manually set the paused flag to ensure proper focus/unfocus behavior. + _shouldPlay = false; + _paused = true; + active = false; + } + else + { + super.pause(); + } + return this; + } + + /** + * Called when the user clicks to focus on the window. + */ + override function onFocus():Void + { + // Flixel can sometimes toss spurious `onFocus` events, e.g. if the Flixel debugger is toggled + // on and off. We only want to resume the sound if we actually lost focus, and if we weren't + // already paused before we lost focus. + if (_lostFocus && !_alreadyPaused) + { + // trace('Resuming audio (${this._label}) on focus!'); + resume(); + } + else + { + // trace('Not resuming audio (${this._label}) on focus!'); + } + _lostFocus = false; + } + + /** + * Called when the user tabs away from the window. + */ + override function onFocusLost():Void + { + // trace('Focus lost, pausing audio!'); + _lostFocus = true; + _alreadyPaused = _paused; + pause(); + } + + public override function resume():FunkinSound + { + if (this._time < 0) + { + // Sound with negative timestamp, restart the timer. + _shouldPlay = true; + _paused = false; + active = true; + } + else + { + super.resume(); + } + return this; + } + + /** + * Call after adjusting the volume to update the sound channel's settings. + */ + @:allow(flixel.sound.FlxSoundGroup) + override function updateTransform():Void + { + if (_transform != null) + { + _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end + (group != null ? group.volume : 1) * _volume * _volumeAdjust; + } + + if (_channel != null) + { + _channel.soundTransform = _transform; + } + } + + public function clone():FunkinSound + { + var sound:FunkinSound = new FunkinSound(); + + // Clone the sound by creating one with the same data buffer. + // Reusing the `Sound` object directly causes issues with playback. + @:privateAccess + sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer); + + // Call init to ensure the FlxSound is properly initialized. + sound.init(this.looped, this.autoDestroy, this.onComplete); + + // Oh yeah, the waveform data is the same too! + @:privateAccess + sound._waveformData = this._waveformData; + + return sound; + } + + /** + * Creates a new `FunkinSound` object and loads it as the current music track. + * + * @param key The key of the music you want to play. Music should be at `music//.ogg`. + * @param params A set of additional optional parameters. + * Data should be at `music//-metadata.json`. + * @return Whether the music was started. `false` if music was already playing or could not be started + */ + public static function playMusic(key:String, params:FunkinSoundPlayMusicParams):Bool + { + if (!(params.overrideExisting ?? false) && (FlxG.sound.music?.exists ?? false) && FlxG.sound.music.playing) return false; + + if (!(params.restartTrack ?? false) && FlxG.sound.music?.playing) + { + if (FlxG.sound.music != null && Std.isOfType(FlxG.sound.music, FunkinSound)) + { + var existingSound:FunkinSound = cast FlxG.sound.music; + // Stop here if we would play a matching music track. + if (existingSound._label == Paths.music('$key/$key')) + { + return false; + } + } + } + + if (FlxG.sound.music != null) + { + FlxG.sound.music.fadeTween?.cancel(); + FlxG.sound.music.stop(); + FlxG.sound.music.kill(); + } + + if (params?.mapTimeChanges ?? true) + { + var songMusicData:Null = SongRegistry.instance.parseMusicData(key); + // Will fall back and return null if the metadata doesn't exist or can't be parsed. + if (songMusicData != null) + { + Conductor.instance.mapTimeChanges(songMusicData.timeChanges); + + if (songMusicData.looped != null && params.loop == null) params.loop = songMusicData.looped; + } + else + { + FlxG.log.warn('Tried and failed to find music metadata for $key'); + } + } + var pathsFunction = params.pathsFunction ?? MUSIC; + var suffix = params.suffix ?? ''; + var pathToUse = switch (pathsFunction) + { + case MUSIC: Paths.music('$key/$key'); + case INST: Paths.inst('$key', suffix); + default: Paths.music('$key/$key'); + } + + var shouldLoadPartial = params.partialParams?.loadPartial ?? false; + + // even if we arent' trying to partial load a song, we want to error out any songs in progress, + // so we don't get overlapping music if someone were to load a new song while a partial one is loading! + + emptyPartialQueue(); + + if (shouldLoadPartial) + { + var music = FunkinSound.loadPartial(pathToUse, params.partialParams?.start ?? 0.0, params.partialParams?.end ?? 1.0, params?.startingVolume ?? 1.0, + params.loop ?? true, false, false, params.onComplete); + + if (music != null) + { + partialQueue.push(music); + + @:nullSafety(Off) + music.future.onComplete(function(partialMusic:Null) { + FlxG.sound.music = partialMusic; + FlxG.sound.list.remove(FlxG.sound.music); + + if (FlxG.sound.music != null && params.onLoad != null) params.onLoad(); + }); + + return true; + } + else + { + return false; + } + } + else + { + var music = FunkinSound.load(pathToUse, params?.startingVolume ?? 1.0, params.loop ?? true, false, true, params.persist ?? false, params.onComplete); + if (music != null) + { + FlxG.sound.music = music; + + // Prevent repeat update() and onFocus() calls. + FlxG.sound.list.remove(FlxG.sound.music); + + if (FlxG.sound.music != null && params.onLoad != null) params.onLoad(); + + return true; + } + else + { + return false; + } + } + } + + public static function emptyPartialQueue():Void + { + while (partialQueue.length > 0) + { + @:nullSafety(Off) + partialQueue.pop().error("Cancel loading partial sound"); + } + } + + static var partialQueue:Array>> = []; + + /** + * Creates a new `FunkinSound` object synchronously. + * + * @param embeddedSound The embedded sound resource you want to play. To stream, use the optional URL parameter instead. + * @param volume How loud to play it (0 to 1). + * @param looped Whether to loop this sound. + * @param group The group to add this sound to. + * @param autoDestroy Whether to destroy this sound when it finishes playing. + * Leave this value set to `false` if you want to re-use this `FunkinSound` instance. + * @param autoPlay Whether to play the sound immediately or wait for a `play()` call. + * @param persist Whether to keep this `FunkinSound` between states, or destroy it. + * @param onComplete Called when the sound finished playing. + * @param onLoad Called when the sound finished loading. Called immediately for succesfully loaded embedded sounds. + * @param important If `true`, the sound channel will forcefully be added onto the channel array, even if full. Use sparingly! + * @return A `FunkinSound` object, or `null` if the sound could not be loaded. + */ + public static function load(embeddedSound:FlxSoundAsset, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false, autoPlay:Bool = false, + persist:Bool = false, ?onComplete:Void->Void, ?onLoad:Void->Void, important:Bool = false):Null + { + @:privateAccess + if (SoundMixer.__soundChannels.length >= SoundMixer.MAX_ACTIVE_CHANNELS && !important) + { + FlxG.log.error('FunkinSound could not play sound, channels exhausted! Found ${SoundMixer.__soundChannels.length} active sound channels.'); + return null; + } + + var sound:FunkinSound = pool.recycle(construct); + + // Load the sound. + // Sets `exists = true` as a side effect. + sound.loadEmbedded(embeddedSound, looped, autoDestroy, onComplete); + + if (embeddedSound is String) + { + sound._label = embeddedSound; + } + else + { + sound._label = 'unknown'; + } + + if (autoPlay) sound.play(); + sound.volume = volume; + sound.group = FlxG.sound.defaultSoundGroup; + sound.persist = persist; + sound.important = important; + + // Make sure to add the sound to the list. + // If it's already in, it won't get re-added. + // If it's not in the list (it gets removed by FunkinSound.playMusic()), + // it will get re-added (then if this was called by playMusic(), removed again) + FlxG.sound.list.add(sound); + + // Call onLoad() because the sound already loaded + if (onLoad != null && sound._sound != null) onLoad(); + + return sound; + } + + /** + * Will load a section of a sound file, useful for Freeplay where we don't want to load all the bytes of a song + * @param path The path to the sound file + * @param start The start time of the sound file + * @param end The end time of the sound file + * @param volume Volume to start at + * @param looped Whether the sound file should loop + * @param autoDestroy Whether the sound file should be destroyed after it finishes playing + * @param autoPlay Whether the sound file should play immediately + * @param onComplete Callback when the sound finishes playing + * @param onLoad Callback when the sound finishes loading + * @return A FunkinSound object + */ + public static function loadPartial(path:String, start:Float = 0, end:Float = 1, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false, + autoPlay:Bool = true, ?onComplete:Void->Void, ?onLoad:Void->Void):Promise> + { + var promise:lime.app.Promise> = new lime.app.Promise>(); + + // split the path and get only after first : + // we are bypassing the openfl/lime asset library fuss on web only + #if web + path = Paths.stripLibrary(path); + #end + + var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end); + + if (soundRequest == null) + { + promise.complete(null); + } + else + { + promise.future.onError(function(e) { + soundRequest.error("Sound loading was errored or cancelled"); + }); + + soundRequest.future.onComplete(function(partialSound) { + var snd = FunkinSound.load(partialSound, volume, looped, autoDestroy, autoPlay, false, onComplete, onLoad); + promise.complete(snd); + }); + } + + return promise; + } + + @:nullSafety(Off) + public override function destroy():Void + { + // trace('[FunkinSound] Destroying sound "${this._label}"'); + super.destroy(); + if (fadeTween != null) + { + fadeTween.cancel(); + fadeTween = null; + } + FlxTween.cancelTweensOf(this); + this._label = 'unknown'; + } + + @:access(openfl.media.Sound) + @:access(openfl.media.SoundChannel) + @:access(openfl.media.SoundMixer) + override function startSound(startTime:Float) + { + if (!important) + { + super.startSound(startTime); + return; + } + + _time = startTime; + _paused = false; + + if (_sound == null) return; + + // Create a channel manually if the sound is considered important. + var pan:Float = FlxMath.bound(SoundMixer.__soundTransform.pan + _transform.pan, -1, 1); + var volume:Float = FlxMath.bound(SoundMixer.__soundTransform.volume * _transform.volume, 0, MAX_VOLUME); + + var audioSource:AudioSource = new AudioSource(_sound.__buffer); + audioSource.offset = Std.int(startTime); + audioSource.gain = volume; + + var position:lime.math.Vector4 = audioSource.position; + position.x = pan; + position.z = -1 * Math.sqrt(1 - Math.pow(pan, 2)); + audioSource.position = position; + + _channel = new SoundChannel(_sound, audioSource, _transform); + _channel.addEventListener(Event.SOUND_COMPLETE, stopped); + pitch = _pitch; + active = true; + } + + /** + * Play a sound effect once, then destroy it. + * @param key + * @param volume + * @return A `FunkinSound` object, or `null` if the sound could not be loaded. + */ + public static function playOnce(key:String, volume:Float = 1.0, ?onComplete:Void->Void, ?onLoad:Void->Void, important:Bool = false):Null + { + var result:Null = FunkinSound.load(key, volume, false, true, true, false, onComplete, onLoad, important); + return result; + } + + /** + * Stop all sounds in the pool and allow them to be recycled. + */ + public static function stopAllAudio(musicToo:Bool = false, persistToo:Bool = false):Void + { + for (sound in pool) + { + if (sound == null) continue; + if (!persistToo && sound.persist) continue; + if (!musicToo && sound == FlxG.sound.music) continue; + sound.destroy(); + } + } + + static function construct():FunkinSound + { + var sound:FunkinSound = new FunkinSound(); + + pool.add(sound); + FlxG.sound.list.add(sound); + + return sound; + } + + /** + * Produces a string representation suitable for debugging. + */ + public override function toString():String + { + return 'FunkinSound(${this._label})'; + } +} + +/** + * Additional parameters for `FunkinSound.playMusic()` + */ +typedef FunkinSoundPlayMusicParams = +{ + /** + * The volume you want the music to start at. + * @default `1.0` + */ + var ?startingVolume:Float; + + /** + * The suffix of the music file to play. Usually for "-erect" tracks when loading an INST file + * @default `` + */ + var ?suffix:String; + + /** + * Whether to override music if a different track is already playing. + * @default `false` + */ + var ?overrideExisting:Bool; + + /** + * Whether to override music if the same track is already playing. + * @default `false` + */ + var ?restartTrack:Bool; + + /** + * Whether the music should loop or play once. + * @default `true` + */ + var ?loop:Bool; + + /** + * Whether to check for `SongMusicData` to update the Conductor with. + * @default `true` + */ + var ?mapTimeChanges:Bool; + + /** + * Which Paths function to use to load a song + * @default `MUSIC` + */ + var ?pathsFunction:PathsFunction; + + var ?partialParams:PartialSoundParams; + + /** + * Whether the sound should be destroyed on state switches + */ + var ?persist:Bool; + + var ?onComplete:Void->Void; + var ?onLoad:Void->Void; +} + +typedef PartialSoundParams = +{ + var loadPartial:Bool; + var start:Float; + var end:Float; +} diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx new file mode 100644 index 00000000..e58d266d --- /dev/null +++ b/source/funkin/audio/SoundGroup.hx @@ -0,0 +1,274 @@ +package funkin.audio; + +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.tweens.FlxTween; + +/** + * A group of FunkinSounds that are all synced together. + * Unlike FlxSoundGroup, you can also control their time and pitch. + */ +class SoundGroup extends FlxTypedGroup +{ + public var time(get, set):Float; + + public var volume(get, set):Float; + + public var muted(get, set):Bool; + + public var pitch(get, set):Float; + + public var playing(get, never):Bool; + + public function new() + { + super(); + } + + @:deprecated("Create sound files and call add() instead") + public static function build(song:String, ?files:Array = null):SoundGroup + { + var result = new SoundGroup(); + + if (files == null) + { + // Add an empty voice. + result.add(new FunkinSound()); + return result; + } + + for (sndFile in files) + { + var snd:FunkinSound = FunkinSound.load(Paths.voices(song, '$sndFile')); + result.add(snd); // adds it to main group for other shit + } + + return result; + } + + /** + * Finds the largest deviation from the desired time inside this SoundGroup. + * + * @param targetTime The time to check against. + * If none is provided, it checks the time of all members against the first member of this SoundGroup. + * @return The largest deviation from the target time found. + */ + public function checkSyncError(?targetTime:Float):Float + { + var error:Float = 0; + + forEachAlive(function(snd) { + if (targetTime == null) targetTime = snd.time; + else + { + var diff:Float = snd.time - targetTime; + if (Math.abs(diff) > Math.abs(error)) error = diff; + } + }); + return error; + } + + /** + * Add a sound to the group. + */ + public override function add(sound:FunkinSound):FunkinSound + { + var result:FunkinSound = super.add(sound); + + if (result == null) return null; + + // We have to play, then pause the sound to set the time, + // else the sound will restart immediately when played. + // TODO: Past me experienced that issue but present me didn't? Investigate. + // result.play(true, 0.0); + // result.pause(); + result.time = this.time; + + result.onComplete = function() { + this.onComplete(); + } + + // Apply parameters to the new sound. + result.pitch = this.pitch; + result.volume = this.volume; + + return result; + } + + public dynamic function onComplete():Void {} + + /** + * Pause all the sounds in the group. + */ + public function pause() + { + forEachAlive(function(sound:FunkinSound) { + sound.pause(); + }); + } + + /** + * Play all the sounds in the group. + */ + public function play(forceRestart:Bool = false, startTime:Float = 0.0, ?endTime:Float) + { + forEachAlive(function(sound:FunkinSound) { + if (sound.length < startTime) + { + // trace('Queuing sound (${sound.toString()} past its length! Skipping...)'); + return; + } + sound.play(forceRestart, startTime, endTime); + }); + } + + /** + * Resume all the sounds in the group. + */ + public function resume() + { + forEachAlive(function(sound:FunkinSound) { + sound.resume(); + }); + } + + /** + * Fade in all the sounds in the group. + */ + public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void + { + forEachAlive(function(sound:FunkinSound) { + sound.fadeIn(duration, from, to, onComplete); + }); + } + + /** + * Fade out all the sounds in the group. + */ + public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void + { + forEachAlive(function(sound:FunkinSound) { + sound.fadeOut(duration, to, onComplete); + }); + } + + /** + * Stop all the sounds in the group. + */ + public function stop():Void + { + if (members != null) + { + forEachAlive(function(sound:FunkinSound) { + sound.stop(); + }); + } + } + + public override function destroy():Void + { + stop(); + super.destroy(); + } + + /** + * Remove all sounds from the group. + */ + public override function clear():Void + { + this.stop(); + + super.clear(); + } + + function get_time():Float + { + if (getFirstAlive() != null) + { + return getFirstAlive().time; + } + else + { + return 0; + } + } + + function set_time(time:Float):Float + { + forEachAlive(function(snd:FunkinSound) { + // account for different offsets per sound? + snd.time = time; + }); + + return time; + } + + function get_playing():Bool + { + if (getFirstAlive() != null) + { + return getFirstAlive().playing; + } + else + { + return false; + } + } + + function get_volume():Float + { + if (getFirstAlive() != null) + { + return getFirstAlive().volume; + } + else + { + return 1; + } + } + + // in PlayState, adjust the code so that it only mutes the player1 vocal tracks? + function set_volume(volume:Float):Float + { + forEachAlive(function(snd:FunkinSound) { + snd.volume = volume; + }); + + return volume; + } + + function get_muted():Bool + { + if (getFirstAlive() != null) return getFirstAlive().muted; + else + return false; + } + + function set_muted(muted:Bool):Bool + { + forEachAlive(function(snd:FunkinSound) { + snd.muted = muted; + }); + + return muted; + } + + function get_pitch():Float + { + #if FLX_PITCH + if (getFirstAlive() != null) return getFirstAlive().pitch; + else + #end + return 1; + } + + function set_pitch(val:Float):Float + { + #if FLX_PITCH + trace('Setting audio pitch to ' + val); + forEachAlive(function(snd:FunkinSound) { + snd.pitch = val; + }); + #end + return val; + } +} diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx new file mode 100644 index 00000000..9a1e0e0c --- /dev/null +++ b/source/funkin/audio/VoicesGroup.hx @@ -0,0 +1,174 @@ +package funkin.audio; + +import flixel.group.FlxGroup.FlxTypedGroup; +import funkin.audio.waveform.WaveformData; + +class VoicesGroup extends SoundGroup +{ + var playerVoices:FlxTypedGroup; + var opponentVoices:FlxTypedGroup; + + /** + * Control the volume of only the sounds in the player group. + */ + public var playerVolume(default, set):Float = 1.0; + + /** + * Control the volume of only the sounds in the opponent group. + */ + public var opponentVolume(default, set):Float = 1.0; + + /** + * Set the time offset for the player's vocal track. + */ + public var playerVoicesOffset(default, set):Float = 0.0; + + /** + * Set the time offset for the opponent's vocal track. + */ + public var opponentVoicesOffset(default, set):Float = 0.0; + + public function new() + { + super(); + playerVoices = new FlxTypedGroup(); + opponentVoices = new FlxTypedGroup(); + } + + /** + * Add a voice to the player group. + */ + public function addPlayerVoice(sound:FunkinSound):Void + { + super.add(sound); + playerVoices.add(sound); + } + + function set_playerVolume(volume:Float):Float + { + playerVoices.forEachAlive(function(voice:FunkinSound) { + voice.volume = volume; + }); + return playerVolume = volume; + } + + override function set_time(time:Float):Float + { + forEachAlive(function(snd) { + // account for different offsets per sound? + snd.time = time; + }); + + playerVoices.forEachAlive(function(voice:FunkinSound) { + voice.time -= playerVoicesOffset; + }); + opponentVoices.forEachAlive(function(voice:FunkinSound) { + voice.time -= opponentVoicesOffset; + }); + + return time; + } + + function set_playerVoicesOffset(offset:Float):Float + { + playerVoices.forEachAlive(function(voice:FunkinSound) { + voice.time += playerVoicesOffset; + voice.time -= offset; + }); + return playerVoicesOffset = offset; + } + + function set_opponentVoicesOffset(offset:Float):Float + { + opponentVoices.forEachAlive(function(voice:FunkinSound) { + voice.time += opponentVoicesOffset; + voice.time -= offset; + }); + return opponentVoicesOffset = offset; + } + + /** + * Add a voice to the opponent group. + */ + public function addOpponentVoice(sound:FunkinSound):Void + { + super.add(sound); + opponentVoices.add(sound); + } + + function set_opponentVolume(volume:Float):Float + { + opponentVoices.forEachAlive(function(voice:FunkinSound) { + voice.volume = volume; + }); + return opponentVolume = volume; + } + + public function getPlayerVoice(index:Int = 0):Null + { + return playerVoices.members[index]; + } + + public function getOpponentVoice(index:Int = 0):Null + { + return opponentVoices.members[index]; + } + + public function getPlayerVoiceWaveform():Null + { + if (playerVoices.members.length == 0) return null; + + return playerVoices.members[0].waveformData; + } + + public function getOpponentVoiceWaveform():Null + { + if (opponentVoices.members.length == 0) return null; + + return opponentVoices.members[0].waveformData; + } + + /** + * The length of the player's vocal track, in milliseconds. + */ + public function getPlayerVoiceLength():Float + { + if (playerVoices.members.length == 0) return 0.0; + + return playerVoices.members[0].length; + } + + /** + * The length of the opponent's vocal track, in milliseconds. + */ + public function getOpponentVoiceLength():Float + { + if (opponentVoices.members.length == 0) return 0.0; + + return opponentVoices.members[0].length; + } + + public override function clear():Void + { + playerVoices.clear(); + opponentVoices.clear(); + super.clear(); + } + + public override function destroy():Void + { + if (playerVoices != null) + { + playerVoices.destroy(); + playerVoices = null; + } + + if (opponentVoices != null) + { + opponentVoices.destroy(); + opponentVoices = null; + } + + super.destroy(); + } +} diff --git a/source/funkin/audio/visualize/ABot.hx b/source/funkin/audio/visualize/ABot.hx new file mode 100644 index 00000000..0b2ec619 --- /dev/null +++ b/source/funkin/audio/visualize/ABot.hx @@ -0,0 +1,12 @@ +package funkin.audio.visualize; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; + +class ABot extends FlxTypedSpriteGroup +{ + public function new() + { + super(); + } +} diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx new file mode 100644 index 00000000..5d8633e0 --- /dev/null +++ b/source/funkin/audio/visualize/ABotVis.hx @@ -0,0 +1,151 @@ +package funkin.audio.visualize; + +import funkin.graphics.FunkinSprite; +import flixel.FlxSprite; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.sound.FlxSound; +import funkin.vis.dsp.SpectralAnalyzer; + +using Lambda; + +@:nullSafety +class ABotVis extends FlxTypedSpriteGroup +{ + // public var vis:VisShit; + var analyzer:Null = null; + + var volumes:Array = []; + + public var snd:Null = null; + + static final BAR_COUNT:Int = 7; + + // TODO: Make the sprites easier to soft code. + public function new(snd:FlxSound, pixel:Bool) + { + super(); + + this.snd = snd; + + var visCount = pixel ? (BAR_COUNT + 1) : (BAR_COUNT + 1); + var visScale = pixel ? 6 : 1; + + var visFrms:FlxAtlasFrames = Paths.getSparrowAtlas(pixel ? 'characters/abotPixel/aBotVizPixel' : 'characters/abot/aBotViz'); + + // these are the differences in X position, from left to right + var positionX:Array = pixel ? [0, 7 * 6, 8 * 6, 9 * 6, 10 * 6, 6 * 6, 7 * 6] : [0, 59, 56, 66, 54, 52, 51]; + var positionY:Array = pixel ? [0, -2 * 6, -1 * 6, 0, 0, 1 * 6, 2 * 6] : [0, -8, -3.5, -0.4, 0.5, 4.7, 7]; + + for (index in 1...visCount) + { + // pushes initial value + volumes.push(0.0); + + // Sum the offsets up to the current index + var sum = function(num:Float, total:Float) return total += num; + var posX:Float = positionX.slice(0, index).fold(sum, 0); + var posY:Float = positionY.slice(0, index).fold(sum, 0); + + var viz:FunkinSprite = new FunkinSprite(posX, posY); + viz.frames = visFrms; + viz.antialiasing = pixel ? false : true; + viz.scale.set(visScale, visScale); + add(viz); + + var visStr = 'viz'; + viz.animation.addByPrefix('VIZ', '$visStr${index}0', 0); + viz.animation.play('VIZ', false, false, 1); + } + } + + public function initAnalyzer():Void + { + if (snd == null) return; + + @:privateAccess + analyzer = new SpectralAnalyzer(snd._channel.__audioSource, BAR_COUNT, 0.1, 40); + // A-Bot tuning... + analyzer.minDb = -65; + analyzer.maxDb = -25; + analyzer.maxFreq = 22000; + // we use a very low minFreq since some songs use low low subbass like a boss + analyzer.minFreq = 10; + + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + analyzer.fftN = 256; + #end + + // analyzer.maxDb = -35; + // analyzer.fftN = 2048; + } + + public function dumpSound():Void + { + snd = null; + analyzer = null; + } + + var visTimer:Float = -1; + var visTimeMax:Float = 1 / 30; + + override function update(elapsed:Float) + { + super.update(elapsed); + } + + static inline function min(x:Int, y:Int):Int + { + return x > y ? y : x; + } + + override function draw() + { + super.draw(); + drawFFT(); + } + + /** + * TJW funkin.vis based visualizer! updateFFT() is the old nasty shit that dont worky! + */ + function drawFFT():Void + { + var levels = (analyzer != null) ? analyzer.getLevels() : getDefaultLevels(); + + for (i in 0...min(group.members.length, levels.length)) + { + var animFrame:Int = Math.round(levels[i].value * 6); + + // don't display if we're at 0 volume from the level + group.members[i].visible = animFrame > 0; + + // decrement our animFrame, so we can get a value from 0-5 for animation frames + animFrame -= 1; + + animFrame = Math.floor(Math.min(5, animFrame)); + animFrame = Math.floor(Math.max(0, animFrame)); + + animFrame = Std.int(Math.abs(animFrame - 5)); // shitty dumbass flip, cuz dave got da shit backwards lol! + + group.members[i].animation.curAnim.curFrame = animFrame; + } + } + + /** + * Explicitly define the default levels to draw when the analyzer is not available. + * @return Array + */ + static function getDefaultLevels():Array + { + var result:Array = []; + + for (i in 0...BAR_COUNT) + { + result.push({value: 0, peak: 0.0}); + } + + return result; + } +} diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx new file mode 100644 index 00000000..cf255052 --- /dev/null +++ b/source/funkin/audio/visualize/PolygonSpectogram.hx @@ -0,0 +1,158 @@ +package funkin.audio.visualize; + +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.util.FlxColor; +import funkin.audio.visualize.VisShit.CurAudioInfo; +import funkin.graphics.rendering.MeshRender; +import lime.utils.Int16Array; + +class PolygonSpectogram extends MeshRender +{ + var sampleRate:Int; + + public var vis:VisShit; + public var visType:VISTYPE = UPDATED; + public var daHeight:Float = FlxG.height; + public var realtimeVisLenght:Float = 0.2; + public var realtimeStartOffset:Float = 0; + + var numSamples:Int = 0; + var setBuffer:Bool = false; + + public var audioData:Int16Array; + public var detail:Float = 1; + + public var thickness:Float = 2; + public var waveAmplitude:Int = 100; + + public function new(?daSound:FlxSound, ?col:FlxColor = FlxColor.WHITE, ?height:Float = 720, ?detail:Float = 1) + { + super(0, 0, col); + + if (daSound != null) setSound(daSound); + + if (height != null) this.daHeight = height; + + this.detail = detail; + + // col not in yet + } + + public function setSound(daSound:FlxSound) + { + vis = new VisShit(daSound); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + switch (visType) + { + case UPDATED: + realtimeVis(); + default: + } + } + + var prevAudioData:Int16Array; + + /** + * Generates and draws a section of the audio data to a visual waveform + * @param start start of the song in milliseconds + * @param seconds how long to generate (also in milliseconds) + */ + public function generateSection(start:Float = 0, seconds:Float = 1):Void + { + checkAndSetBuffer(); + + if (setBuffer) + { + clear(); + + start = Math.max(start, 0); + + // gets how many samples to generate + var samplesToGen:Int = Std.int(sampleRate * seconds); + + if (samplesToGen == 0) return; + // gets which sample to start at + var startSample:Int = Std.int(FlxMath.remapToRange(start, 0, vis.snd.length, 0, numSamples)); + + // Check if startSample and samplesToGen are within the bounds of the audioData array + if (startSample < 0 || startSample >= numSamples) return; + if (samplesToGen <= 0 || startSample + samplesToGen > numSamples) samplesToGen = numSamples - startSample; + + var prevPoint:FlxPoint = new FlxPoint(); + + var funnyPixels:Int = Std.int(daHeight * detail); // sorta redundant but just need it for different var... + + if (prevAudioData == audioData.subarray(startSample, startSample + samplesToGen)) return; // optimize / finish funciton here, no need to re-render + + prevAudioData = audioData.subarray(startSample, samplesToGen); + + for (i in 0...funnyPixels) + { + var sampleApprox:Int = Std.int(FlxMath.remapToRange(i, 0, funnyPixels, startSample, startSample + samplesToGen)); + var curAud:CurAudioInfo = VisShit.getCurAud(audioData, sampleApprox); + + var coolPoint:FlxPoint = new FlxPoint(); + coolPoint.x = (curAud.balanced * waveAmplitude); + coolPoint.y = (i / funnyPixels * daHeight); + + build_quad(prevPoint.x, prevPoint.y, prevPoint.x + + thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x + + thickness, coolPoint.y + + thickness); + + prevPoint.x = coolPoint.x; + prevPoint.y = coolPoint.y; + } + } + } + + var curTime:Float = 0; + + function realtimeVis():Void + { + if (vis.snd != null) + { + if (curTime != vis.snd.time) + { + // trace("DOIN SHIT" + FlxG.random.int(0, 200)); + + if (vis.snd.playing) curTime = vis.snd.time; + else + { + if (Math.abs(curTime - vis.snd.time) > 10) curTime = FlxMath.lerp(curTime, vis.snd.time, 0.5); + } + + curTime = vis.snd.time; + + if (vis.snd.time < vis.snd.length - realtimeVisLenght) generateSection(vis.snd.time + realtimeStartOffset, realtimeVisLenght); + } + } + } + + public function checkAndSetBuffer() + { + vis.checkAndSetBuffer(); + + if (vis.setBuffer) + { + audioData = vis.audioData; + sampleRate = vis.sampleRate; + setBuffer = vis.setBuffer; + numSamples = Std.int(audioData.length / 2); + } + } +} + +enum VISTYPE +{ + STATIC; + UPDATED; + FREQUENCIES; +} diff --git a/source/funkin/audio/visualize/PolygonVisGroup.hx b/source/funkin/audio/visualize/PolygonVisGroup.hx new file mode 100644 index 00000000..bff84579 --- /dev/null +++ b/source/funkin/audio/visualize/PolygonVisGroup.hx @@ -0,0 +1,106 @@ +package funkin.audio.visualize; + +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.sound.FlxSound; + +class PolygonVisGroup extends FlxTypedGroup +{ + public var playerVis:PolygonSpectogram; + public var opponentVis:PolygonSpectogram; + public var instVis:PolygonSpectogram; + + public function new() + { + super(); + playerVis = new PolygonSpectogram(); + opponentVis = new PolygonSpectogram(); + } + + /** + * Adds the player's visualizer to the group. + * @param visSnd The visualizer to add. + */ + public function addPlayerVis(visSnd:FlxSound):Void + { + var vis:PolygonSpectogram = new PolygonSpectogram(visSnd); + super.add(vis); + playerVis = vis; + } + + /** + * Adds the opponent's visualizer to the group. + * @param visSnd The visualizer to add. + */ + public function addOpponentVis(visSnd:FlxSound):Void + { + var vis:PolygonSpectogram = new PolygonSpectogram(visSnd); + super.add(vis); + opponentVis = vis; + } + + /** + * Adds the instrument's visualizer to the group. + * @param visSnd The visualizer to add. + */ + public function addInstVis(visSnd:FlxSound):Void + { + var vis:PolygonSpectogram = new PolygonSpectogram(visSnd); + super.add(vis); + instVis = vis; + } + + public function clearPlayerVis():Void + { + if (playerVis != null) + { + remove(playerVis); + playerVis.destroy(); + playerVis = null; + } + } + + public function clearOpponentVis():Void + { + if (opponentVis != null) + { + remove(opponentVis); + opponentVis.destroy(); + opponentVis = null; + } + } + + public function clearInstVis():Void + { + if (instVis != null) + { + remove(instVis); + instVis.destroy(); + instVis = null; + } + } + + public function clearAllVis():Void + { + clearPlayerVis(); + clearOpponentVis(); + clearInstVis(); + } + + /** + * Overrides the add function to add a visualizer to the group. + * @param vis The visualizer to add. + * @return The added visualizer. + */ + public override function add(vis:PolygonSpectogram):PolygonSpectogram + { + var result:PolygonSpectogram = super.add(vis); + return result; + } + + public override function destroy():Void + { + playerVis.destroy(); + opponentVis.destroy(); + super.destroy(); + } +} diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx new file mode 100644 index 00000000..615e80d9 --- /dev/null +++ b/source/funkin/audio/visualize/SpectogramSprite.hx @@ -0,0 +1,266 @@ +package funkin.audio.visualize; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.util.FlxColor; +import funkin.audio.visualize.PolygonSpectogram.VISTYPE; +import funkin.audio.visualize.VisShit.CurAudioInfo; +import lime.utils.Int16Array; + +using Lambda; +using flixel.util.FlxSpriteUtil; + +class SpectogramSprite extends FlxTypedSpriteGroup +{ + var sampleRate:Int; + + var lengthOfShit:Int = 500; + + public var visType:VISTYPE = UPDATED; + + public var col:Int = FlxColor.WHITE; + public var daHeight:Float = FlxG.height; + + public var vis:VisShit; + + public function new(daSound:FlxSound, ?col:FlxColor = FlxColor.WHITE, ?height:Float = 720, ?amnt:Int = 500) + { + super(); + + vis = new VisShit(daSound); + this.col = col; + this.daHeight = height; + lengthOfShit = amnt; + + regenLineShit(); + } + + public function regenLineShit():Void + { + for (i in 0...lengthOfShit) + { + var lineShit:FlxSprite = new FlxSprite(100, i / lengthOfShit * daHeight).makeGraphic(1, 1, col); + lineShit.active = false; + lineShit.ID = i; + add(lineShit); + } + } + + var setBuffer:Bool = false; + + public var audioData:Int16Array; + + var numSamples:Int = 0; + + public var wavOptimiz:Int = 10; + + override function update(elapsed:Float) + { + switch (visType) + { + case UPDATED: + updateVisulizer(); + + case FREQUENCIES: + updateFFT(); + default: + } + + forEach(spr -> { + spr.visible = spr.ID % wavOptimiz == 0; + }); + + // if visType is static, call updateVisulizer() manually whenever you want to update it! + + super.update(elapsed); + } + + /** + * @param start is the start in milliseconds? + */ + public function generateSection(start:Float = 0, seconds:Float = 1):Void + { + checkAndSetBuffer(); + + if (setBuffer) + { + var samplesToGen:Int = Std.int(sampleRate * seconds); + var startingSample:Int = Std.int(FlxMath.remapToRange(start, 0, vis.snd.length, 0, numSamples)); + + var prevLine:FlxPoint = new FlxPoint(); + + for (i in 0...group.members.length) + { + var sampleApprox:Int = Std.int(FlxMath.remapToRange(i, 0, group.members.length, startingSample, startingSample + samplesToGen)); + var curAud:CurAudioInfo = VisShit.getCurAud(audioData, sampleApprox); + + var swagheight:Int = 200; + + group.members[i].x = prevLine.x; + group.members[i].y = prevLine.y; + + prevLine.x = (curAud.balanced * swagheight / 2 + swagheight / 2) + x; + prevLine.y = (i / group.members.length * daHeight) + y; + + var line = FlxPoint.get(prevLine.x - group.members[i].x, prevLine.y - group.members[i].y); + + group.members[i].setGraphicSize(Std.int(Math.max(line.length, 1)), Std.int(1)); + group.members[i].angle = line.degrees; + } + + wavOptimiz = 1; // hard set wavOptimiz to 1 so its a pure thing + } + } + + public function checkAndSetBuffer() + { + vis.checkAndSetBuffer(); + + if (vis.setBuffer) + { + audioData = vis.audioData; + sampleRate = vis.sampleRate; + setBuffer = vis.setBuffer; + numSamples = Std.int(audioData.length / 2); + } + } + + var doAnim:Bool = false; + var frameCounter:Int = 0; + + public function updateFFT() + { + if (vis.snd != null) + { + var remappedShit:Int = 0; + + checkAndSetBuffer(); + + if (!doAnim) + { + frameCounter++; + + if (frameCounter >= 0) + { + frameCounter = 0; + doAnim = true; + } + } + + if (setBuffer && doAnim) + { + doAnim = false; + + if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples)); + else + remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples)); + + var fftSamples:Array = []; + var i = remappedShit; + + for (sample in remappedShit...remappedShit + (Std.int((44100 * (1 / 144))))) + { + var curAud:CurAudioInfo = VisShit.getCurAud(audioData, i); + i += 2; + + fftSamples.push(curAud.balanced); + } + + var freqShit = vis.funnyFFT(fftSamples); + var prevLine:FlxPoint = new FlxPoint(); + var swagheight:Int = 200; + + for (i in 0...group.members.length) + { + // needs to be exponential growth / scaling + // still need to optmize the FFT to run better, gets only samples needed? + // not every frequency is built the same! + // 20hz to 40z is a LOT of subtle low ends, but somethin like 20,000hz to 20,020hz, the difference is NOT the same! + + var powedShit:Float = FlxMath.remapToRange(i, 0, group.members.length, 0, 4); + + // a value between 10hz and 100Khz + var hzPicker:Float = Math.pow(10, powedShit); + + var remappedFreq:Int = Std.int(FlxMath.remapToRange(hzPicker, 0, 10000, 0, freqShit[0].length - 1)); + + group.members[i].x = prevLine.x; + group.members[i].y = prevLine.y; + + var freqPower:Float = 0; + + for (pow in 0...freqShit.length) + freqPower += freqShit[pow][remappedFreq]; + + freqPower /= freqShit.length; + var freqIDK:Float = FlxMath.remapToRange(freqPower, 0, 0.000005, 0, 50); + + prevLine.x = (freqIDK * swagheight / 2 + swagheight / 2) + x; + prevLine.y = (i / group.members.length * daHeight) + y; + + var line = FlxPoint.get(prevLine.x - group.members[i].x, prevLine.y - group.members[i].y); + + // dont draw a line until i figure out a nicer way to view da spikes and shit idk lol! + } + } + } + } + + var curTime:Float = 0; + + public function updateVisulizer():Void + { + if (vis.snd != null) + { + var remappedShit:Int = 0; + + checkAndSetBuffer(); + + if (setBuffer) + { + if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples)); + else + { + if (curTime == Conductor.instance.songPosition) + { + wavOptimiz = 3; + return; // already did shit, so finishes function early + } + + curTime = Conductor.instance.songPosition; + + remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples)); + } + + wavOptimiz = 8; + + var i = remappedShit; + var prevLine:FlxPoint = new FlxPoint(); + + var swagheight:Int = 200; + + for (sample in remappedShit...remappedShit + lengthOfShit) + { + var curAud:CurAudioInfo = VisShit.getCurAud(audioData, i); + + i += 2; + + var remappedSample:Float = FlxMath.remapToRange(sample, remappedShit, remappedShit + lengthOfShit, 0, lengthOfShit - 1); + + group.members[Std.int(remappedSample)].x = prevLine.x; + group.members[Std.int(remappedSample)].y = prevLine.y; + prevLine.x = (curAud.balanced * swagheight / 2 + swagheight / 2) + x; + prevLine.y = (Std.int(remappedSample) / lengthOfShit * daHeight) + y; + + var line = FlxPoint.get(prevLine.x - group.members[Std.int(remappedSample)].x, prevLine.y - group.members[Std.int(remappedSample)].y); + + group.members[Std.int(remappedSample)].setGraphicSize(Std.int(Math.max(line.length, 1)), Std.int(1)); + group.members[Std.int(remappedSample)].angle = line.degrees; + } + } + } + } +} diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx new file mode 100644 index 00000000..83b9496a --- /dev/null +++ b/source/funkin/audio/visualize/VisShit.hx @@ -0,0 +1,142 @@ +package funkin.audio.visualize; + +import flixel.math.FlxMath; +import flixel.sound.FlxSound; +import funkin.audio.visualize.dsp.FFT; +import lime.utils.Int16Array; +import funkin.util.MathUtil; + +using Lambda; + +class VisShit +{ + public var snd:FlxSound; + public var setBuffer:Bool = false; + public var audioData:Int16Array; + public var sampleRate:Int = 44100; // default, ez? + public var numSamples:Int = 0; + + public function new(snd:FlxSound) + { + this.snd = snd; + } + + public function funnyFFT(samples:Array, ?skipped:Int = 1):Array> + { + // nab multiple samples at once in while / for loops? + + var fs:Float = 44100 / skipped; // sample rate shit? + + final fftN = 1024; + final halfN = Std.int(fftN / 2); + final overlap = 0.5; + final hop = Std.int(fftN * (1 - overlap)); + + // window function to compensate for overlapping + final a0 = 0.5; // => Hann(ing) window + final window = (n:Int) -> a0 - (1 - a0) * Math.cos(2 * Math.PI * n / fftN); + + // NOTE TO SELF FOR WHEN I WAKE UP + + // helpers, note that spectrum indexes suppose non-negative frequencies + final binSize = fs / fftN; + final indexToFreq = function(k:Int) { + var powShit:Float = FlxMath.remapToRange(k, 0, halfN, 0, MathUtil.logBase(10, halfN)); // 4.3 is almost the log of 20Khz or so. Close enuf lol + + return 1.0 * (Math.pow(10, powShit)); // we need the `1.0` to avoid overflows + }; + + // "melodic" band-pass filter + final minFreq = 20.70; + final maxFreq = 4000.01; + final melodicBandPass = function(k:Int, s:Float) { + final freq = indexToFreq(k); + final filter = freq > minFreq - binSize && freq < maxFreq + binSize ? 1 : 0; + return s; + }; + + var freqOutput:Array> = []; + + var c = 0; // index where each chunk begins + var indexOfArray:Int = 0; + while (c < samples.length) + { + // take a chunk (zero-padded if needed) and apply the window + final chunk = [ + for (n in 0...fftN) + (c + n < samples.length ? samples[c + n] : 0.0) * window(n) + ]; + + // compute positive spectrum with sampling correction and BP filter + final freqs = FFT.rfft(chunk).map(z -> z.scale(1 / fftN).magnitude).mapi(melodicBandPass); + + freqOutput.push([]); + + // find spectral peaks and their instantaneous frequencies + for (k => s in freqs) + { + final time = c / fs; + final freq = indexToFreq(k); + final power = s * s; + if (FlxG.keys.justPressed.I) + { + trace(k); + + haxe.Log.trace('${time};${freq};${power}', null); + } + if (freq < maxFreq) freqOutput[indexOfArray].push(power); + // + } + + indexOfArray++; + // move to next (overlapping) chunk + c += hop; + } + + if (FlxG.keys.justPressed.C) trace(freqOutput.length); + + return freqOutput; + } + + public static function getCurAud(aud:Int16Array, index:Int):CurAudioInfo + { + var left = aud[index] / 32767; + var right = aud[index + 2] / 32767; + var balanced = (left + right) / 2; + + var funny:CurAudioInfo = {left: left, right: right, balanced: balanced}; + + return funny; + } + + public function checkAndSetBuffer() + { + if (snd != null && snd.playing) + { + if (!setBuffer) + { + // Math.pow3 + @:privateAccess + var buf = snd._channel.__audioSource.buffer; + + // @:privateAccess + audioData = cast buf.data; // jank and hacky lol! kinda busted on HTML5 also!! + sampleRate = buf.sampleRate; + + trace('got audio buffer shit'); + trace(sampleRate); + trace(buf.bitsPerSample); + + setBuffer = true; + numSamples = Std.int(audioData.length / 2); + } + } + } +} + +typedef CurAudioInfo = +{ + var left:Float; + var right:Float; + var balanced:Float; +} diff --git a/source/funkin/audio/visualize/dsp/Complex.hx b/source/funkin/audio/visualize/dsp/Complex.hx new file mode 100644 index 00000000..37861bcc --- /dev/null +++ b/source/funkin/audio/visualize/dsp/Complex.hx @@ -0,0 +1,80 @@ +package funkin.audio.visualize.dsp; + +/** + Complex number representation. +**/ +@:forward(real, imag) @:notNull @:pure +abstract Complex({ + final real:Float; + final imag:Float; +}) +{ + public inline function new(real:Float, imag:Float) + this = {real: real, imag: imag}; + + /** + Makes a Complex number with the given Float as its real part and a zero imag part. + **/ + @:from + public static inline function fromReal(r:Float) + return new Complex(r, 0); + + /** + Complex argument, in radians. + **/ + public var angle(get, never):Float; + + inline function get_angle() + return Math.atan2(this.imag, this.real); + + /** + Complex module. + **/ + public var magnitude(get, never):Float; + + inline function get_magnitude() + return Math.sqrt(this.real * this.real + this.imag * this.imag); + + @:op(A + B) + public inline function add(rhs:Complex):Complex + return new Complex(this.real + rhs.real, this.imag + rhs.imag); + + @:op(A - B) + public inline function sub(rhs:Complex):Complex + return new Complex(this.real - rhs.real, this.imag - rhs.imag); + + @:op(A * B) + public inline function mult(rhs:Complex):Complex + return new Complex(this.real * rhs.real - this.imag * rhs.imag, this.real * rhs.imag + this.imag * rhs.real); + + /** + Returns the complex conjugate, does not modify this object. + **/ + public inline function conj():Complex + return new Complex(this.real, -this.imag); + + /** + Multiplication by a real factor, does not modify this object. + **/ + public inline function scale(k:Float):Complex + return new Complex(this.real * k, this.imag * k); + + public inline function copy():Complex + return new Complex(this.real, this.imag); + + /** + The imaginary unit. + **/ + public static final im = new Complex(0, 1); + + /** + The complex zero. + **/ + public static final zero = new Complex(0, 0); + + /** + Computes the complex exponential `e^(iw)`. + **/ + public static inline function exp(w:Float) + return new Complex(Math.cos(w), Math.sin(w)); +} diff --git a/source/funkin/audio/visualize/dsp/FFT.hx b/source/funkin/audio/visualize/dsp/FFT.hx new file mode 100644 index 00000000..40ee9cb8 --- /dev/null +++ b/source/funkin/audio/visualize/dsp/FFT.hx @@ -0,0 +1,159 @@ +package funkin.audio.visualize.dsp; + +using funkin.audio.visualize.dsp.OffsetArray; +using funkin.audio.visualize.dsp.Signal; + +// these are only used for testing, down in FFT.main() + +/** + Fast/Finite Fourier Transforms. +**/ +class FFT +{ + /** + Computes the Discrete Fourier Transform (DFT) of a `Complex` sequence. + + If the input has N data points (N should be a power of 2 or padding will be added) + from a signal sampled at intervals of 1/Fs, the result will be a sequence of N + samples from the Discrete-Time Fourier Transform (DTFT) - which is Fs-periodic - + with a spacing of Fs/N Hz between them and a scaling factor of Fs. + **/ + public static function fft(input:Array):Array + return do_fft(input, false); + + /** + Like `fft`, but for a real (Float) sequence input. + + Since the input time signal is real, its frequency representation is + Hermitian-symmetric so we only return the positive frequencies. + **/ + public static function rfft(input:Array):Array + { + final s = fft(input.map(Complex.fromReal)); + return s.slice(0, Std.int(s.length / 2) + 1); + } + + /** + Computes the Inverse DFT of a periodic input sequence. + + If the input contains N (a power of 2) DTFT samples, each spaced Fs/N Hz + from each other, the result will consist of N data points as sampled + from a time signal at intervals of 1/Fs with a scaling factor of 1/Fs. + **/ + public static function ifft(input:Array):Array + return do_fft(input, true); + + // Handles padding and scaling for forwards and inverse FFTs. + static function do_fft(input:Array, inverse:Bool):Array + { + final n = nextPow2(input.length); + var ts = [for (i in 0...n) if (i < input.length) input[i] else Complex.zero]; + var fs = [for (_ in 0...n) Complex.zero]; + ditfft2(ts, 0, fs, 0, n, 1, inverse); + return inverse ? fs.map(z -> z.scale(1 / n)) : fs; + return fs; + } + + // Radix-2 Decimation-In-Time variant of Cooley–Tukey's FFT, recursive. + static function ditfft2(time:Array, t:Int, freq:Array, f:Int, n:Int, step:Int, inverse:Bool):Void + { + if (n == 1) + { + freq[f] = time[t].copy(); + } + else + { + final halfLen = Std.int(n / 2); + ditfft2(time, t, freq, f, halfLen, step * 2, inverse); + ditfft2(time, t + step, freq, f + halfLen, halfLen, step * 2, inverse); + for (k in 0...halfLen) + { + final twiddle = Complex.exp((inverse ? 1 : -1) * 2 * Math.PI * k / n); + final even = freq[f + k].copy(); + final odd = freq[f + k + halfLen].copy(); + freq[f + k] = even + twiddle * odd; + freq[f + k + halfLen] = even - twiddle * odd; + } + } + } + + // Naive O(n^2) DFT, used for testing purposes. + static function dft(ts:Array, ?inverse:Bool):Array + { + if (inverse == null) inverse = false; + final n = ts.length; + var fs = new Array(); + fs.resize(n); + for (f in 0...n) + { + var sum = Complex.zero; + for (t in 0...n) + { + sum += ts[t] * Complex.exp((inverse ? 1 : -1) * 2 * Math.PI * f * t / n); + } + fs[f] = inverse ? sum.scale(1 / n) : sum; + } + return fs; + } + + /** + Finds the power of 2 that is equal to or greater than the given natural. + **/ + static function nextPow2(x:Int):Int + { + if (x < 2) return 1; + else if ((x & (x - 1)) == 0) return x; + var pow = 2; + x--; + while ((x >>= 1) != 0) + pow <<= 1; + return pow; + } + + // testing, but also acts like an example + static function main() + { + // sampling and buffer parameters + final Fs = 44100.0; + final N = 512; + final halfN = Std.int(N / 2); + + // build a time signal as a sum of sinusoids + final freqs = [5919.911]; + final ts = [for (n in 0...N) freqs.map(f -> Math.sin(2 * Math.PI * f * n / Fs)).sum()]; + + // get positive spectrum and use its symmetry to reconstruct negative domain + final fs_pos = rfft(ts); + final fs_fft = new OffsetArray([for (k in -(halfN - 1)...0) fs_pos[-k].conj()].concat(fs_pos), -(halfN - 1)); + + // double-check with naive DFT + final fs_dft = new OffsetArray(dft(ts.map(Complex.fromReal)).circShift(halfN - 1), -(halfN - 1)); + final fs_err = [for (k in -(halfN - 1)...halfN) fs_fft[k] - fs_dft[k]]; + final max_fs_err = fs_err.map(z -> z.magnitude).max(); + if (max_fs_err > 1e-6) haxe.Log.trace('FT Error: ${max_fs_err}', null); + // else for (k => s in fs_fft) haxe.Log.trace('${k * Fs / N};${s.scale(1 / Fs).magnitude}', null); + + // find spectral peaks to detect signal frequencies + final freqis = fs_fft.array.map(z -> z.magnitude) + .findPeaks() + .map(k -> (k - (halfN - 1)) * Fs / N) + .filter(f -> f >= 0); + if (freqis.length != freqs.length) + { + trace('Found frequencies: ${freqis}'); + } + else + { + final freqs_err = [for (i in 0...freqs.length) freqis[i] - freqs[i]]; + final max_freqs_err = freqs_err.map(Math.abs).max(); + if (max_freqs_err > Fs / N) trace('Frequency Errors: ${freqs_err}'); + } + + // recover time signal from the frequency domain + final ts_ifft = ifft(fs_fft.array.circShift(-(halfN - 1)).map(z -> z.scale(1 / Fs))); + final ts_err = [for (n in 0...N) ts_ifft[n].scale(Fs).real - ts[n]]; + final max_ts_err = ts_err.map(Math.abs).max(); + if (max_ts_err > 1e-6) haxe.Log.trace('IFT Error: ${max_ts_err}', null); + // else for (n in 0...ts_ifft.length) haxe.Log.trace('${n / Fs};${ts_ifft[n].scale(Fs).real}', null); + } +} diff --git a/source/funkin/audio/visualize/dsp/OffsetArray.hx b/source/funkin/audio/visualize/dsp/OffsetArray.hx new file mode 100644 index 00000000..c8a5c27c --- /dev/null +++ b/source/funkin/audio/visualize/dsp/OffsetArray.hx @@ -0,0 +1,86 @@ +package funkin.audio.visualize.dsp; + +/** + A view into an Array with an indexing offset. + + Usages include 1-indexed sequences or zero-centered buffers with negative indexing. +**/ +@:forward(array, offset) +abstract OffsetArray({ + final array:Array; + final offset:Int; +}) +{ + public inline function new(array:Array, offset:Int) + this = {array: array, offset: offset}; + + public var length(get, never):Int; + + inline function get_length() + return this.array.length; + + @:arrayAccess + public inline function get(index:Int):T + return this.array[index - this.offset]; + + @:arrayAccess + public inline function set(index:Int, value:T):Void + this.array[index - this.offset] = value; + + /** + Iterates through items in their original order while providing the altered indexes as keys. + **/ + public inline function keyValueIterator():KeyValueIterator + return new OffsetArrayIterator(this.array, this.offset); + + @:from + static inline function fromArray(array:Array) + return new OffsetArray(array, 0); + + @:to + inline function toArray() + return this.array; + + /** + Makes a shifted version of the given `array`, where elements are in the + same order but shifted by `n` positions (to the right if positive and to + the left if negative) in **circular** fashion (no elements discarded). + **/ + public static function circShift(array:Array, n:Int):Array + { + if (n < 0) return circShift(array, array.length + n); + + var shifted = new Array(); + + n = n % array.length; + for (i in array.length - n...array.length) + shifted.push(array[i]); + for (i in 0...array.length - n) + shifted.push(array[i]); + + return shifted; + } +} + +private class OffsetArrayIterator +{ + final array:Array; + final offset:Int; + var enumeration:Int; + + public inline function new(array:Array, offset:Int) + { + this.array = array; + this.offset = offset; + this.enumeration = 0; + } + + public inline function next():{key:Int, value:T} + { + final i = this.enumeration++; + return {key: i + this.offset, value: this.array[i]}; + } + + public inline function hasNext():Bool + return this.enumeration < this.array.length; +} diff --git a/source/funkin/audio/visualize/dsp/Signal.hx b/source/funkin/audio/visualize/dsp/Signal.hx new file mode 100644 index 00000000..4557dc19 --- /dev/null +++ b/source/funkin/audio/visualize/dsp/Signal.hx @@ -0,0 +1,114 @@ +package funkin.audio.visualize.dsp; + +using Lambda; + +/** + Signal processing miscellaneous utilities. +**/ +class Signal +{ + /** + Returns a smoothed version of the input array using a moving average. + **/ + public static function smooth(y:Array, n:Int):Array + { + if (n <= 0) + { + return null; + } + else if (n == 1) + { + return y.copy(); + } + else + { + var smoothed = new Array(); + smoothed.resize(y.length); + for (i in 0...y.length) + { + var m = i + 1 < n ? i : n - 1; + smoothed[i] = sum(y.slice(i - m, i + 1)); + } + return smoothed; + } + } + + /** + Finds indexes of peaks in the order they appear in the input sequence. + + @param threshold Minimal peak height wrt. its neighbours, defaults to 0. + @param minHeight Minimal peak height wrt. the whole input, defaults to global minimum. + **/ + public static function findPeaks(y:Array, ?threshold:Float, ?minHeight:Float):Array + { + threshold = threshold == null ? 0.0 : Math.abs(threshold); + minHeight = minHeight == null ? Signal.min(y) : minHeight; + + var peaks = new Array(); + + final dy = [for (i in 1...y.length) y[i] - y[i - 1]]; + for (i in 1...dy.length) + { + // peak: function growth positive to its left and negative to its right + if (dy[i - 1] > threshold && dy[i] < -threshold && y[i] > minHeight) + { + peaks.push(i); + } + } + + return peaks; + } + + /** + Returns the sum of all the elements of a given array. + + This function tries to minimize floating-point precision errors. + **/ + public static function sum(array:Array):Float + { + // Neumaier's "improved Kahan-Babuska algorithm": + + var sum = 0.0; + var c = 0.0; // running compensation for lost precision + + for (v in array) + { + var t = sum + v; + c += Math.abs(sum) >= Math.abs(v) ? (sum - t) + v // sum is bigger => low-order digits of v are lost + : (v - t) + sum; // v is bigger => low-order digits of sum are lost + sum = t; + } + + return sum + c; // correction only applied at the very end + } + + /** + Returns the average value of an array. + **/ + public static function mean(y:Array):Float + return sum(y) / y.length; + + /** + Returns the global maximum. + **/ + public static function max(y:Array):Float + return y.fold(Math.max, y[0]); + + /** + Returns the global maximum's index. + **/ + public static function maxi(y:Array):Int + return y.foldi((yi, m, i) -> yi > y[m] ? i : m, 0); + + /** + Returns the global minimum. + **/ + public static function min(y:Array):Float + return y.fold(Math.min, y[0]); + + /** + Returns the global minimum's index. + **/ + public static function mini(y:Array):Int + return y.foldi((yi, m, i) -> yi < y[m] ? i : m, 0); +} diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx new file mode 100644 index 00000000..a939f91b --- /dev/null +++ b/source/funkin/audio/waveform/WaveformData.hx @@ -0,0 +1,336 @@ +package funkin.audio.waveform; + +@:nullSafety +class WaveformData +{ + static final DEFAULT_VERSION:Int = 2; + + /** + * The version of the waveform data format. + * @default `2` (-1 if not specified/invalid) + */ + public var version(default, null):Int = -1; + + /** + * The number of channels in the waveform. + */ + public var channels(default, null):Int = 1; + + @:alias('sample_rate') + public var sampleRate(default, null):Int = 44100; + + /** + * Number of input audio samples per output waveform data point. + * At base zoom level this is number of samples per pixel. + * Lower values can more accurately represent the waveform when zoomed in, but take more data. + */ + @:alias('samples_per_pixel') + public var samplesPerPoint(default, null):Int = 256; + + /** + * Number of bits to use for each sample value. Valid values are `8` and `16`. + */ + public var bits(default, null):Int = 16; + + /** + * The length of the data array, in points. + */ + public var length(default, null):Int = 0; + + /** + * Array of Int16 values representing the waveform. + * TODO: Use an `openfl.Vector` for performance. + */ + public var data(default, null):Array = []; + + @:jignored + var channelData:Null> = null; + + public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array) + { + this.version = version ?? DEFAULT_VERSION; + this.channels = channels; + this.sampleRate = sampleRate; + this.samplesPerPoint = samplesPerPoint; + this.bits = bits; + this.length = length; + this.data = data; + } + + function buildChannelData():Array + { + channelData = []; + for (i in 0...channels) + { + channelData.push(new WaveformDataChannel(this, i)); + } + return channelData; + } + + public function channel(index:Int) + { + return (channelData == null) ? buildChannelData()[index] : channelData[index]; + } + + public function get(index:Int):Int + { + return data[index] ?? 0; + } + + public function set(index:Int, value:Int) + { + data[index] = value; + } + + /** + * Maximum possible value for a waveform data point. + * The minimum possible value is (-1 * maxSampleValue) + */ + public function maxSampleValue():Int + { + if (_maxSampleValue != 0) return _maxSampleValue; + return _maxSampleValue = Std.int(Math.pow(2, bits)); + } + + /** + * Cache the value because `Math.pow` is expensive and the value gets used a lot. + */ + @:jignored + var _maxSampleValue:Int = 0; + + /** + * @return The length of the waveform in samples. + */ + public function lenSamples():Int + { + return length * samplesPerPoint; + } + + /** + * @return The length of the waveform in seconds. + */ + public function lenSeconds():Float + { + return inline lenSamples() / sampleRate; + } + + /** + * Given the time in seconds, return the waveform data point index. + */ + public function secondsToIndex(seconds:Float):Int + { + return Std.int(seconds * inline pointsPerSecond()); + } + + /** + * Given a waveform data point index, return the time in seconds. + */ + public function indexToSeconds(index:Int):Float + { + return index / inline pointsPerSecond(); + } + + /** + * The number of data points this waveform data provides per second of audio. + */ + public inline function pointsPerSecond():Float + { + return sampleRate / samplesPerPoint; + } + + /** + * Given the percentage progress through the waveform, return the waveform data point index. + */ + public function percentToIndex(percent:Float):Int + { + return Std.int(percent * length); + } + + /** + * Given a waveform data point index, return the percentage progress through the waveform. + */ + public function indexToPercent(index:Int):Float + { + return index / length; + } + + /** + * Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value. + * This is useful for zooming in/out of the waveform in a performant manner. + * + * @param newSamplesPerPoint The new value for `samplesPerPoint`. + */ + public function resample(newSamplesPerPoint:Int):WaveformData + { + var result = this.clone(); + + var ratio = newSamplesPerPoint / samplesPerPoint; + if (ratio == 1) return result; + if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.'); + + var inputSampleCount = this.lenSamples(); + var outputSampleCount = Std.int(inputSampleCount * ratio); + + var inputPointCount = this.length; + var outputPointCount = Std.int(inputPointCount / ratio); + var outputChannelCount = this.channels; + + // TODO: Actually figure out the dumbass logic for this. + + return result; + } + + /** + * Create a new WaveformData whose data represents the two waveforms overlayed. + */ + public function merge(that:WaveformData):WaveformData + { + if (that == null) return this.clone(); + + var result = this.clone([]); + + for (channelIndex in 0...this.channels) + { + var thisChannel = this.channel(channelIndex); + var thatChannel = that.channel(channelIndex); + var resultChannel = result.channel(channelIndex); + + for (index in 0...this.length) + { + var thisMinSample = thisChannel.minSample(index); + var thatMinSample = thatChannel.minSample(index); + + var thisMaxSample = thisChannel.maxSample(index); + var thatMaxSample = thatChannel.maxSample(index); + + resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample))); + resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample))); + } + } + + @:privateAccess + result.length = this.length; + + return result; + } + + /** + * Create a new WaveformData whose parameters match the current object. + */ + public function clone(?newData:Array = null):WaveformData + { + if (newData == null) + { + newData = this.data.clone(); + } + + var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData); + + return clone; + } +} + +@:nullSafety +class WaveformDataChannel +{ + var parent:WaveformData; + var channelId:Int; + + public function new(parent:WaveformData, channelId:Int) + { + this.parent = parent; + this.channelId = channelId; + } + + /** + * Retrieve a given minimum point at an index. + */ + public function minSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function minSampleMapped(i:Int) + { + return inline minSample(i) / inline parent.maxSampleValue(); + } + + /** + * Minimum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function minSampleRange(start:Int, end:Int) + { + var min = inline parent.maxSampleValue(); + for (i in start...end) + { + var sample = inline minSample(i); + if (sample < min) min = sample; + } + return min; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function minSampleRangeMapped(start:Int, end:Int) + { + return inline minSampleRange(start, end) / inline parent.maxSampleValue(); + } + + /** + * Retrieve a given maximum point at an index. + */ + public function maxSample(i:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + return inline parent.get(offset); + } + + /** + * Mapped to a value between 0 and 1. + */ + public function maxSampleMapped(i:Int) + { + return inline maxSample(i) / inline parent.maxSampleValue(); + } + + /** + * Maximum value within the range of samples. + * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead. + */ + public function maxSampleRange(start:Int, end:Int) + { + var max = -(inline parent.maxSampleValue()); + for (i in start...end) + { + var sample = inline maxSample(i); + if (sample > max) max = sample; + } + return max; + } + + /** + * Maximum value within the range of samples, mapped to a value between 0 and 1. + */ + public function maxSampleRangeMapped(start:Int, end:Int) + { + return inline maxSampleRange(start, end) / inline parent.maxSampleValue(); + } + + public function setMinSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2; + inline parent.set(offset, value); + } + + public function setMaxSample(i:Int, value:Int) + { + var offset = (i * parent.channels + this.channelId) * 2 + 1; + inline parent.set(offset, value); + } +} diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx new file mode 100644 index 00000000..ca421581 --- /dev/null +++ b/source/funkin/audio/waveform/WaveformDataParser.hx @@ -0,0 +1,146 @@ +package funkin.audio.waveform; + +import funkin.util.TimerUtil; + +class WaveformDataParser +{ + static final INT16_MAX:Int = 32767; + static final INT16_MIN:Int = -32768; + + static final INT8_MAX:Int = 127; + static final INT8_MIN:Int = -128; + + public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null + { + if (sound == null) return null; + + // Method 1. This only works if the sound has been played before. + @:privateAccess + var soundBuffer:Null = sound?._channel?.__audioSource?.buffer; + + if (soundBuffer == null) + { + // Method 2. This works if the sound has not been played before. + @:privateAccess + soundBuffer = sound?._sound?.__buffer; + + if (soundBuffer == null) + { + trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}'); + return null; + } + else + { + // trace('[WAVEFORM] Method 2 worked.'); + } + } + else + { + // trace('[WAVEFORM] Method 1 worked.'); + } + + return interpretAudioBuffer(soundBuffer); + } + + public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null + { + var sampleRate = soundBuffer.sampleRate; + var channels = soundBuffer.channels; + var bitsPerSample = soundBuffer.bitsPerSample; + var samplesPerPoint:Int = 256; // I don't think we need to configure this. + var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant.. + + // TODO: Make this work better on HTML5. + var soundData:lime.utils.Int16Array = cast soundBuffer.data; + + var soundDataRawLength:Int = soundData.length; + var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1))); + var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint)); + + // trace('Interpreting audio buffer:'); + // trace(' sampleRate: ${sampleRate}'); + // trace(' channels: ${channels}'); + // trace(' bitsPerSample: ${bitsPerSample}'); + // trace(' samplesPerPoint: ${samplesPerPoint}'); + // trace(' pointsPerSecond: ${pointsPerSecond}'); + // trace(' soundDataRawLength: ${soundDataRawLength}'); + // trace(' soundDataSampleCount: ${soundDataSampleCount}'); + // trace(' soundDataRawLength/4: ${soundDataRawLength / 4}'); + // trace(' outputPointCount: ${outputPointCount}'); + + var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN; + var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX; + + var outputData:Array = []; + + var perfStart:Float = TimerUtil.start(); + + for (pointIndex in 0...outputPointCount) + { + // minChannel1, maxChannel1, minChannel2, maxChannel2, ... + var values:Array = []; + + for (i in 0...channels) + { + values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX); + values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN); + } + + var rangeStart = pointIndex * samplesPerPoint; + var rangeEnd = rangeStart + samplesPerPoint; + if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount; + + for (sampleIndex in rangeStart...rangeEnd) + { + for (channelIndex in 0...channels) + { + var sampleIndex:Int = sampleIndex * channels + channelIndex; + var sampleValue = soundData[sampleIndex]; + + if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue; + if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue; + } + } + + // We now have the min and max values for the range. + for (value in values) + outputData.push(value); + } + + var outputDataLength:Int = Std.int(outputData.length / channels / 2); + var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData); + + trace('[WAVEFORM] Interpreted audio buffer in ${TimerUtil.seconds(perfStart)}.'); + + return result; + } + + public static function parseWaveformData(path:String):Null + { + var rawJson:String = openfl.Assets.getText(path).trim(); + return parseWaveformDataString(rawJson, path); + } + + public static function parseWaveformDataString(contents:String, ?fileName:String):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + trace('[WAVEFORM] Parsing waveform data: ${contents}'); + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + static function printErrors(errors:Array, id:String = ''):Void + { + trace('[WAVEFORM] Failed to parse waveform data: ${id}'); + + for (error in errors) + funkin.data.DataError.printError(error); + } +} diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx new file mode 100644 index 00000000..8eaba811 --- /dev/null +++ b/source/funkin/audio/waveform/WaveformSprite.hx @@ -0,0 +1,447 @@ +package funkin.audio.waveform; + +import funkin.graphics.rendering.MeshRender; +import flixel.util.FlxColor; + +class WaveformSprite extends MeshRender +{ + static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE; + static final DEFAULT_DURATION:Float = 5.0; + static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL; + static final DEFAULT_X:Float = 0.0; + static final DEFAULT_Y:Float = 0.0; + static final DEFAULT_WIDTH:Float = 100.0; + static final DEFAULT_HEIGHT:Float = 100.0; + + /** + * Set this to true to tell the waveform to rebuild itself. + * Do this any time the data or drawable area of the waveform changes. + * This often (but not always) needs to be done every frame. + */ + var isWaveformDirty:Bool = true; + + /** + * If true, force the waveform to redraw every frame. + * Useful if the waveform's clipRect is constantly changing. + */ + public var forceUpdate:Bool = false; + + public var waveformData(default, set):Null; + + function set_waveformData(value:Null):Null + { + if (waveformData == value) return value; + + waveformData = value; + isWaveformDirty = true; + return waveformData; + } + + /** + * The color to render the waveform with. + */ + public var waveformColor(default, set):FlxColor; + + function set_waveformColor(value:FlxColor):FlxColor + { + if (waveformColor == value) return value; + + waveformColor = value; + // We don't need to dirty the waveform geometry, just rebuild the texture. + rebuildGraphic(); + return waveformColor; + } + + public var orientation(default, set):WaveformOrientation; + + function set_orientation(value:WaveformOrientation):WaveformOrientation + { + if (orientation == value) return value; + + orientation = value; + isWaveformDirty = true; + return orientation; + } + + /** + * Time, in seconds, at which the waveform starts. + */ + public var time(default, set):Float; + + function set_time(value:Float) + { + if (time == value) return value; + + time = value; + isWaveformDirty = true; + return time; + } + + /** + * The duration, in seconds, that the waveform represents. + * The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents. + */ + public var duration(default, set):Float; + + function set_duration(value:Float) + { + if (duration == value) return value; + + duration = value; + isWaveformDirty = true; + return duration; + } + + /** + * Set the physical size of the waveform with `this.height = value`. + */ + override function set_height(value:Float):Float + { + if (height == value) return super.set_height(value); + + isWaveformDirty = true; + return super.set_height(value); + } + + /** + * Set the physical size of the waveform with `this.width = value`. + */ + override function set_width(value:Float):Float + { + if (width == value) return super.set_width(value); + + isWaveformDirty = true; + return super.set_width(value); + } + + /** + * The minimum size, in pixels, that a waveform will display with. + * Useful for preventing the waveform from becoming too small to see. + * + * NOTE: This is technically doubled since it's applied above and below the center of the waveform. + */ + public var minWaveformSize:Int = 1; + + /** + * A multiplier on the size of the waveform. + * Still capped at the width and height set for the sprite. + */ + public var amplitude:Float = 1.0; + + public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR); + this.waveformColor = color ?? DEFAULT_COLOR; + this.width = DEFAULT_WIDTH; + this.height = DEFAULT_HEIGHT; + + this.waveformData = waveformData; + this.orientation = orientation ?? DEFAULT_ORIENTATION; + this.time = 0.0; + this.duration = duration ?? DEFAULT_DURATION; + + this.forceUpdate = false; + } + + /** + * Manually tell the waveform to rebuild itself, even if none of its properties have changed. + */ + public function markDirty():Void + { + isWaveformDirty = true; + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + if (forceUpdate || isWaveformDirty) + { + // Recalculate the waveform vertices. + drawWaveform(); + isWaveformDirty = false; + } + } + + function rebuildGraphic():Void + { + // The waveform is rendered using a single colored pixel as a texture. + // If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want. + makeGraphic(1, 1, this.waveformColor); + } + + /** + * @param offsetX Horizontal offset to draw the waveform at, in samples. + */ + function drawWaveform():Void + { + // For each sample in the waveform... + // Add a MAX vertex and a MIN vertex. + // If previous MAX/MIN is empty, store. + // If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN. + // Continue until end of waveform. + + this.clear(); + + if (waveformData == null) return; + + // Center point of the waveform. When horizontal this is half the height, when vertical this is half the width. + var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2); + + var oneSecondInIndices:Int = waveformData.secondsToIndex(1); + + var startTime:Float = time; + var endTime:Float = time + duration; + + var startIndex:Int = waveformData.secondsToIndex(startTime); + var endIndex:Int = waveformData.secondsToIndex(endTime); + + var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex); + var indexesPerPixel:Float = 1 / pixelsPerIndex; + + var topLeftVertexIndex:Int = -1; + var topRightVertexIndex:Int = -1; + var bottomLeftVertexIndex:Int = -1; + var bottomRightVertexIndex:Int = -1; + + if (clipRect != null) + { + topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y); + topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y); + bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height); + bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height); + } + + if (pixelsPerIndex >= 1.0) + { + // Each index is at least one pixel wide, so we render each index. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + for (i in startIndex...endIndex) + { + var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex); + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize; + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + // Don't render if we don't have a previous different set of vertices to create a quad from. + if (prevVertexTopIndex != -1 + && prevVertexBottomIndex != -1 + && prevVertexTopIndex != vertexTopIndex + && prevVertexBottomIndex != vertexBottomIndex) + { + switch (orientation) // the line of code that makes you gay + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + else + { + // Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it. + var prevVertexTopIndex:Int = -1; + var prevVertexBottomIndex:Int = -1; + var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height); + for (i in 0...waveformLengthPixels) + { + var pixelPos:Int = i; + + var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y); + + if (isBeforeClipRect) continue; + + var isAfterClipRect:Bool = (clipRect != null) + && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height)); + + if (isAfterClipRect) + { + break; + }; + + // Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming. + var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex); + var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex); + + var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0); + var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0); + var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize; + var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2; + if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize; + var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize); + var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize); + + var vertexTopIndex:Int = -1; + var vertexBottomIndex:Int = -1; + + if (clipRect != null) + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + else + { + vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex); + vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, + bottomRightVertexIndex); + } + } + else + { + if (orientation == HORIZONTAL) + { + vertexTopIndex = this.build_vertex(pixelPos, vertexTopY); + vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY); + } + else + { + vertexTopIndex = this.build_vertex(vertexTopY, pixelPos); + vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos); + } + } + + if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1) + { + switch (orientation) + { + case HORIZONTAL: + this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex); + case VERTICAL: + this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex); + } + } + prevVertexTopIndex = vertexTopIndex; + prevVertexBottomIndex = vertexBottomIndex; + } + } + } + + function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int + { + var shouldClipXLeft = x < clipRect.x; + var shouldClipXRight = x > (clipRect.x + clipRect.width); + var shouldClipYTop = y < clipRect.y; + var shouldClipYBottom = y > (clipRect.y + clipRect.height); + + // If the vertex is fully outside the clipRect, use a pre-existing vertex. + // Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis. + // Else, create a whole new vertex. + if (shouldClipXLeft && shouldClipYTop) + { + return topLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYTop) + { + return topRightVertexIndex; + } + else if (shouldClipXLeft && shouldClipYBottom) + { + return bottomLeftVertexIndex; + } + else if (shouldClipXRight && shouldClipYBottom) + { + return bottomRightVertexIndex; + } + else if (shouldClipXLeft) + { + return this.build_vertex(clipRect.x, y); + } + else if (shouldClipXRight) + { + return this.build_vertex(clipRect.x + clipRect.width, y); + } + else if (shouldClipYTop) + { + return this.build_vertex(x, clipRect.y); + } + else if (shouldClipYBottom) + { + return this.build_vertex(x, clipRect.y + clipRect.height); + } + else + { + return this.build_vertex(x, y); + } + } + + public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + return new WaveformSprite(data, orientation, color, duration); + } + + public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float) + { + // TODO: Build waveform data from FunkinSound. + var data = null; + + return buildFromWaveformData(data, orientation, color, duration); + } +} + +enum WaveformOrientation +{ + HORIZONTAL; + VERTICAL; +} diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx new file mode 100644 index 00000000..dad23847 --- /dev/null +++ b/source/funkin/data/BaseRegistry.hx @@ -0,0 +1,341 @@ +package funkin.data; + +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; +import haxe.Constraints.Constructible; + +/** + * The entry's constructor function must take a single argument, the entry's ID. + */ +typedef EntryConstructorFunction = String->Void; + +/** + * A base type for a Registry, which is an object which handles loading scriptable objects. + * + * @param T The type to construct. Must implement `IRegistryEntry`. + * @param J The type of the JSON data used when constructing. + */ +@:generic +@:autoBuild(funkin.util.macro.DataRegistryMacro.buildRegistry()) +abstract class BaseRegistry & Constructible), J> +{ + /** + * The ID of the registry. Used when logging. + */ + public final registryId:String; + + final dataFilePath:String; + + /** + * A map of entry IDs to entries. + */ + final entries:Map; + + /** + * A map of entry IDs to scripted class names. + */ + final scriptedEntryIds:Map; + + /** + * The version rule to use when loading entries. + * If the entry's version does not match this rule, migration is needed. + */ + final versionRule:thx.semver.VersionRule; + + // public abstract static final instance:BaseRegistry = new BaseRegistry<>(); + + /** + * @param registryId A readable ID for this registry, used when logging. + * @param dataFilePath The path (relative to `assets/data`) to search for JSON files. + */ + public function new(registryId:String, dataFilePath:String, ?versionRule:thx.semver.VersionRule) + { + this.registryId = registryId; + this.dataFilePath = dataFilePath; + this.versionRule = versionRule == null ? '1.0.x' : versionRule; + + this.entries = new Map(); + this.scriptedEntryIds = []; + + // Lazy initialization of singletons should let this get called, + // but we have this check just in case. + if (FlxG.game != null) + { + FlxG.console.registerObject('registry$registryId', this); + } + } + + /** + * TODO: Create a `loadEntriesAsync(onProgress, onComplete)` function. + */ + public function loadEntries():Void + { + clearEntries(); + + // + // SCRIPTED ENTRIES + // + var scriptedEntryClassNames:Array = getScriptedClassNames(); + log('Parsing ${scriptedEntryClassNames.length} scripted entries...'); + + for (entryCls in scriptedEntryClassNames) + { + var entry:Null = null; + try + { + entry = createScriptedEntry(entryCls); + } + catch (e) + { + log('Failed to create scripted entry (${entryCls})'); + continue; + } + + if (entry != null) + { + log('Successfully created scripted entry (${entryCls} = ${entry.id})'); + entries.set(entry.id, entry); + scriptedEntryIds.set(entry.id, entryCls); + } + else + { + log('Failed to create scripted entry (${entryCls})'); + } + } + + // + // UNSCRIPTED ENTRIES + // + var entryIdList:Array = DataAssets.listDataFilesInPath('${dataFilePath}/'); + var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool { + return !entries.exists(entryId); + }); + log('Parsing ${unscriptedEntryIds.length} unscripted entries...'); + for (entryId in unscriptedEntryIds) + { + try + { + var entry:T = createEntry(entryId); + if (entry != null) + { + trace(' Loaded entry data: ${entry}'); + entries.set(entry.id, entry); + } + } + catch (e) + { + // Print the error. + trace(' Failed to load entry data: ${entryId}'); + trace(e); + continue; + } + } + } + + /** + * Retrieve a list of all entry IDs in this registry. + * @return The list of entry IDs. + */ + public function listEntryIds():Array + { + return entries.keys().array(); + } + + /** + * Count the number of entries in this registry. + * @return The number of entries. + */ + public function countEntries():Int + { + return entries.size(); + } + + /** + * Return whether the entry ID is known to have an attached script. + * @param id The ID of the entry. + * @return `true` if the entry has an attached script, `false` otherwise. + */ + public function isScriptedEntry(id:String):Bool + { + return scriptedEntryIds.exists(id); + } + + /** + * Return the class name of the scripted entry with the given ID, if it exists. + * @param id The ID of the entry. + * @return The class name, or `null` if it does not exist. + */ + public function getScriptedEntryClassName(id:String):String + { + return scriptedEntryIds.get(id); + } + + /** + * Return whether the registry has successfully parsed an entry with the given ID. + * @param id The ID of the entry. + * @return `true` if the entry exists, `false` otherwise. + */ + public function hasEntry(id:String):Bool + { + return entries.exists(id); + } + + /** + * Fetch an entry by its ID. + * @param id The ID of the entry to fetch. + * @return The entry, or `null` if it does not exist. + */ + public function fetchEntry(id:String):Null + { + return entries.get(id); + } + + /** + * A list of all entries included in the base game. + * The actual function exists and is auto-generated on each registry at build time. + * @return Array + */ + // public function listBaseGameEntryIds():Array {} + + /** + * A list of all entries that are not included in the base game. + * @return Array + */ + // public function listModdedEntryIds():Array {} + + public function toString():String + { + return 'Registry(' + registryId + ', ${countEntries()} entries)'; + } + + /** + * Retrieve the data for an entry and parse its Semantic Version. + * @param id The ID of the entry. + * @return The entry's version, or `null` if it does not exist or is invalid. + */ + public function fetchEntryVersion(id:String):Null + { + var entryStr:String = loadEntryFile(id).contents; + var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + function log(message:String):Void + { + trace('[' + registryId + '] ' + message); + } + + function loadEntryFile(id:String):JsonFile + { + var entryFilePath:String = Paths.json('${dataFilePath}/${id}'); + var rawJson:String = openfl.Assets.getText(entryFilePath).trim(); + return { + fileName: entryFilePath, + contents: rawJson + }; + } + + function clearEntries():Void + { + for (entry in entries) + { + entry.destroy(); + } + + entries.clear(); + } + + // + // FUNCTIONS TO IMPLEMENT + // + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param id The ID of the entry. + * @return The created entry. + */ + public abstract function parseEntryData(id:String):Null; + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + * @return The created entry. + */ + public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null; + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object, + * accounting for old versions of the data. + * + * NOTE: Extend this function to handle migration. + * @param id The ID of the entry. + * @param version The entry's version (use `fetchEntryVersion(id)`). + * @return The created entry. + */ + public function parseEntryDataWithMigration(id:String, version:Null):Null + { + if (version == null) + { + throw '[${registryId}] Entry ${id} could not be JSON-parsed or does not have a parseable version.'; + } + + // If a version rule is not specified, do not check against it. + if (versionRule == null || VersionUtil.validateVersion(version, versionRule)) + { + return parseEntryData(id); + } + else + { + throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.'; + } + + /* + * An example of what you should override this with: + * + * ```haxe + * if (VersionUtil.validateVersion(version, "0.1.x")) { + * return parseEntryData_v0_1_x(id); + * } else { + * super.parseEntryDataWithMigration(id, version); + * } + * ``` + */ + } + + /** + * Retrieve the list of scripted class names to load. + * @return An array of scripted class names. + */ + abstract function getScriptedClassNames():Array; + + /** + * Create an entry from the given ID. + * @param id + */ + function createEntry(id:String):Null + { + // We enforce that T is Constructible to ensure this is valid. + return new T(id); + } + + /** + * Create a entry, attached to a scripted class, from the given class name. + * @param clsName + */ + abstract function createScriptedEntry(clsName:String):Null; + + function printErrors(errors:Array, id:String = ''):Void + { + trace('[${registryId}] Failed to parse entry data: ${id}'); + + for (error in errors) + { + DataError.printError(error); + } + } +} diff --git a/source/funkin/data/DataError.hx b/source/funkin/data/DataError.hx new file mode 100644 index 00000000..87c99fff --- /dev/null +++ b/source/funkin/data/DataError.hx @@ -0,0 +1,75 @@ +package funkin.data; + +import json2object.Position; +import json2object.Position.Line; +import json2object.Error; + +class DataError +{ + public static function printError(error:Error):Void + { + switch (error) + { + case IncorrectType(vari, expected, pos): + trace(' Expected field "$vari" to be of type "$expected".'); + printPos(pos); + case IncorrectEnumValue(value, expected, pos): + trace(' Invalid enum value (expected "$expected", got "$value")'); + printPos(pos); + case InvalidEnumConstructor(value, expected, pos): + trace(' Invalid enum constructor (epxected "$expected", got "$value")'); + printPos(pos); + case UninitializedVariable(vari, pos): + trace(' Uninitialized variable "$vari"'); + printPos(pos); + case UnknownVariable(vari, pos): + trace(' Unknown variable "$vari"'); + printPos(pos); + case ParserError(message, pos): + trace(' Parsing error: ${message}'); + printPos(pos); + case CustomFunctionException(e, pos): + if (Std.isOfType(e, String)) + { + trace(' ${e}'); + } + else + { + printUnknownError(e); + } + printPos(pos); + default: + printUnknownError(error); + } + } + + public static function printUnknownError(e:Dynamic):Void + { + switch (Type.typeof(e)) + { + case TClass(c): + trace(' [${Type.getClassName(c)}] ${e.toString()}'); + case TEnum(c): + trace(' [${Type.getEnumName(c)}] ${e.toString()}'); + default: + trace(' [${Type.typeof(e)}] ${e.toString()}'); + } + } + + /** + * TODO: Figure out the nicest way to print this. + * Maybe look up how other JSON parsers format their errors? + * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx + */ + static function printPos(pos:Position):Void + { + if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number) + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}'); + } + else + { + trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}'); + } + } +} diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx new file mode 100644 index 00000000..472ffd6e --- /dev/null +++ b/source/funkin/data/DataParse.hx @@ -0,0 +1,319 @@ +package funkin.data; + +import funkin.data.song.importer.FNFLegacyData.LegacyNote; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteData; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection; +import funkin.data.song.importer.FNFLegacyData.LegacyScrollSpeeds; +import haxe.ds.Either; +import hxjsonast.Json; +import hxjsonast.Json.JObjectField; +import hxjsonast.Tools; +import thx.semver.Version; +import thx.semver.VersionRule; + +/** + * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values. + * + * It also allows for validation, since throwing an error in this function will cause the issue to be properly caught. + * Parsing will fail and `parser.errors` will contain the thrown exception. + * + * Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + */ +class DataParse +{ + /** + * `@:jcustomparse(funkin.data.DataParse.stringNotEmpty)` + * @param json Contains the `pos` and `value` of the property. + * @param name The name of the property. + * @throws Error If the property is not a string or is empty. + * @return The string value. + */ + public static function stringNotEmpty(json:Json, name:String):String + { + switch (json.value) + { + case JString(s): + if (s == "") throw 'Expected property $name to be non-empty.'; + return s; + default: + throw 'Expected property $name to be a string, but it was ${json.value}.'; + } + } + + /** + * `@:jcustomparse(funkin.data.DataParse.semverVersion)` + * @param json Contains the `pos` and `value` of the property. + * @param name The name of the property. + * @return The value of the property as a `thx.semver.Version`. + */ + public static function semverVersion(json:Json, name:String):Version + { + switch (json.value) + { + case JString(s): + if (s == "") throw 'Expected version property $name to be non-empty.'; + return s; + default: + throw 'Expected version property $name to be a string, but it was ${json.value}.'; + } + } + + /** + * `@:jcustomparse(funkin.data.DataParse.semverVersionRule)` + * @param json Contains the `pos` and `value` of the property. + * @param name The name of the property. + * @return The value of the property as a `thx.semver.VersionRule`. + */ + public static function semverVersionRule(json:Json, name:String):VersionRule + { + switch (json.value) + { + case JString(s): + if (s == "") throw 'Expected version rule property $name to be non-empty.'; + return s; + default: + throw 'Expected version rule property $name to be a string, but it was ${json.value}.'; + } + } + + /** + * Parser which outputs a Dynamic value, either a object or something else. + * @param json + * @param name + * @return The value of the property. + */ + public static function dynamicValue(json:Json, name:String):Dynamic + { + return Tools.getValue(json); + } + + /** + * Parser which outputs a `Either, LegacyNoteData>`. + * Used by the FNF legacy JSON importer. + */ + public static function eitherLegacyNoteData(json:Json, name:String):Either, LegacyNoteData> + { + switch (json.value) + { + case JArray(values): + return Either.Left(legacyNoteSectionArray(json, name)); + case JObject(fields): + return Either.Right(cast Tools.getValue(json)); + default: + throw 'Expected property $name to be note data, but it was ${json.value}.'; + } + } + + /** + * Parser which outputs a `Either>`. + */ + public static function eitherFloatOrFloats(json:Json, name:String):Null>> + { + switch (json.value) + { + case JNumber(f): + return Either.Left(Std.parseFloat(f)); + case JArray(fields): + return Either.Right(fields.map((field) -> cast Tools.getValue(field))); + default: + throw 'Expected property $name to be one or multiple floats, but it was ${json.value}.'; + } + } + + public static function backdropData(json:Json, name:String):funkin.data.dialogue.conversation.ConversationData.BackdropData + { + switch (json.value) + { + case JObject(fields): + var result:Dynamic = {}; + var backdropType:String = ''; + + for (field in fields) + { + switch (field.name) + { + case 'type': + backdropType = Tools.getValue(field.value); + } + Reflect.setField(result, field.name, Tools.getValue(field.value)); + } + + switch (backdropType) + { + case 'solid': + return SOLID(result); + default: + throw 'Expected Backdrop property $name to be specify a valid "type", but it was "${backdropType}".'; + } + + return null; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function outroData(json:Json, name:String):Null + { + switch (json.value) + { + case JObject(fields): + var result:Dynamic = {}; + var outroType:String = ''; + + for (field in fields) + { + switch (field.name) + { + case 'type': + outroType = Tools.getValue(field.value); + } + Reflect.setField(result, field.name, Tools.getValue(field.value)); + } + + switch (outroType) + { + case 'none': + return NONE(result); + case 'fade': + return FADE(result); + default: + throw 'Expected Outro property $name to be specify a valid "type", but it was "${outroType}".'; + } + return null; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + /** + * Parser which outputs a `Either`. + * Used by the FNF legacy JSON importer. + */ + public static function eitherLegacyScrollSpeeds(json:Json, name:String):Either + { + switch (json.value) + { + case JNumber(f): + return Either.Left(Std.parseFloat(f)); + case JObject(fields): + return Either.Right(cast Tools.getValue(json)); + default: + throw 'Expected property $name to be scroll speeds, but it was ${json.value}.'; + } + } + + /** + * Array of JSON fields `[{key, value}, {key, value}]` to a Dynamic object `{key:value, key:value}`. + * @param fields + * @return Dynamic + */ + static function jsonFieldsToDynamicObject(fields:Array):Dynamic + { + var result:Dynamic = {}; + for (field in fields) + { + Reflect.setField(result, field.name, Tools.getValue(field.value)); + } + return result; + } + + /** + * Array of JSON elements `[Json, Json, Json]` to a Dynamic array `[String, Object, Int, Array]` + * @param jsons + * @return Array + */ + static function jsonArrayToDynamicArray(jsons:Array):Array> + { + return [for (json in jsons) Tools.getValue(json)]; + } + + static function legacyNoteSectionArray(json:Json, name:String):Array + { + switch (json.value) + { + case JArray(values): + return [for (value in values) legacyNoteSection(value, name)]; + default: + throw 'Expected property to be an array, but it was ${json.value}.'; + } + } + + static function legacyNoteSection(json:Json, name:String):LegacyNoteSection + { + switch (json.value) + { + case JObject(fields): + var result:LegacyNoteSection = + { + mustHitSection: false, + sectionNotes: [], + }; + for (field in fields) + { + switch (field.name) + { + case 'sectionNotes': + result.sectionNotes = legacyNotes(field.value, field.name); + + case 'mustHitSection': + result.mustHitSection = Tools.getValue(field.value); + case 'typeOfSection': + result.typeOfSection = Tools.getValue(field.value); + case 'lengthInSteps': + result.lengthInSteps = Tools.getValue(field.value); + case 'changeBPM': + result.changeBPM = Tools.getValue(field.value); + case 'bpm': + result.bpm = Tools.getValue(field.value); + } + } + return result; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function legacyNoteData(json:Json, name:String):LegacyNoteData + { + switch (json.value) + { + case JObject(fields): + var result = {}; + for (field in fields) + { + Reflect.setField(result, field.name, legacyNoteSectionArray(field.value, field.name)); + } + return result; + default: + throw 'Expected property $name to be an object, but it was ${json.value}.'; + } + } + + public static function legacyNotes(json:Json, name:String):Array + { + switch (json.value) + { + case JArray(values): + return [for (value in values) legacyNote(value, name)]; + default: + throw 'Expected property $name to be an array of notes, but it was ${json.value}.'; + } + } + + public static function legacyNote(json:Json, name:String):LegacyNote + { + switch (json.value) + { + case JArray(values): + var time:Null = values[0] == null ? null : Tools.getValue(values[0]); + var data:Null = values[1] == null ? null : Tools.getValue(values[1]); + var length:Null = values[2] == null ? null : Tools.getValue(values[2]); + var alt:Null = values[3] == null ? null : Tools.getValue(values[3]); + + return new LegacyNote(time, data, length, alt); + // return null; + default: + throw 'Expected property $name to be a note, but it was ${json.value}.'; + } + } +} diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx new file mode 100644 index 00000000..e277cb01 --- /dev/null +++ b/source/funkin/data/DataWrite.hx @@ -0,0 +1,60 @@ +package funkin.data; + +import funkin.util.SerializerUtil; +import thx.semver.Version; +import thx.semver.VersionRule; +import haxe.ds.Either; + +/** + * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON. + * + * Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + * + * NOTE: Result must include quotation marks if the value is a string! json2object will not add them for you! + */ +class DataWrite +{ + /** + * `@:jcustomwrite(funkin.data.DataWrite.dynamicValue)` + * @param value + * @return String + */ + public static function dynamicValue(value:Dynamic):String + { + // Is this cheating? Yes. Do I care? No. + return SerializerUtil.toJSON(value); + } + + /** + * + * `@:jcustomwrite(funkin.data.DataWrite.semverVersion)` + */ + public static function semverVersion(value:Version):String + { + return '"${value.toString()}"'; + } + + /** + * `@:jcustomwrite(funkin.data.DataWrite.semverVersionRule)` + */ + public static function semverVersionRule(value:VersionRule):String + { + return '"${value.toString()}"'; + } + + /** + * `@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)` + */ + public static function eitherFloatOrFloats(value:Null>>):String + { + switch (value) + { + case null: + return '${1.0}'; + case Left(inner): + return '$inner'; + case Right(inner): + return dynamicValue(inner); + } + } +} diff --git a/source/funkin/data/DefaultRegistryImpl.hx b/source/funkin/data/DefaultRegistryImpl.hx new file mode 100644 index 00000000..f7c137fb --- /dev/null +++ b/source/funkin/data/DefaultRegistryImpl.hx @@ -0,0 +1,9 @@ +package funkin.data; + +/** + * An interface which automatically implements essential fields for a class extending `BaseRegistry`. + * + * @see `funkin.data.BaseRegistry` + */ +@:autoBuild(funkin.util.macro.RegistryMacro.buildRegistry()) +interface DefaultRegistryImpl {} diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx new file mode 100644 index 00000000..70f464f8 --- /dev/null +++ b/source/funkin/data/IRegistryEntry.hx @@ -0,0 +1,21 @@ +package funkin.data; + +/** + * An interface defining the necessary functions for a registry entry. + * A `String->Void` constructor is also mandatory, but enforced elsewhere. + * @param T The JSON data type of the registry entry. + */ +@:autoBuild(funkin.util.macro.RegistryMacro.buildEntry()) +interface IRegistryEntry +{ + public final id:String; + + // public function new(id:String):Void; + public function destroy():Void; + public function toString():String; + + // Can't make an interface field private I guess. + public final _data:T; + // Can't make a static field required by an interface I guess. + // private static function _fetchData(id:String):Null; +} diff --git a/source/funkin/data/JsonFile.hx b/source/funkin/data/JsonFile.hx new file mode 100644 index 00000000..421ffc22 --- /dev/null +++ b/source/funkin/data/JsonFile.hx @@ -0,0 +1,10 @@ +package funkin.data; + +/** + * A pair of a file name and its contents. + */ +typedef JsonFile = +{ + fileName:String, + contents:String +}; diff --git a/source/funkin/data/README.md b/source/funkin/data/README.md new file mode 100644 index 00000000..58fa6fa5 --- /dev/null +++ b/source/funkin/data/README.md @@ -0,0 +1,21 @@ +# funkin.data + +Data structures are parsed using `json2object`, which uses macros to generate parser classes based on anonymous structures OR classes. + +Parsing errors will be returned in `parser.errors`. See `json2object.Error` for an enumeration of possible parsing errors. If an error occurred, `parser.value` will be null. + +The properties of these anonymous structures can have their behavior changed with annotations: + +- `@:optional`: The value is optional and will not throw a parsing error if it is not present in the JSON data. +- `@:default("test")`: If the value is optional, this value will be used instead of `null`. Replace `"test"` with a value of the property's type. +- `@:default(auto)`: If the value is an anonymous structure with `json2object` annotations, each field will be initialized to its default value. +- `@:jignored`: This value will be ignored by the parser. Their presence will not be checked in the JSON data and their values will not be parsed. +- `@:alias`: Choose the name the value will use in the JSON data to be separate from the property name. Useful if the desired name is a reserved word like `public`. +- `@:jcustomparse`: Provide a custom function for parsing from a JSON string into a value. + - Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property. + - `hxjsonast.Json` contains a `pos` and a `value`, with `value` being an enum: https://nadako.github.io/hxjsonast/hxjsonast/JsonValue.html + - Errors thrown in this function will cause a parsing error (`CustomFunctionException`) along with a position! + - Make sure to provide the FULLY QUALIFIED path to the custom function. +- `@:jcustomwrite`: Provide a custom function for serializing the property into a string for storage as JSON. + - Functions must be of the signature `(T) -> String`, where `T` is the type of the property. + diff --git a/source/funkin/data/animation/AnimationData.hx b/source/funkin/data/animation/AnimationData.hx new file mode 100644 index 00000000..8f977219 --- /dev/null +++ b/source/funkin/data/animation/AnimationData.hx @@ -0,0 +1,140 @@ +package funkin.data.animation; + +class AnimationDataUtil +{ + public static function toNamed(data:UnnamedAnimationData, ?name:String = ""):AnimationData + { + return { + name: name, + prefix: data.prefix, + assetPath: data.assetPath, + offsets: data.offsets, + looped: data.looped, + flipX: data.flipX, + flipY: data.flipY, + frameRate: data.frameRate, + frameIndices: data.frameIndices + }; + } + + /** + * @param data + * @param name (adds index to name) + * @return Array + */ + public static function toNamedArray(data:Array, ?name:String = ""):Array + { + return data.mapi(function(animItem, ind) return toNamed(animItem, '$name$ind')); + } + + public static function toUnnamed(data:AnimationData):UnnamedAnimationData + { + return { + prefix: data.prefix, + assetPath: data.assetPath, + offsets: data.offsets, + looped: data.looped, + flipX: data.flipX, + flipY: data.flipY, + frameRate: data.frameRate, + frameIndices: data.frameIndices + }; + } + + public static function toUnnamedArray(data:Array):Array + { + return data.map(toUnnamed); + } +} + +/** + * A data structure representing an animation in a spritesheet. + * This is a generic data structure used by characters, stage props, and more! + * BE CAREFUL when changing it. + */ +typedef AnimationData = +{ + > UnnamedAnimationData, + + /** + * The name for the animation. + * This should match the animation name queried by the game; + * for example, characters need animations with names `idle`, `singDOWN`, `singUPmiss`, etc. + */ + var name:String; +} + +/** + * A data structure representing an animation in a spritesheet. + * This animation doesn't specify a name, that's presumably specified by the parent data structure. + */ +typedef UnnamedAnimationData = +{ + /** + * The prefix for the frames of the animation as defined by the XML file. + * This will may or may not differ from the `name` of the animation, + * depending on how your animator organized their FLA or whatever. + * + * NOTE: For Sparrow animations, this is not optional, but for Packer animations it is. + */ + @:optional + var prefix:String; + + /** + * Optionally specify an asset path to use for this specific animation. + * ONLY for use by MultiSparrow characters. + * @default The assetPath of the parent sprite + */ + @:optional + var assetPath:Null; + + /** + * Offset the character's position by this amount when playing this animation. + * @default [0, 0] + */ + @:default([0, 0]) + @:optional + var offsets:Null>; + + /** + * Whether the animation should loop when it finishes. + * @default false + */ + @:default(false) + @:optional + var looped:Bool; + + /** + * Whether the animation's sprites should be flipped horizontally. + * @default false + */ + @:default(false) + @:optional + var flipX:Null; + + /** + * Whether the animation's sprites should be flipped vertically. + * @default false + */ + @:default(false) + @:optional + var flipY:Null; + + /** + * The frame rate of the animation. + * @default 24 + */ + @:default(24) + @:optional + var frameRate:Null; + + /** + * If you want this animation to use only certain frames of an animation with a given prefix, + * select them here. + * @example [0, 1, 2, 3] (use only the first four frames) + * @default [] (all frames) + */ + @:default([]) + @:optional + var frameIndices:Null>; +} diff --git a/source/funkin/data/character/TODO.md b/source/funkin/data/character/TODO.md new file mode 100644 index 00000000..e69de29b diff --git a/source/funkin/data/dialogue/conversation/CHANGELOG.md b/source/funkin/data/dialogue/conversation/CHANGELOG.md new file mode 100644 index 00000000..f9ab99fa --- /dev/null +++ b/source/funkin/data/dialogue/conversation/CHANGELOG.md @@ -0,0 +1,9 @@ +# Dialogue Conversation Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/dialogue/conversation/ConversationData.hx b/source/funkin/data/dialogue/conversation/ConversationData.hx new file mode 100644 index 00000000..65051983 --- /dev/null +++ b/source/funkin/data/dialogue/conversation/ConversationData.hx @@ -0,0 +1,166 @@ +package funkin.data.dialogue.conversation; + +/** + * A type definition for the data for a specific conversation. + * It includes things like what dialogue boxes to use, what text to display, and what animations to play. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef ConversationData = +{ + /** + * Semantic version for conversation data. + */ + public var version:String; + + /** + * Data on the backdrop for the conversation. + */ + @:jcustomparse(funkin.data.DataParse.backdropData) + public var backdrop:BackdropData; + + /** + * Data on the outro for the conversation. + */ + @:jcustomparse(funkin.data.DataParse.outroData) + @:optional + public var outro:Null; + + /** + * Data on the music for the conversation. + */ + @:optional + public var music:Null; + + /** + * Data for each line of dialogue in the conversation. + */ + public var dialogue:Array; +} + +/** + * Data on the backdrop for the conversation, behind the dialogue box. + * A custom parser distinguishes between backdrop types based on the `type` field. + */ +enum BackdropData +{ + SOLID(data:BackdropData_Solid); // 'solid' +} + +/** + * Data for a Solid color backdrop. + */ +typedef BackdropData_Solid = +{ + /** + * Used to distinguish between backdrop types. Should always be `solid` for this type. + */ + var type:String; + + /** + * The color of the backdrop. + */ + var color:String; + + /** + * Fade-in time for the backdrop. + * @default No fade-in + */ + @:optional + @:default(0.0) + var fadeTime:Float; +}; + +enum OutroData +{ + NONE(data:OutroData_None); // 'none' + FADE(data:OutroData_Fade); // 'fade' +} + +typedef OutroData_None = +{ + /** + * Used to distinguish between outro types. Should always be `none` for this type. + */ + var type:String; +} + +typedef OutroData_Fade = +{ + /** + * Used to distinguish between outro types. Should always be `fade` for this type. + */ + var type:String; + + /** + * The time to fade out the conversation. + * @default 1 second + */ + @:optional + @:default(1.0) + var fadeTime:Float; +} + +typedef MusicData = +{ + /** + * The asset to play for the music. + */ + var asset:String; + + /** + * The time to fade in the music. + */ + @:optional + @:default(0.0) + var fadeTime:Float; + + @:optional + @:default(false) + var looped:Bool; +}; + +/** + * Data on a single line of dialogue in a conversation. + */ +typedef DialogueEntryData = +{ + /** + * Which speaker is speaking. + * @see `SpeakerData.hx` + */ + public var speaker:String; + + /** + * The animation the speaker should play for this line of dialogue. + */ + public var speakerAnimation:String; + + /** + * Which dialogue box to use for this line of dialogue. + * @see `DialogueBoxData.hx` + */ + public var box:String; + + /** + * Which animation to play for the dialogue box. + */ + public var boxAnimation:String; + + /** + * The text that will display for this line of dialogue. + * Text will automatically wrap. + * When the user advances the dialogue, the next entry in the array will concatenate on. + * Advancing when the last entry is displayed will move to the next `DialogueEntryData`, + * or end the conversation if there are no more. + */ + public var text:Array; + + /** + * The relative speed at which text gets "typed out". + * Setting `speed` to `1.5` would make it look like the character is speaking quickly, + * and setting `speed` to `0.5` would make it look like the character is emphasizing each word. + */ + @:optional + @:default(1.0) + public var speed:Float; +}; diff --git a/source/funkin/data/dialogue/conversation/ConversationRegistry.hx b/source/funkin/data/dialogue/conversation/ConversationRegistry.hx new file mode 100644 index 00000000..2f79cf97 --- /dev/null +++ b/source/funkin/data/dialogue/conversation/ConversationRegistry.hx @@ -0,0 +1,23 @@ +package funkin.data.dialogue.conversation; + +import funkin.play.cutscene.dialogue.Conversation; +import funkin.play.cutscene.dialogue.ScriptedConversation; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class ConversationRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the dialogue box data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateConversationData()` function. + */ + public static final CONVERSATION_DATA_VERSION:thx.semver.Version = "1.0.0"; + + public static final CONVERSATION_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + public function new() + { + super('CONVERSATION', 'dialogue/conversations', CONVERSATION_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/dialogue/dialoguebox/CHANGELOG.md b/source/funkin/data/dialogue/dialoguebox/CHANGELOG.md new file mode 100644 index 00000000..06604734 --- /dev/null +++ b/source/funkin/data/dialogue/dialoguebox/CHANGELOG.md @@ -0,0 +1,13 @@ +# Dialogue Box Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] +### Added +- Added an option to specify the font used by the dialogue box. Defaults to `Arial` if unspecified. + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/dialogue/dialoguebox/DialogueBoxData.hx b/source/funkin/data/dialogue/dialoguebox/DialogueBoxData.hx new file mode 100644 index 00000000..228d391c --- /dev/null +++ b/source/funkin/data/dialogue/dialoguebox/DialogueBoxData.hx @@ -0,0 +1,128 @@ +package funkin.data.dialogue.dialoguebox; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data for a conversation text box. + * It includes things like the sprite to use, and the font and color for the text. + * The actual text is included in the ConversationData. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef DialogueBoxData = +{ + /** + * Semantic version for dialogue box data. + */ + public var version:String; + + /** + * A human readable name for the dialogue box type. + */ + public var name:String; + + /** + * The asset path for the sprite to use for the dialogue box. + * Takes a static sprite or a sprite sheet. + */ + public var assetPath:String; + + /** + * Whether to horizontally flip the dialogue box sprite. + */ + @:optional + @:default(false) + public var flipX:Bool; + + /** + * Whether to vertically flip the dialogue box sprite. + */ + @:optional + @:default(false) + public var flipY:Bool; + + /** + * Whether to disable anti-aliasing for the dialogue box sprite. + */ + @:optional + @:default(false) + public var isPixel:Bool; + + /** + * The relative horizontal and vertical offsets for the dialogue box sprite. + */ + @:optional + @:default([0, 0]) + public var offsets:Array; + + /** + * Info about how to display text in the dialogue box. + */ + public var text:DialogueBoxTextData; + + /** + * Multiply the size of the dialogue box sprite. + */ + @:optional + @:default(1) + public var scale:Float; + + /** + * If using a spritesheet for the dialogue box, the animations to use. + */ + @:optional + @:default([]) + public var animations:Array; +} + +typedef DialogueBoxTextData = +{ + /** + * The position of the text in teh box. + */ + @:optional + @:default([0, 0]) + var offsets:Array; + + /** + * The width of the + */ + @:optional + @:default(300) + var width:Int; + + /** + * The font size to use for the text. + */ + @:optional + @:default(32) + var size:Int; + + /** + * The color to use for the text. + * Use a string that can be translated to a color, like `#FF0000` for red. + */ + @:optional + @:default("#000000") + var color:String; + + /** + * The font to use for the text. + * @since v1.1.0 + * @default `Arial`, make sure to switch this! + */ + @:optional + @:default("Arial") + var fontFamily:String; + + /** + * The color to use for the shadow of the text. Use transparent to disable. + */ + var shadowColor:String; + + /** + * The width of the shadow of the text. + */ + @:optional + @:default(0) + var shadowWidth:Int; +}; diff --git a/source/funkin/data/dialogue/dialoguebox/DialogueBoxRegistry.hx b/source/funkin/data/dialogue/dialoguebox/DialogueBoxRegistry.hx new file mode 100644 index 00000000..681f8566 --- /dev/null +++ b/source/funkin/data/dialogue/dialoguebox/DialogueBoxRegistry.hx @@ -0,0 +1,24 @@ +package funkin.data.dialogue.dialoguebox; + +import funkin.play.cutscene.dialogue.DialogueBox; +import funkin.data.dialogue.dialoguebox.DialogueBoxData; +import funkin.play.cutscene.dialogue.ScriptedDialogueBox; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class DialogueBoxRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the dialogue box data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateDialogueBoxData()` function. + */ + public static final DIALOGUEBOX_DATA_VERSION:thx.semver.Version = "1.1.0"; + + public static final DIALOGUEBOX_DATA_VERSION_RULE:thx.semver.VersionRule = ">=1.0.0 <1.2.0"; + + public function new() + { + super('DIALOGUEBOX', 'dialogue/boxes', DIALOGUEBOX_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/dialogue/speaker/CHANGELOG.md b/source/funkin/data/dialogue/speaker/CHANGELOG.md new file mode 100644 index 00000000..284e690e --- /dev/null +++ b/source/funkin/data/dialogue/speaker/CHANGELOG.md @@ -0,0 +1,9 @@ +# Dialogue Speaker Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/dialogue/speaker/SpeakerData.hx b/source/funkin/data/dialogue/speaker/SpeakerData.hx new file mode 100644 index 00000000..458f91ca --- /dev/null +++ b/source/funkin/data/dialogue/speaker/SpeakerData.hx @@ -0,0 +1,68 @@ +package funkin.data.dialogue.speaker; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for a specific speaker in a conversation. + * It includes things like what sprite to use and its available animations. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef SpeakerData = +{ + /** + * Semantic version of the speaker data. + */ + public var version:String; + + /** + * A human-readable name for the speaker. + */ + public var name:String; + + /** + * The path to the asset to use for the speaker's sprite. + */ + public var assetPath:String; + + /** + * Whether the sprite should be flipped horizontally. + */ + @:optional + @:default(false) + public var flipX:Bool; + + /** + * Whether the sprite should be flipped vertically. + */ + @:optional + @:default(false) + public var flipY:Bool; + + /** + * Whether to disable anti-aliasing for the dialogue box sprite. + */ + @:optional + @:default(false) + public var isPixel:Bool; + + /** + * The offsets to apply to the sprite's position. + */ + @:optional + @:default([0, 0]) + public var offsets:Array; + + /** + * The scale to apply to the sprite. + */ + @:optional + @:default(1.0) + public var scale:Float; + + /** + * The available animations for the speaker. + */ + @:optional + @:default([]) + public var animations:Array; +} diff --git a/source/funkin/data/dialogue/speaker/SpeakerRegistry.hx b/source/funkin/data/dialogue/speaker/SpeakerRegistry.hx new file mode 100644 index 00000000..96e85aca --- /dev/null +++ b/source/funkin/data/dialogue/speaker/SpeakerRegistry.hx @@ -0,0 +1,23 @@ +package funkin.data.dialogue.speaker; + +import funkin.play.cutscene.dialogue.Speaker; +import funkin.play.cutscene.dialogue.ScriptedSpeaker; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class SpeakerRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the speaker data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateSpeakerData()` function. + */ + public static final SPEAKER_DATA_VERSION:thx.semver.Version = "1.0.0"; + + public static final SPEAKER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + public function new() + { + super('SPEAKER', 'dialogue/speakers', SPEAKER_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/event/SongEventRegistry.hx b/source/funkin/data/event/SongEventRegistry.hx new file mode 100644 index 00000000..772465cf --- /dev/null +++ b/source/funkin/data/event/SongEventRegistry.hx @@ -0,0 +1,184 @@ +package funkin.data.event; + +import funkin.play.event.SongEvent; +import funkin.data.song.SongData.SongEventData; +import funkin.util.macro.ClassMacro; +import funkin.play.event.ScriptedSongEvent; + +/** + * This class statically handles the parsing of internal and scripted song event handlers. + */ +class SongEventRegistry +{ + /** + * Every built-in event class must be added to this list. + * Thankfully, with the power of `SongEventMacro`, this is done automatically. + */ + static final BUILTIN_EVENTS:List> = ClassMacro.listSubclassesOf(SongEvent); + + /** + * Map of internal handlers for song events. + * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`. + */ + static final eventCache:Map = new Map(); + + public static function loadEventCache():Void + { + clearEventCache(); + + // + // BASE GAME EVENTS + // + registerBaseEvents(); + registerScriptedEvents(); + } + + static function registerBaseEvents() + { + trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...'); + for (eventCls in BUILTIN_EVENTS) + { + var eventClsName:String = Type.getClassName(eventCls); + if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent') continue; + + var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]); + + if (event != null) + { + trace(' Loaded built-in song event: ${event.id}'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to load built-in song event: ${Type.getClassName(eventCls)}'); + } + } + } + + static function registerScriptedEvents() + { + var scriptedEventClassNames:Array = ScriptedSongEvent.listScriptClasses(); + trace('Instantiating ${scriptedEventClassNames.length} scripted song events...'); + if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return; + + for (eventCls in scriptedEventClassNames) + { + var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN"); + + if (event != null) + { + trace(' Loaded scripted song event: ${event.id}'); + eventCache.set(event.id, event); + } + else + { + trace(' Failed to instantiate scripted song event class: ${eventCls}'); + } + } + } + + public static function listEventIds():Array + { + return eventCache.keys().array(); + } + + public static function listEvents():Array + { + return eventCache.values(); + } + + public static function getEvent(id:String):SongEvent + { + return eventCache.get(id); + } + + public static function getEventSchema(id:String):SongEventSchema + { + var event:SongEvent = getEvent(id); + if (event == null) return null; + + return event.getEventSchema(); + } + + static function clearEventCache() + { + eventCache.clear(); + } + + public static function handleEvent(data:SongEventData):Void + { + var eventKind:String = data.eventKind; + var eventHandler:SongEvent = eventCache.get(eventKind); + + if (eventHandler != null) + { + eventHandler.handleEvent(data); + } + else + { + trace('WARNING: No event handler for event with kind: ${eventKind}'); + } + + data.activated = true; + } + + public static inline function handleEvents(events:Array):Void + { + for (event in events) + { + handleEvent(event); + } + } + + /** + * Given a list of song events and the current timestamp, + * return a list of events that should be handled. + */ + public static function queryEvents(events:Array, currentTime:Float):Array + { + return events.filter(function(event:SongEventData):Bool { + // If the event is already activated, don't activate it again. + if (event.activated) return false; + + // If the event is in the future, don't activate it. + if (event.time > currentTime) return false; + + return true; + }); + } + + /** + * The currentTime has jumped far ahead or back. + * If we moved back in time, we need to reset all the events in that space. + * If we moved forward in time, we need to skip all the events in that space. + */ + public static function handleSkippedEvents(events:Array, currentTime:Float):Void + { + for (event in events) + { + // Deactivate future events. + if (event.time > currentTime) + { + event.activated = false; + } + + // Skip past events. + if (event.time < currentTime) + { + event.activated = true; + } + } + } + + /** + * Reset activation of all the provided events. + */ + public static function resetEvents(events:Array):Void + { + for (event in events) + { + event.activated = false; + // TODO: Add an onReset() method to SongEvent? + } + } +} diff --git a/source/funkin/data/event/SongEventSchema.hx b/source/funkin/data/event/SongEventSchema.hx new file mode 100644 index 00000000..72dac337 --- /dev/null +++ b/source/funkin/data/event/SongEventSchema.hx @@ -0,0 +1,171 @@ +package funkin.data.event; + +@:forward(name, title, type, keys, min, max, step, units, defaultValue, iterator) +abstract SongEventSchema(SongEventSchemaRaw) +{ + /** + * These units look better when placed immediately next to the value, rather than after a space. + */ + static final NO_SPACE_UNITS:Array = ['x', '°', '%']; + + public function new(?fields:Array) + { + this = fields; + } + + @:arrayAccess + public function getByName(name:String):SongEventSchemaField + { + for (field in this) + { + if (field.name == name) return field; + } + + return null; + } + + public function getFirstField():SongEventSchemaField + { + return this[0]; + } + + @:arrayAccess + public inline function get(key:Int) + { + return this[key]; + } + + @:arrayAccess + public inline function arrayWrite(k:Int, v:SongEventSchemaField):SongEventSchemaField + { + return this[k] = v; + } + + public function stringifyFieldValue(name:String, value:Dynamic, addUnits:Bool = true):String + { + var field:SongEventSchemaField = getByName(name); + if (field == null) return 'Unknown'; + + switch (field.type) + { + case SongEventFieldType.STRING: + return Std.string(value); + case SongEventFieldType.INTEGER: + var returnValue:String = Std.string(value); + if (addUnits) return addUnitsToString(returnValue, field); + return returnValue; + case SongEventFieldType.FLOAT: + var returnValue:String = Std.string(value); + if (addUnits) return addUnitsToString(returnValue, field); + return returnValue; + case SongEventFieldType.BOOL: + return Std.string(value); + case SongEventFieldType.ENUM: + var valueString:String = Std.string(value); + for (key in field.keys.keys()) + { + // Comparing these values as strings because comparing Dynamic variables is jank. + if (Std.string(field.keys.get(key)) == valueString) return key; + } + return valueString; + default: + return 'Unknown'; + } + } + + function addUnitsToString(value:String, field:SongEventSchemaField) + { + if (field.units == null || field.units == '') return value; + + var unit:String = field.units; + + return value + (NO_SPACE_UNITS.contains(unit) ? '' : ' ') + '${unit}'; + } +} + +typedef SongEventSchemaRaw = Array; + +typedef SongEventSchemaField = +{ + /** + * The name of the property as it should be saved in the event data. + */ + name:String, + + /** + * The title of the field to display in the UI. + */ + title:String, + + /** + * The type of the field. + */ + type:SongEventFieldType, + + /** + * Used only for ENUM values. + * The key is the display name and the value is the actual value. + */ + ?keys:Map, + + /** + * Used for INTEGER and FLOAT values. + * The minimum value that can be entered. + * @default No minimum + */ + ?min:Float, + + /** + * Used for INTEGER and FLOAT values. + * The maximum value that can be entered. + * @default No maximum + */ + ?max:Float, + + /** + * Used for INTEGER and FLOAT values. + * The step value that will be used when incrementing/decrementing the value. + * @default `0.1` + */ + ?step:Float, + + /** + * Used for INTEGER and FLOAT values. + * The units that the value is expressed in (pixels, percent, etc). + */ + ?units:String, + + /** + * An optional default value for the field. + */ + ?defaultValue:Dynamic, +} + +enum abstract SongEventFieldType(String) from String to String +{ + /** + * The STRING type will display as a text field. + */ + var STRING = "string"; + + /** + * The INTEGER type will display as a text field that only accepts numbers. + */ + var INTEGER = "integer"; + + /** + * The FLOAT type will display as a text field that only accepts numbers. + */ + var FLOAT = "float"; + + /** + * The BOOL type will display as a checkbox. + */ + var BOOL = "bool"; + + /** + * The ENUM type will display as a dropdown. + * Make sure to specify the `keys` field in the schema. + */ + var ENUM = "enum"; +} diff --git a/source/funkin/data/freeplay/album/AlbumData.hx b/source/funkin/data/freeplay/album/AlbumData.hx new file mode 100644 index 00000000..2a74561c --- /dev/null +++ b/source/funkin/data/freeplay/album/AlbumData.hx @@ -0,0 +1,52 @@ +package funkin.data.freeplay.album; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data for an album of songs. + * It includes things like what graphics to display in Freeplay. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef AlbumData = +{ + /** + * Semantic version for album data. + */ + public var version:String; + + /** + * Readable name of the album. + */ + public var name:String; + + /** + * Readable name of the artist(s) of the album. + */ + public var artists:Array; + + /** + * Asset key for the album art. + * The album art will be displayed in Freeplay. + */ + public var albumArtAsset:String; + + /** + * Asset key for the album title. + * The album title will be displayed below the album art in Freeplay. + */ + public var albumTitleAsset:String; + + /** + * Offsets for the album title. + */ + @:optional + @:default([0, 0]) + public var albumTitleOffsets:Null>; + + /** + * An optional array of animations for the album title. + */ + @:optional + @:default([]) + public var albumTitleAnimations:Array; +} diff --git a/source/funkin/data/freeplay/album/AlbumRegistry.hx b/source/funkin/data/freeplay/album/AlbumRegistry.hx new file mode 100644 index 00000000..a7c3d399 --- /dev/null +++ b/source/funkin/data/freeplay/album/AlbumRegistry.hx @@ -0,0 +1,24 @@ +package funkin.data.freeplay.album; + +import funkin.ui.freeplay.Album; +import funkin.data.freeplay.album.AlbumData; +import funkin.ui.freeplay.ScriptedAlbum; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class AlbumRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the album data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateAlbumData()` function. + */ + public static final ALBUM_DATA_VERSION:thx.semver.Version = '1.0.0'; + + public static final ALBUM_DATA_VERSION_RULE:thx.semver.VersionRule = '1.0.x'; + + public function new() + { + super('ALBUM', 'ui/freeplay/albums', ALBUM_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/freeplay/album/CHANGELOG.md b/source/funkin/data/freeplay/album/CHANGELOG.md new file mode 100644 index 00000000..ae307465 --- /dev/null +++ b/source/funkin/data/freeplay/album/CHANGELOG.md @@ -0,0 +1,9 @@ +# Freeplay Album Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/freeplay/player/CHANGELOG.md b/source/funkin/data/freeplay/player/CHANGELOG.md new file mode 100644 index 00000000..ace9a27c --- /dev/null +++ b/source/funkin/data/freeplay/player/CHANGELOG.md @@ -0,0 +1,14 @@ +# Freeplay Playable Character Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.1] +### Added +- Added `stickerPack` to specify which sticker pack to use when transitioning into and out of song featuring this character. + - If the value isn't present, it will use a default sticker pack instead. + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx new file mode 100644 index 00000000..b6bf8e01 --- /dev/null +++ b/source/funkin/data/freeplay/player/PlayerData.hx @@ -0,0 +1,421 @@ +package funkin.data.freeplay.player; + +import funkin.data.animation.AnimationData; + +@:nullSafety +class PlayerData +{ + /** + * The sematic version number of the player data JSON format. + * Supports fancy comparisons like NPM does it's neat. + */ + @:default(funkin.data.freeplay.player.PlayerRegistry.PLAYER_DATA_VERSION) + public var version:String; + + /** + * A readable name for this playable character. + */ + public var name:String = 'Unknown'; + + /** + * The character IDs this character is associated with. + * Only songs that use these characters will show up in Freeplay. + */ + @:default([]) + public var ownedChars:Array = []; + + /** + * Whether to show songs with character IDs that aren't associated with any specific character. + */ + @:optional + @:default(false) + public var showUnownedChars:Bool = false; + + /** + * The default sticker pack to use for songs featuring this playable character. + * Can be overridden by specific songs. + * @default `default` + */ + @:optional + @:default(funkin.util.Constants.DEFAULT_STICKER_PACK) + public var stickerPack:String = Constants.DEFAULT_STICKER_PACK; + + /** + * Which freeplay style to use for this character. + */ + @:optional + @:default("bf") + public var freeplayStyle:String = Constants.DEFAULT_FREEPLAY_STYLE; + + /** + * Data for displaying this character in the Freeplay menu. + * If null, display no DJ. + */ + @:optional + public var freeplayDJ:Null = null; + + /** + * Data for displaying this character in the Character Select menu. + * If null, exclude from Character Select. + */ + @:optional + public var charSelect:Null = null; + + /** + * Data for displaying this character in the results screen. + */ + @:optional + public var results:Null = null; + + /** + * Whether this character is unlocked by default. + * Use a ScriptedPlayableCharacter to add custom logic. + */ + @:optional + @:default(true) + public var unlocked:Bool = true; + + public function new() + { + this.version = PlayerRegistry.PLAYER_DATA_VERSION; + } + + /** + * Convert this StageData into a JSON string. + */ + public function serialize(pretty:Bool = true):String + { + // Update generatedBy and version before writing. + updateVersionToLatest(); + + var writer = new json2object.JsonWriter(); + return writer.write(this, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.version = PlayerRegistry.PLAYER_DATA_VERSION; + } +} + +class PlayerFreeplayDJData +{ + var assetPath:String; + var animations:Array; + + @:optional + @:default("BOYFRIEND") + var text1:String; + + @:optional + @:default("HOT BLOODED IN MORE WAYS THAN ONE") + var text2:String; + + @:optional + @:default("PROTECT YO NUTS") + var text3:String; + + @:jignored + var animationMap:Map; + + @:jignored + var prefixToOffsetsMap:Map>; + + @:optional + var charSelect:Null; + + @:optional + var cartoon:Null; + + @:optional + var fistPump:Null; + + public function new() + { + animationMap = new Map(); + } + + function mapAnimations() + { + if (animationMap == null) animationMap = new Map(); + if (prefixToOffsetsMap == null) prefixToOffsetsMap = new Map(); + + animationMap.clear(); + prefixToOffsetsMap.clear(); + for (anim in animations) + { + animationMap.set(anim.name, anim); + prefixToOffsetsMap.set(anim.prefix, anim.offsets); + } + } + + public function getAtlasPath():String + { + return Paths.animateAtlas(assetPath); + } + + public function getFreeplayDJText(index:Int):String + { + switch (index) + { + case 1: + return text1; + case 2: + return text2; + case 3: + return text3; + default: + return ''; + } + } + + public function getAnimationPrefix(name:String):Null + { + if (animationMap.size() == 0) mapAnimations(); + + var anim = animationMap.get(name); + if (anim == null) return null; + return anim.prefix; + } + + public function getAnimationOffsetsByPrefix(?prefix:String):Array + { + if (prefixToOffsetsMap.size() == 0) mapAnimations(); + if (prefix == null) return [0, 0]; + return prefixToOffsetsMap.get(prefix); + } + + public function getAnimationOffsets(name:String):Array + { + return getAnimationOffsetsByPrefix(getAnimationPrefix(name)); + } + + // TODO: These should really be frame labels, ehe. + + public function getCartoonSoundClickFrame():Int + { + return cartoon?.soundClickFrame ?? 80; + } + + public function getCartoonSoundCartoonFrame():Int + { + return cartoon?.soundCartoonFrame ?? 85; + } + + public function getCartoonLoopBlinkFrame():Int + { + return cartoon?.loopBlinkFrame ?? 112; + } + + public function getCartoonLoopFrame():Int + { + return cartoon?.loopFrame ?? 166; + } + + public function getCartoonChannelChangeFrame():Int + { + return cartoon?.channelChangeFrame ?? 60; + } + + public function getFistPumpIntroStartFrame():Int + { + return fistPump?.introStartFrame ?? 0; + } + + public function getFistPumpIntroEndFrame():Int + { + return fistPump?.introEndFrame ?? 0; + } + + public function getFistPumpLoopStartFrame():Int + { + return fistPump?.loopStartFrame ?? 0; + } + + public function getFistPumpLoopEndFrame():Int + { + return fistPump?.loopEndFrame ?? 0; + } + + public function getFistPumpIntroBadStartFrame():Int + { + return fistPump?.introBadStartFrame ?? 0; + } + + public function getFistPumpIntroBadEndFrame():Int + { + return fistPump?.introBadEndFrame ?? 0; + } + + public function getFistPumpLoopBadStartFrame():Int + { + return fistPump?.loopBadStartFrame ?? 0; + } + + public function getFistPumpLoopBadEndFrame():Int + { + return fistPump?.loopBadEndFrame ?? 0; + } + + public function getCharSelectTransitionDelay():Float + { + return charSelect?.transitionDelay ?? 0.25; + } +} + +class PlayerCharSelectData +{ + /** + * A zero-indexed number for the character's preferred position in the grid. + * 0 = top left, 4 = center, 8 = bottom right + * In the event of a conflict, the first character alphabetically gets it, + * and others get shifted over. + */ + @:optional + public var position:Null; + + /** + * The GF name to assign for this character. + */ + @:optional + public var gf:PlayerCharSelectGFData; +} + +typedef PlayerCharSelectGFData = +{ + @:optional + public var assetPath:String; + + @:optional + public var animInfoPath:String; + + @:optional + @:default(false) + public var visualizer:Bool; +} + +typedef PlayerResultsData = +{ + var music:PlayerResultsMusicData; + + var perfectGold:Array; + var perfect:Array; + var excellent:Array; + var great:Array; + var good:Array; + var loss:Array; +}; + +typedef PlayerResultsMusicData = +{ + @:optional + var PERFECT_GOLD:String; + + @:optional + var PERFECT:String; + + @:optional + var EXCELLENT:String; + + @:optional + var GREAT:String; + + @:optional + var GOOD:String; + + @:optional + var SHIT:String; +} + +typedef PlayerResultsAnimationData = +{ + /** + * `sparrow` or `animate` or whatever + */ + var renderType:String; + + @:optional + var assetPath:Null; + + @:optional + var scriptClass:Null; + + @:optional + @:default([0, 0]) + var offsets:Array; + + @:optional + @:default("both") + var filter:String; + + @:optional + @:default(500) + var zIndex:Int; + + @:optional + @:default(0.0) + var delay:Float; + + @:optional + @:default(1.0) + var scale:Float; + + @:optional + @:default('') + var startFrameLabel:Null; + + @:optional + @:default('') + var sound:Null; + + @:optional + @:default(true) + var looped:Bool; + + @:optional + var loopFrame:Null; + + @:optional + var loopFrameLabel:Null; +}; + +typedef PlayerFreeplayDJCharSelectData = +{ + var transitionDelay:Float; +} + +typedef PlayerFreeplayDJCartoonData = +{ + var soundClickFrame:Int; + var soundCartoonFrame:Int; + var loopBlinkFrame:Int; + var loopFrame:Int; + var channelChangeFrame:Int; +} + +typedef PlayerFreeplayDJFistPumpData = +{ + @:default(0) + var introStartFrame:Int; + + @:default(4) + var introEndFrame:Int; + + @:default(4) + var loopStartFrame:Int; + + @:default(-1) + var loopEndFrame:Int; + + @:default(0) + var introBadStartFrame:Int; + + @:default(4) + var introBadEndFrame:Int; + + @:default(4) + var loopBadStartFrame:Int; + + @:default(-1) + var loopBadEndFrame:Int; +}; diff --git a/source/funkin/data/freeplay/player/PlayerRegistry.hx b/source/funkin/data/freeplay/player/PlayerRegistry.hx new file mode 100644 index 00000000..d584398e --- /dev/null +++ b/source/funkin/data/freeplay/player/PlayerRegistry.hx @@ -0,0 +1,126 @@ +package funkin.data.freeplay.player; + +import funkin.data.freeplay.player.PlayerData; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter; +import funkin.save.Save; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class PlayerRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migratePlayerData()` function. + */ + public static final PLAYER_DATA_VERSION:thx.semver.Version = "1.0.0"; + + public static final PLAYER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x"; + + /** + * A mapping between stage character IDs and Freeplay playable character IDs. + */ + var ownedCharacterIds:Map = []; + + public function new() + { + super('PLAYER', 'players', PLAYER_DATA_VERSION_RULE); + } + + public override function loadEntries():Void + { + super.loadEntries(); + + for (playerId in listEntryIds()) + { + var player = fetchEntry(playerId); + if (player == null) continue; + + var currentPlayerCharIds = player.getOwnedCharacterIds(); + for (characterId in currentPlayerCharIds) + { + ownedCharacterIds.set(characterId, playerId); + } + } + + log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.'); + } + + public function countUnlockedCharacters():Int + { + var count = 0; + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (player.isUnlocked()) count++; + } + + return count; + } + + public function hasNewCharacter():Bool + { + var charactersSeen = Save.instance.charactersSeen.clone(); + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (!player.isUnlocked()) continue; + if (charactersSeen.contains(charId)) continue; + + // This character is unlocked but we haven't seen them in Freeplay yet. + return true; + } + + // Fallthrough case. + return false; + } + + public function listNewCharacters():Array + { + var charactersSeen = Save.instance.charactersSeen.clone(); + var result = []; + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (!player.isUnlocked()) continue; + if (charactersSeen.contains(charId)) continue; + + // This character is unlocked but we haven't seen them in Freeplay yet. + result.push(charId); + } + + return result; + } + + /** + * Get the playable character associated with a given stage character. + * @param characterId The stage character ID. + * @return The playable character. + */ + public function getCharacterOwnerId(characterId:Null):Null + { + if (characterId == null) return null; + return ownedCharacterIds[characterId]; + } + + /** + * Return true if the given stage character is associated with a specific playable character. + * If so, the level should only appear if that character is selected in Freeplay. + * @param characterId The stage character ID. + * @return Whether the character is owned by any one character. + */ + public function isCharacterOwned(characterId:String):Bool + { + return ownedCharacterIds.exists(characterId); + } +} diff --git a/source/funkin/data/freeplay/style/CHANGELOG.md b/source/funkin/data/freeplay/style/CHANGELOG.md new file mode 100644 index 00000000..8fe9f7eb --- /dev/null +++ b/source/funkin/data/freeplay/style/CHANGELOG.md @@ -0,0 +1,9 @@ +# Freeplay Style Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/freeplay/style/FreeplayStyleData.hx b/source/funkin/data/freeplay/style/FreeplayStyleData.hx new file mode 100644 index 00000000..1af19821 --- /dev/null +++ b/source/funkin/data/freeplay/style/FreeplayStyleData.hx @@ -0,0 +1,48 @@ +package funkin.data.freeplay.style; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data for an album of songs. + * It includes things like what graphics to display in Freeplay. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef FreeplayStyleData = +{ + /** + * Semantic version for style data. + */ + public var version:String; + + /** + * Asset key for the background image. + */ + public var bgAsset:String; + + /** + * Asset key for the difficulty selector image. + */ + public var selectorAsset:String; + + /** + * Asset key for the numbers shown at the top right of the screen. + */ + public var numbersAsset:String; + + /** + * Asset key for the freeplay capsules. + */ + public var capsuleAsset:String; + + /** + * Color data for the capsule text outline. + * the order of this array goes as follows: [DESELECTED, SELECTED] + */ + public var capsuleTextColors:Array; + + /** + * Delay time after confirming a song selection, before entering PlayState. + * Useful for letting longer animations play out. + */ + public var startDelay:Float; +} diff --git a/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx b/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx new file mode 100644 index 00000000..f6d75a86 --- /dev/null +++ b/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx @@ -0,0 +1,24 @@ +package funkin.data.freeplay.style; + +import funkin.ui.freeplay.FreeplayStyle; +import funkin.data.freeplay.style.FreeplayStyleData; +import funkin.ui.freeplay.ScriptedFreeplayStyle; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class FreeplayStyleRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the style data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStyleData()` function. + */ + public static final FREEPLAYSTYLE_DATA_VERSION:thx.semver.Version = '1.0.0'; + + public static final FREEPLAYSTYLE_DATA_VERSION_RULE:thx.semver.VersionRule = '1.0.x'; + + public function new() + { + super('FREEPLAYSTYLE', 'ui/freeplay/styles', FREEPLAYSTYLE_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/notestyle/CHANGELOG.md b/source/funkin/data/notestyle/CHANGELOG.md new file mode 100644 index 00000000..de9a19f1 --- /dev/null +++ b/source/funkin/data/notestyle/CHANGELOG.md @@ -0,0 +1,33 @@ +# Note Style Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [1.1.0] +### Added +- Added several new `assets`: + - `countdownThree` + - `countdownTwo` + - `countdownOne` + - `countdownGo` + - `judgementSick` + - `judgementGood` + - `judgementBad` + - `judgementShit` + - `comboNumber0` + - `comboNumber1` + - `comboNumber2` + - `comboNumber3` + - `comboNumber4` + - `comboNumber5` + - `comboNumber6` + - `comboNumber7` + - `comboNumber8` + - `comboNumber9` + +## [1.0.0] +Initial version. diff --git a/source/funkin/data/notestyle/NoteStyleData.hx b/source/funkin/data/notestyle/NoteStyleData.hx new file mode 100644 index 00000000..fb27e381 --- /dev/null +++ b/source/funkin/data/notestyle/NoteStyleData.hx @@ -0,0 +1,322 @@ +package funkin.data.notestyle; + +import haxe.DynamicAccess; +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data in a note style JSON file. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef NoteStyleData = +{ + /** + * The version number of the note style data schema. + * When making changes to the note style data format, this should be incremented, + * and a migration function should be added to NoteStyleDataParser to handle old versions. + */ + @:default(funkin.data.notestyle.NoteStyleRegistry.NOTE_STYLE_DATA_VERSION) + var version:String; + + /** + * The readable title of the note style. + */ + var name:String; + + /** + * The author of the note style. + */ + var author:String; + + /** + * The note style to use as a fallback/parent. + * @default null + */ + @:optional + var fallback:Null; + + /** + * Data for each of the assets in the note style. + */ + var assets:NoteStyleAssetsData; +} + +typedef NoteStyleAssetsData = +{ + /** + * The sprites for the notes. + * @default The sprites from the fallback note style. + */ + @:optional + var note:NoteStyleAssetData; + + /** + * The sprites for the hold notes. + * @default The sprites from the fallback note style. + */ + @:optional + var holdNote:NoteStyleAssetData; + + /** + * The sprites for the strumline. + * @default The sprites from the fallback note style. + */ + @:optional + var noteStrumline:NoteStyleAssetData; + + /** + * The sprites for the note splashes. + */ + @:optional + var noteSplash:NoteStyleAssetData; + + /** + * The sprites for the hold note covers. + */ + @:optional + var holdNoteCover:NoteStyleAssetData; + + /** + * The THREE sound (and an optional pre-READY graphic). + */ + @:optional + var countdownThree:NoteStyleAssetData; + + /** + * The TWO sound and READY graphic. + */ + @:optional + var countdownTwo:NoteStyleAssetData; + + /** + * The ONE sound and SET graphic. + */ + @:optional + var countdownOne:NoteStyleAssetData; + + /** + * The GO sound and GO! graphic. + */ + @:optional + var countdownGo:NoteStyleAssetData; + + /** + * The SICK! judgement. + */ + @:optional + var judgementSick:NoteStyleAssetData; + + /** + * The GOOD! judgement. + */ + @:optional + var judgementGood:NoteStyleAssetData; + + /** + * The BAD! judgement. + */ + @:optional + var judgementBad:NoteStyleAssetData; + + /** + * The SHIT! judgement. + */ + @:optional + var judgementShit:NoteStyleAssetData; + + @:optional + var comboNumber0:NoteStyleAssetData; + + @:optional + var comboNumber1:NoteStyleAssetData; + + @:optional + var comboNumber2:NoteStyleAssetData; + + @:optional + var comboNumber3:NoteStyleAssetData; + + @:optional + var comboNumber4:NoteStyleAssetData; + + @:optional + var comboNumber5:NoteStyleAssetData; + + @:optional + var comboNumber6:NoteStyleAssetData; + + @:optional + var comboNumber7:NoteStyleAssetData; + + @:optional + var comboNumber8:NoteStyleAssetData; + + @:optional + var comboNumber9:NoteStyleAssetData; +} + +/** + * Data shared by all note style assets. + */ +typedef NoteStyleAssetData = +{ + /** + * The image to use for the asset. May be a Sparrow sprite sheet. + */ + var assetPath:String; + + /** + * The scale to render the prop at. + * @default 1.0 + */ + @:default(1.0) + @:optional + var scale:Float; + + /** + * Offset the sprite's position by this amount. + * @default [0, 0] + */ + @:default([0, 0]) + @:optional + var offsets:Null>; + + /** + * If true, the prop is a pixel sprite, and will be rendered without anti-aliasing. + */ + @:default(false) + @:optional + var isPixel:Bool; + + @:default(1.0) + @:optional + var alpha:Float; + + /** + * If true, animations will be played on the graphic. + * @default `false` to save performance. + */ + @:default(false) + @:optional + var animated:Bool; + + /** + * The structure of this data depends on the asset. + */ + @:optional + var data:Null; +} + +typedef NoteStyleData_Note = +{ + var left:UnnamedAnimationData; + var down:UnnamedAnimationData; + var up:UnnamedAnimationData; + var right:UnnamedAnimationData; +} + +typedef NoteStyleData_Countdown = +{ + var audioPath:String; +} + +typedef NoteStyleData_HoldNote = {} +typedef NoteStyleData_Judgement = {} +typedef NoteStyleData_ComboNum = {} + +/** + * Data on animations for each direction of the strumline. + */ +typedef NoteStyleData_NoteStrumline = +{ + var leftStatic:UnnamedAnimationData; + var leftPress:UnnamedAnimationData; + var leftConfirm:UnnamedAnimationData; + var leftConfirmHold:UnnamedAnimationData; + var downStatic:UnnamedAnimationData; + var downPress:UnnamedAnimationData; + var downConfirm:UnnamedAnimationData; + var downConfirmHold:UnnamedAnimationData; + var upStatic:UnnamedAnimationData; + var upPress:UnnamedAnimationData; + var upConfirm:UnnamedAnimationData; + var upConfirmHold:UnnamedAnimationData; + var rightStatic:UnnamedAnimationData; + var rightPress:UnnamedAnimationData; + var rightConfirm:UnnamedAnimationData; + var rightConfirmHold:UnnamedAnimationData; +} + +typedef NoteStyleData_NoteSplash = +{ + /** + * If false, note splashes are entirely hidden on this note style. + * @default Note splashes are enabled. + */ + @:optional + @:default(true) + var enabled:Bool; + + @:optional + @:default(24) + var framerateDefault:Int; + + @:optional + @:default(2) + var framerateVariance:Int; + + @:optional + @:default("normal") + var blendMode:String; + + @:optional + var leftSplashes:Array; + + @:optional + var downSplashes:Array; + + @:optional + var upSplashes:Array; + + @:optional + var rightSplashes:Array; +}; + +typedef NoteStyleData_HoldNoteCover = +{ + /** + * If false, hold note covers are entirely hidden on this note style. + * @default Hold note covers are enabled. + */ + @:optional + @:default(true) + var enabled:Bool; + + @:optional + var left:NoteStyleData_HoldNoteCoverDirectionData; + + @:optional + var down:NoteStyleData_HoldNoteCoverDirectionData; + + @:optional + var up:NoteStyleData_HoldNoteCoverDirectionData; + + @:optional + var right:NoteStyleData_HoldNoteCoverDirectionData; +}; + +typedef NoteStyleData_HoldNoteCoverDirectionData = +{ + /** + * Optionally specify an asset path to use for this specific animation. + * @:default The assetPath of the main holdNoteCover asset + */ + @:optional + var assetPath:String; + + @:optional + var start:UnnamedAnimationData; + @:optional + var hold:UnnamedAnimationData; + @:optional + var end:UnnamedAnimationData; +} diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx new file mode 100644 index 00000000..c88aba6d --- /dev/null +++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx @@ -0,0 +1,29 @@ +package funkin.data.notestyle; + +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.notes.notestyle.ScriptedNoteStyle; +import funkin.data.notestyle.NoteStyleData; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class NoteStyleRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the note style data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateNoteStyleData()` function. + */ + public static final NOTE_STYLE_DATA_VERSION:thx.semver.Version = "1.1.0"; + + public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = ">=1.0.0 <1.2.0"; + + public function new() + { + super('NOTESTYLE', 'notestyles', NOTE_STYLE_DATA_VERSION_RULE); + } + + public function fetchDefault():NoteStyle + { + return fetchEntry(Constants.DEFAULT_NOTE_STYLE); + } +} diff --git a/source/funkin/data/song/CHANGELOG.md b/source/funkin/data/song/CHANGELOG.md new file mode 100644 index 00000000..e95c9d98 --- /dev/null +++ b/source/funkin/data/song/CHANGELOG.md @@ -0,0 +1,54 @@ +# Song Chart Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.2.5] +### Added +- Added `playData.stickerPack` to specify which sticker pack to use when transitioning into and out of a song. + - If the value isn't present, it will use the character's sticker pack instead. + +## [2.2.4] +### Added +- Added `playData.characters.opponentVocals` to specify which vocal track(s) to play for the opponent. + - If the value isn't present, it will use the `playData.characters.opponent`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the opponent) +- Added `playData.characters.playerVocals` to specify which vocal track(s) to play for the player. + - If the value isn't present, it will use the `playData.characters.player`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the player) +- Added `offsets.altVocals` field to apply vocal offsets when alternate instrumentals are used. + + +## [2.2.3] +### Added +- Added `charter` field to denote authorship of a chart. + +## [2.2.2] +### Added +- Added `playData.previewStart` and `playData.previewEnd` fields to specify when in the song should the song's audio should be played as a preview in Freeplay. + +## [2.2.1] +### Added +- Added `playData.offsets` field to specify instrumental and vocal offsets. + +## [2.2.0] +### Added +- Added `playData.album` to specify the album art to display in Freeplay. +- Added `playData.ratings` for difficulty ratings displayed in Freeplay. +### Changed +- Renamed `playData.noteSkin` to `playData.noteStyle`. + +## [2.1.0] +### Changed +- Rearranged the `playData` field. + - Refactored the `playableChars` +### Removed +- Removed the `variation` field. + +## [2.0.0] +Full refactor of the chart format for improved structure. +### Added +- Added a semantic version field for migration tracking. + +## [1.0.0] +Initial version from 2020. diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx new file mode 100644 index 00000000..b5da02d0 --- /dev/null +++ b/source/funkin/data/song/SongData.hx @@ -0,0 +1,1267 @@ +package funkin.data.song; + +import funkin.data.event.SongEventRegistry; +import funkin.play.event.SongEvent; +import funkin.data.event.SongEventSchema; +import funkin.data.song.SongRegistry; +import thx.semver.Version; +import funkin.util.tools.ICloneable; + +/** + * Data containing information about a song. + * It should contain all the data needed to display a song in the Freeplay menu, or to load the assets required to play its chart. + * Data which is only necessary in-game should be stored in the SongChartData. + */ +@:nullSafety +class SongMetadata implements ICloneable +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + public var charter:Null = null; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + /** + * Instrumental and vocal offsets. + * Defaults to an empty SongOffsets object. + */ + @:optional + public var offsets:Null; + + /** + * Data relating to the song's gameplay. + */ + public var playData:SongPlayData; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + @:optional + @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + public var timeChanges:Array; + + /** + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. + */ + @:jignored + public var variation:String; + + public function new(songName:String, artist:String, ?variation:String) + { + this.version = SongRegistry.SONG_METADATA_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.offsets = new SongOffsets(); + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.playData = new SongPlayData(); + this.playData.songVariations = []; + this.playData.difficulties = []; + this.playData.characters = new SongCharacterData('bf', 'gf', 'dad'); + this.playData.stage = 'mainStage'; + this.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation; + } + + /** + * Create a copy of this SongMetadata with the same information. + * @param newVariation Set to a new variation ID to change the new metadata. + * @return The cloned SongMetadata + */ + public function clone():SongMetadata + { + var result:SongMetadata = new SongMetadata(this.songName, this.artist, this.variation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.offsets = this.offsets != null ? this.offsets.clone() : new SongOffsets(); // if no song offsets found (aka null), so just create new ones + result.timeChanges = this.timeChanges.deepClone(); + result.looped = this.looped; + result.playData = this.playData.clone(); + result.generatedBy = this.generatedBy; + + return result; + } + + /** + * Serialize this SongMetadata into a JSON string. + * @param pretty Whether the JSON should be big ol string (false), + * or formatted with tabs (true) + * @return The JSON string. + */ + public function serialize(pretty:Bool = true):String + { + // Update generatedBy and version before writing. + updateVersionToLatest(); + + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); + // I believe @:jignored should be ignored by the writer? + // var output = this.clone(); + // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer. + return writer.write(this, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.version = SongRegistry.SONG_METADATA_VERSION; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +enum abstract SongTimeFormat(String) from String to String +{ + var TICKS = 'ticks'; + var FLOAT = 'float'; + var MILLISECONDS = 'ms'; +} + +class SongTimeChange implements ICloneable +{ + public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); + + public static final DEFAULT_SONGTIMECHANGES:Array = [DEFAULT_SONGTIMECHANGE]; + + static final DEFAULT_BEAT_TUPLETS:Array = [4, 4, 4, 4]; + static final DEFAULT_BEAT_TIME:Null = null; // Later, null gets detected and recalculated. + + /** + * Timestamp in specified `timeFormat`. + */ + @:alias("t") + public var timeStamp:Float; + + /** + * Time in beats (int). The game will calculate further beat values based on this one, + * so it can do it in a simple linear fashion. + */ + @:optional + @:alias("b") + public var beatTime:Float; + + /** + * Quarter notes per minute (float). Cannot be empty in the first element of the list, + * but otherwise it's optional, and defaults to the value of the previous element. + */ + @:alias("bpm") + public var bpm:Float; + + /** + * Time signature numerator (int). Optional, defaults to 4. + */ + @:default(4) + @:optional + @:alias("n") + public var timeSignatureNum:Int; + + /** + * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + */ + @:default(4) + @:optional + @:alias("d") + public var timeSignatureDen:Int; + + /** + * Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. + * Optional, defaults to `[4]`. + */ + @:optional + @:alias("bt") + public var beatTuplets:Array; + + public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array) + { + this.timeStamp = timeStamp; + this.bpm = bpm; + + this.timeSignatureNum = timeSignatureNum; + this.timeSignatureDen = timeSignatureDen; + + this.beatTime = beatTime == null ? DEFAULT_BEAT_TIME : beatTime; + this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; + } + + public function clone():SongTimeChange + { + return new SongTimeChange(this.timeStamp, this.bpm, this.timeSignatureNum, this.timeSignatureDen, this.beatTime, this.beatTuplets); + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)'; + } +} + +/** + * Offsets to apply to the song's instrumental and vocals, relative to the chart. + * These are intended to correct for issues with the chart, or with the song's audio (for example a 10ms delay before the song starts). + * This is independent of the offsets applied in the user's settings, which are applied after these offsets and intended to correct for the user's hardware. + */ +class SongOffsets implements ICloneable +{ + /** + * The offset, in milliseconds, to apply to the song's instrumental relative to the chart. + * For example, setting this to `-10.0` will start the instrumental 10ms earlier than the chart. + * + * Setting this to `-5000.0` means the chart start 5 seconds into the song. + * Setting this to `5000.0` means there will be 5 seconds of silence before the song starts. + */ + @:optional + @:default(0) + public var instrumental:Float; + + /** + * Apply different offsets to different alternate instrumentals. + */ + @:optional + @:default([]) + public var altInstrumentals:Map; + + /** + * The offset, in milliseconds, to apply to the song's vocals, relative to the song's base instrumental. + * These are applied ON TOP OF the instrumental offset. + */ + @:optional + @:default([]) + public var vocals:Map; + + /** + * The offset, in milliseconds, to apply to the songs vocals, relative to each alternate instrumental. + * This is useful for the circumstance where, for example, an alt instrumental has a few seconds of lead in before the song starts. + */ + @:optional + @:default([]) + public var altVocals:Map>; + + public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map, ?altVocals:Map>) + { + this.instrumental = instrumental; + this.altInstrumentals = altInstrumentals == null ? new Map() : altInstrumentals; + this.vocals = vocals == null ? new Map() : vocals; + this.altVocals = altVocals == null ? new Map>() : altVocals; + } + + public function getInstrumentalOffset(?instrumental:String):Float + { + if (instrumental == null || instrumental == '') return this.instrumental; + + if (!this.altInstrumentals.exists(instrumental)) return this.instrumental; + + return this.altInstrumentals.get(instrumental); + } + + public function setInstrumentalOffset(value:Float, ?instrumental:String):Float + { + if (instrumental == null || instrumental == '') + { + this.instrumental = value; + } + else + { + this.altInstrumentals.set(instrumental, value); + } + return value; + } + + public function getVocalOffset(charId:String, ?instrumental:String):Float + { + if (instrumental == null) + { + if (!this.vocals.exists(charId)) return 0.0; + return this.vocals.get(charId); + } + else + { + if (!this.altVocals.exists(instrumental)) return 0.0; + if (!this.altVocals.get(instrumental).exists(charId)) return 0.0; + return this.altVocals.get(instrumental).get(charId); + } + } + + public function setVocalOffset(charId:String, value:Float):Float + { + this.vocals.set(charId, value); + return value; + } + + public function clone():SongOffsets + { + var result:SongOffsets = new SongOffsets(this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + result.vocals = this.vocals.clone(); + + return result; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals}, ${this.altVocals})'; + } +} + +/** + * Metadata for a song only used for the music. + * For example, the menu music. + */ +class SongMusicData implements ICloneable +{ + /** + * A semantic versioning string for the song data format. + * + */ + // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION) + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Null; + + // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS) + public var timeFormat:SongTimeFormat; + + // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES) + public var timeChanges:Array; + + /** + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. + */ + @:jignored + public var variation:String; + + public function new(songName:String, artist:String, variation:String = 'default') + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + } + + public function updateVersionToLatest():Void + { + this.version = SongRegistry.SONG_MUSIC_DATA_VERSION; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function clone():SongMusicData + { + var result:SongMusicData = new SongMusicData(this.songName, this.artist, this.variation); + result.version = this.version; + result.timeFormat = this.timeFormat; + result.divisions = this.divisions; + result.timeChanges = this.timeChanges.clone(); + result.looped = this.looped; + result.generatedBy = this.generatedBy; + + return result; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +class SongPlayData implements ICloneable +{ + /** + * The variations this song has. The associated metadata files should exist. + */ + @:default([]) + @:optional + public var songVariations:Array; + + /** + * The difficulties contained in this song's chart file. + */ + public var difficulties:Array; + + /** + * The characters used by this song. + */ + public var characters:SongCharacterData; + + /** + * The stage used by this song. + */ + public var stage:String; + + /** + * The note style used by this song. + */ + public var noteStyle:String; + + /** + * The difficulty ratings for this song as displayed in Freeplay. + * Key is a difficulty ID. + */ + @:optional + @:default(['normal' => 0]) + public var ratings:Map; + + /** + * The album ID for the album to display in Freeplay. + * If `null`, display no album. + */ + @:optional + public var album:Null; + + /** + * The sticker pack for the song to use during transitions. + * If `null`, display the character's sticker pack. + */ + @:optional + public var stickerPack:Null; + + /** + * The start time for the audio preview in Freeplay. + * Defaults to 0 seconds in. + * @since `2.2.2` + */ + @:optional + @:default(0) + public var previewStart:Int; + + /** + * The end time for the audio preview in Freeplay. + * Defaults to 15 seconds in. + * @since `2.2.2` + */ + @:optional + @:default(15000) + public var previewEnd:Int; + + public function new() + { + ratings = new Map(); + } + + public function clone():SongPlayData + { + // TODO: This sucks! If you forget to update this you get weird behavior. + var result:SongPlayData = new SongPlayData(); + result.songVariations = this.songVariations.clone(); + result.difficulties = this.difficulties.clone(); + result.characters = this.characters.clone(); + result.stage = this.stage; + result.noteStyle = this.noteStyle; + result.ratings = this.ratings.clone(); + result.album = this.album; + result.previewStart = this.previewStart; + result.previewEnd = this.previewEnd; + + return result; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayData(${this.songVariations}, ${this.difficulties})'; + } +} + +/** + * Information about the characters used in this variation of the song. + * Create a new variation if you want to change the characters. + */ +class SongCharacterData implements ICloneable +{ + @:optional + @:default('') + public var player:String = ''; + + @:optional + @:default('') + public var girlfriend:String = ''; + + @:optional + @:default('') + public var opponent:String = ''; + + @:optional + @:default('') + public var instrumental:String = ''; + + @:optional + @:default([]) + public var altInstrumentals:Array = []; + + @:optional + public var opponentVocals:Null> = null; + + @:optional + public var playerVocals:Null> = null; + + public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '', ?altInstrumentals:Array, + ?opponentVocals:Array, ?playerVocals:Array) + { + this.player = player; + this.girlfriend = girlfriend; + this.opponent = opponent; + this.instrumental = instrumental; + + this.altInstrumentals = altInstrumentals; + this.opponentVocals = opponentVocals; + this.playerVocals = playerVocals; + + if (opponentVocals == null) this.opponentVocals = [opponent]; + if (playerVocals == null) this.playerVocals = [player]; + } + + public function clone():SongCharacterData + { + var result:SongCharacterData = new SongCharacterData(this.player, this.girlfriend, this.opponent, this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + + return result; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongCharacterData(${this.player}, ${this.girlfriend}, ${this.opponent}, ${this.instrumental}, [${this.altInstrumentals.join(', ')}])'; + } +} + +class SongChartData implements ICloneable +{ + @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:Version; + + public var scrollSpeed:Map; + public var events:Array; + public var notes:Map>; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + /** + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. + */ + @:jignored + public var variation:String; + + public function new(scrollSpeed:Map, events:Array, notes:Map>) + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + + this.events = events; + this.notes = notes; + this.scrollSpeed = scrollSpeed; + + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function getScrollSpeed(diff:String = 'default'):Float + { + var result:Float = this.scrollSpeed.get(diff); + + if (result == 0.0 && diff != 'default') return getScrollSpeed('default'); + + return (result == 0.0) ? 1.0 : result; + } + + public function setScrollSpeed(value:Float, diff:String = 'default'):Float + { + this.scrollSpeed.set(diff, value); + return value; + } + + public function getNotes(diff:String):Array + { + var result:Array = this.notes.get(diff); + + if (result == null && diff != 'normal') return getNotes('normal'); + + return (result == null) ? [] : result; + } + + public function setNotes(value:Array, diff:String):Array + { + this.notes.set(diff, value); + return value; + } + + /** + * Convert this SongChartData into a JSON string. + */ + public function serialize(pretty:Bool = true):String + { + // Update generatedBy and version before writing. + updateVersionToLatest(); + + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); + return writer.write(this, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.version = SongRegistry.SONG_CHART_DATA_VERSION; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + } + + public function clone():SongChartData + { + // We have to manually perform the deep clone here because Map.deepClone() doesn't work. + var noteDataClone:Map> = new Map>(); + for (key in this.notes.keys()) + { + noteDataClone.set(key, this.notes.get(key).deepClone()); + } + var eventDataClone:Array = this.events.deepClone(); + + var result:SongChartData = new SongChartData(this.scrollSpeed.clone(), eventDataClone, noteDataClone); + result.version = this.version; + result.generatedBy = this.generatedBy; + result.variation = this.variation; + + return result; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongChartData(${this.events.length} events, ${this.notes.size()} difficulties, ${generatedBy})'; + } +} + +class SongEventDataRaw implements ICloneable +{ + /** + * The timestamp of the event. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time(default, set):Float; + + function set_time(value:Float):Float + { + _stepTime = null; + return time = value; + } + + /** + * The kind of the event. + * Examples include "FocusCamera" and "PlayAnimation" + * Custom events can be added by scripts with the `ScriptedSongEvent` class. + */ + @:alias("e") + public var eventKind:String; + + /** + * The data for the event. + * This can allow the event to include information used for custom behavior. + * Data type depends on the event kind. It can be anything that's JSON serializable. + */ + @:alias("v") + @:optional + @:jcustomparse(funkin.data.DataParse.dynamicValue) + @:jcustomwrite(funkin.data.DataWrite.dynamicValue) + public var value:Dynamic = null; + + /** + * Whether this event has been activated. + * This is only used internally by the game. It should not be serialized. + */ + @:jignored + public var activated:Bool = false; + + public function new(time:Float, eventKind:String, value:Dynamic = null) + { + this.time = time; + this.eventKind = eventKind; + this.value = value; + } + + @:jignored + var _stepTime:Null = null; + + public function getStepTime(force:Bool = false):Float + { + if (_stepTime != null && !force) return _stepTime; + + return _stepTime = Conductor.instance.getTimeInSteps(this.time); + } + + public function clone():SongEventDataRaw + { + return new SongEventDataRaw(this.time, this.eventKind, this.value); + } + + public function valueAsStruct(?defaultKey:String = "key"):Dynamic + { + if (this.value == null) return {}; + if (Std.isOfType(this.value, Array)) + { + var result:haxe.DynamicAccess = {}; + result.set(defaultKey, this.value); + return cast result; + } + else if (Reflect.isObject(this.value)) + { + // We enter this case if the value is a struct. + return cast this.value; + } + else + { + var result:haxe.DynamicAccess = {}; + result.set(defaultKey, this.value); + return cast result; + } + } + + public function getHandler():Null + { + return SongEventRegistry.getEvent(this.eventKind); + } + + public function getSchema():Null + { + return SongEventRegistry.getEventSchema(this.eventKind); + } + + public function getDynamic(key:String):Null + { + return this.value == null ? null : Reflect.field(this.value, key); + } + + public function getBool(key:String):Null + { + return this.value == null ? null : cast Reflect.field(this.value, key); + } + + public function getInt(key:String):Null + { + if (this.value == null) return null; + var result = Reflect.field(this.value, key); + if (result == null) return null; + if (Std.isOfType(result, Int)) return result; + if (Std.isOfType(result, String)) return Std.parseInt(cast result); + return cast result; + } + + public function getFloat(key:String):Null + { + if (this.value == null) return null; + var result = Reflect.field(this.value, key); + if (result == null) return null; + if (Std.isOfType(result, Float)) return result; + if (Std.isOfType(result, String)) return Std.parseFloat(cast result); + return cast result; + } + + public function getString(key:String):String + { + return this.value == null ? null : cast Reflect.field(this.value, key); + } + + public function getArray(key:String):Array + { + return this.value == null ? null : cast Reflect.field(this.value, key); + } + + public function getBoolArray(key:String):Array + { + return this.value == null ? null : cast Reflect.field(this.value, key); + } + + public function buildTooltip():String + { + var eventHandler = getHandler(); + var eventSchema = getSchema(); + + if (eventSchema == null) return 'Unknown Event: ${this.eventKind}'; + + var result = '${eventHandler.getTitle()}'; + + var defaultKey = eventSchema.getFirstField()?.name; + var valueStruct:haxe.DynamicAccess = valueAsStruct(defaultKey); + + for (pair in valueStruct.keyValueIterator()) + { + var key = pair.key; + var value = pair.value; + + var title = eventSchema.getByName(key)?.title ?? 'UnknownField'; + + // if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value)); + var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue'; + + result += '\n- ${title}: ${valueStr}'; + } + + return result; + } +} + +/** + * Wrap SongEventData in an abstract so we can overload operators. + */ +@:forward(time, eventKind, value, activated, getStepTime, clone, getHandler, getSchema, getDynamic, getBool, getInt, getFloat, getString, getArray, + getBoolArray, buildTooltip, valueAsStruct) +abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw +{ + public function new(time:Float, eventKind:String, value:Dynamic = null) + { + this = new SongEventDataRaw(time, eventKind, value); + } + + public function clone():SongEventData + { + return new SongEventData(this.time, this.eventKind, this.value); + } + + @:op(A == B) + public function op_equals(other:SongEventData):Bool + { + return this.time == other.time && this.eventKind == other.eventKind && this.value == other.value; + } + + @:op(A != B) + public function op_notEquals(other:SongEventData):Bool + { + return this.time != other.time || this.eventKind != other.eventKind || this.value != other.value; + } + + @:op(A > B) + public function op_greaterThan(other:SongEventData):Bool + { + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongEventData):Bool + { + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongEventData):Bool + { + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongEventData):Bool + { + return this.time <= other.time; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongEventData(${this.time}ms, ${this.eventKind}: ${this.value})'; + } +} + +class SongNoteDataRaw implements ICloneable +{ + /** + * The timestamp of the note. The timestamp is in the format of the song's time format. + */ + @:alias("t") + public var time(default, set):Float; + + function set_time(value:Float):Float + { + _stepTime = null; + return time = value; + } + + /** + * Data for the note. Represents the index on the strumline. + * 0 = left, 1 = down, 2 = up, 3 = right + * `floor(direction / strumlineSize)` specifies which strumline the note is on. + * 0 = player, 1 = opponent, etc. + */ + @:alias("d") + public var data:Int; + + /** + * Length of the note, if applicable. + * Defaults to 0 for single notes. + */ + @:alias("l") + @:default(0) + @:optional + public var length(default, set):Float; + + function set_length(value:Float):Float + { + _stepLength = null; + return length = value; + } + + /** + * The kind of the note. + * This can allow the note to include information used for custom behavior. + * Defaults to `null` for no kind. + */ + @:alias("k") + @:optional + @:isVar + public var kind(get, set):Null = null; + + function get_kind():Null + { + if (this.kind == null || this.kind == '') return null; + + return this.kind; + } + + function set_kind(value:Null):Null + { + if (value == '') value = null; + return this.kind = value; + } + + @:alias("p") + @:default([]) + @:optional + public var params:Array; + + public function new(time:Float, data:Int, length:Float = 0, kind:String = '', ?params:Array) + { + this.time = time; + this.data = data; + this.length = length; + this.kind = kind; + this.params = params ?? []; + } + + /** + * The direction of the note, if applicable. + * Strips the strumline index from the data. + * + * 0 = left, 1 = down, 2 = up, 3 = right + */ + public inline function getDirection(strumlineSize:Int = 4):Int + { + return this.data % strumlineSize; + } + + public function getDirectionName(strumlineSize:Int = 4):String + { + return SongNoteData.buildDirectionName(this.data, strumlineSize); + } + + /** + * The strumline index of the note, if applicable. + * Strips the direction from the data. + * + * 0 = player, 1 = opponent, etc. + */ + public function getStrumlineIndex(strumlineSize:Int = 4):Int + { + return Math.floor(this.data / strumlineSize); + } + + /** + * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side). + * TODO: The name of this function is a little misleading; what about mines? + * @param strumlineSize Defaults to 4. + * @return True if it's Boyfriend's note. + */ + public function getMustHitNote(strumlineSize:Int = 4):Bool + { + return getStrumlineIndex(strumlineSize) == 0; + } + + @:jignored + var _stepTime:Null = null; + + /** + * @param force Set to `true` to force recalculation (good after BPM changes) + * @return The position of the note in the song, in steps. + */ + public function getStepTime(force:Bool = false):Float + { + if (_stepTime != null && !force) return _stepTime; + + return _stepTime = Conductor.instance.getTimeInSteps(this.time); + } + + /** + * The length of the note, if applicable, in steps. + * Calculated from the length and the BPM. + * Cached for performance. Set to `null` to recalculate. + */ + @:jignored + var _stepLength:Null = null; + + /** + * @param force Set to `true` to force recalculation (good after BPM changes) + * @return The length of the hold note in steps, or `0` if this is not a hold note. + */ + public function getStepLength(force = false):Float + { + if (this.length <= 0) return 0.0; + + if (_stepLength != null && !force) return _stepLength; + + return _stepLength = Conductor.instance.getTimeInSteps(this.time + this.length) - getStepTime(); + } + + public function setStepLength(value:Float):Void + { + if (value <= 0) + { + this.length = 0.0; + } + else + { + var endStep:Float = getStepTime() + value; + var endMs:Float = Conductor.instance.getStepTimeInMs(endStep); + var lengthMs:Float = endMs - this.time; + + this.length = lengthMs; + } + + // Recalculate the step length next time it's requested. + _stepLength = null; + } + + public function cloneParams():Array + { + var params:Array = []; + for (param in this.params) + { + params.push(param.clone()); + } + return params; + } + + public function clone():SongNoteDataRaw + { + return new SongNoteDataRaw(this.time, this.data, this.length, this.kind, cloneParams()); + } + + public function toString():String + { + return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}' + + (this.kind != '' ? ' [kind: ${this.kind}])' : ')'); + } +} + +/** + * Wrap SongNoteData in an abstract so we can overload operators. + */ +@:forward +abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw +{ + public function new(time:Float, data:Int, length:Float = 0, kind:String = '', ?params:Array) + { + this = new SongNoteDataRaw(time, data, length, kind, params); + } + + public static function buildDirectionName(data:Int, strumlineSize:Int = 4):String + { + switch (data % strumlineSize) + { + case 0: + return 'Left'; + case 1: + return 'Down'; + case 2: + return 'Up'; + case 3: + return 'Right'; + default: + return 'Unknown'; + } + } + + @:jignored + public var isHoldNote(get, never):Bool; + + public function get_isHoldNote():Bool + { + return this.length > 0; + } + + @:op(A == B) + public function op_equals(other:SongNoteData):Bool + { + // Handle the case where one value is null. + if (this == null) return other == null; + if (other == null) return false; + + if (this.kind == null || this.kind == '') + { + if (other.kind != '' && this.kind != null) return false; + } + else + { + if (other.kind == '' || this.kind == null) return false; + } + + return this.time == other.time && this.data == other.data && this.length == other.length && this.params == other.params; + } + + @:op(A != B) + public function op_notEquals(other:SongNoteData):Bool + { + // Handle the case where one value is null. + if (this == null) return other == null; + if (other == null) return false; + + if (this.kind == '') + { + if (other.kind != '') return true; + } + else + { + if (other.kind == '') return true; + } + + return this.time != other.time || this.data != other.data || this.length != other.length || this.params != other.params; + } + + @:op(A > B) + public function op_greaterThan(other:SongNoteData):Bool + { + if (other == null) return false; + + return this.time > other.time; + } + + @:op(A < B) + public function op_lessThan(other:SongNoteData):Bool + { + if (other == null) return false; + + return this.time < other.time; + } + + @:op(A >= B) + public function op_greaterThanOrEquals(other:SongNoteData):Bool + { + if (other == null) return false; + + return this.time >= other.time; + } + + @:op(A <= B) + public function op_lessThanOrEquals(other:SongNoteData):Bool + { + if (other == null) return false; + + return this.time <= other.time; + } + + public function clone():SongNoteData + { + return new SongNoteData(this.time, this.data, this.length, this.kind, this.params); + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}' + + (this.kind != '' ? ' [kind: ${this.kind}])' : ')'); + } +} + +class NoteParamData implements ICloneable +{ + @:alias("n") + public var name:String; + + @:alias("v") + @:jcustomparse(funkin.data.DataParse.dynamicValue) + @:jcustomwrite(funkin.data.DataWrite.dynamicValue) + public var value:Dynamic; + + public function new(name:String, value:Dynamic) + { + this.name = name; + this.value = value; + } + + public function clone():NoteParamData + { + return new NoteParamData(this.name, this.value); + } + + public function toString():String + { + return 'NoteParamData(${this.name}, ${this.value})'; + } +} diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx new file mode 100644 index 00000000..d0e554e0 --- /dev/null +++ b/source/funkin/data/song/SongDataUtils.hx @@ -0,0 +1,311 @@ +package funkin.data.song; + +import flixel.util.FlxSort; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongTimeChange; +import funkin.util.ClipboardUtil; +import funkin.util.SerializerUtil; + +using Lambda; + +/** + * Utility functions for working with song data, including note data, event data, metadata, etc. + */ +class SongDataUtils +{ + /** + * Given an array of SongNoteData objects, return a new array of SongNoteData objects + * whose timestamps are shifted by the given amount. + * Does not mutate the original array. + * + * @param notes The notes to modify. + * @param offset The time difference to apply in milliseconds. + */ + public static function offsetSongNoteData(notes:Array, offset:Float):Array + { + var offsetNote = function(note:SongNoteData):SongNoteData { + var time:Float = note.time + offset; + var data:Int = note.data; + var length:Float = note.length; + var kind:String = note.kind; + return new SongNoteData(time, data, length, kind); + }; + + var result = [for (i in 0...notes.length) offsetNote(notes[i])]; + return result; + } + + /** + * Given an array of SongEventData objects, return a new array of SongEventData objects + * whose timestamps are shifted by the given amount. + * Does not mutate the original array. + * + * @param events The events to modify. + * @param offset The time difference to apply in milliseconds. + */ + public static function offsetSongEventData(events:Array, offset:Float):Array + { + return events.map(function(event:SongEventData):SongEventData { + return new SongEventData(event.time + offset, event.eventKind, event.value); + }); + } + + /** + * Given an array of SongNoteData objects, return a new array of SongNoteData objects + * which excludes any notes whose timestamps are outside of the given range. + * @param notes The notes to modify. + * @param startTime The start of the range in milliseconds. + * @param endTime The end of the range in milliseconds. + * @return The filtered array of notes. + */ + public static function clampSongNoteData(notes:Array, startTime:Float, endTime:Float):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return note.time >= startTime && note.time <= endTime; + }); + } + + /** + * Given an array of SongEventData objects, return a new array of SongEventData objects + * which excludes any events whose timestamps are outside of the given range. + * @param events The events to modify. + * @param startTime The start of the range in milliseconds. + * @param endTime The end of the range in milliseconds. + * @return The filtered array of events. + */ + public static function clampSongEventData(events:Array, startTime:Float, endTime:Float):Array + { + return events.filter(function(event:SongEventData):Bool { + return event.time >= startTime && event.time <= endTime; + }); + } + + /** + * Return a new array without a certain subset of notes from an array of SongNoteData objects. + * Does not mutate the original array. + * + * @param notes The array of notes to be subtracted from. + * @param subtrahend The notes to remove from the `notes` array. Yes, subtrahend is a real word. + */ + public static function subtractNotes(notes:Array, subtrahend:Array) + { + if (notes.length == 0 || subtrahend.length == 0) return notes; + + var result = notes.filter(function(note:SongNoteData):Bool { + for (x in subtrahend) + { + // The currently iterated note is in the subtrahend array. + // SongNoteData's == operation has been overridden so that this will work. + if (x == note) return false; + } + + return true; + }); + + return result; + } + + /** + * Return a new array without a certain subset of events from an array of SongEventData objects. + * Does not mutate the original array. + * + * @param events The array of events to be subtracted from. + * @param subtrahend The events to remove from the `events` array. Yes, subtrahend is a real word. + */ + public static function subtractEvents(events:Array, subtrahend:Array) + { + if (events.length == 0 || subtrahend.length == 0) return events; + + return events.filter(function(event:SongEventData):Bool { + for (x in subtrahend) + { + // The currently iterated event is in the subtrahend array. + // SongEventData's == operation has been overridden so that this will work. + if (x == event) return false; + } + return true; + }); + } + + /** + * Create an array of notes whose note data is flipped (player becomes opponent and vice versa) + * Does not mutate the original array. + */ + public static function flipNotes(notes:Array, ?strumlineSize:Int = 4):Array + { + return notes.map(function(note:SongNoteData):SongNoteData { + var newData = note.data; + + if (newData < strumlineSize) newData += strumlineSize; + else + newData -= strumlineSize; + + return new SongNoteData(note.time, newData, note.length, note.kind); + }); + } + + /** + * Prepare an array of notes to be used as the clipboard data. + * + * Offset the provided array of notes such that the first note is at 0 milliseconds. + */ + public static function buildNoteClipboard(notes:Array, ?timeOffset:Int = null):Array + { + if (notes.length == 0) return notes; + if (timeOffset == null) timeOffset = Std.int(notes[0].time); + return offsetSongNoteData(sortNotes(notes), -timeOffset); + } + + /** + * Prepare an array of events to be used as the clipboard data. + * + * Offset the provided array of events such that the first event is at 0 milliseconds. + */ + public static function buildEventClipboard(events:Array, ?timeOffset:Int = null):Array + { + if (events.length == 0) return events; + if (timeOffset == null) timeOffset = Std.int(events[0].time); + return offsetSongEventData(sortEvents(events), -timeOffset); + } + + /** + * Sort an array of notes by strum time. + */ + public static function sortNotes(notes:Array, desc:Bool = false):Array + { + // TODO: Modifies the array in place. Is this okay? + notes.sort(function(a:SongNoteData, b:SongNoteData):Int { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); + }); + return notes; + } + + /** + * Sort an array of events by strum time. + */ + public static function sortEvents(events:Array, desc:Bool = false):Array + { + // TODO: Modifies the array in place. Is this okay? + events.sort(function(a:SongEventData, b:SongEventData):Int { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time); + }); + return events; + } + + /** + * Sort an array of notes by strum time. + */ + public static function sortTimeChanges(timeChanges:Array, desc:Bool = false):Array + { + // TODO: Modifies the array in place. Is this okay? + timeChanges.sort(function(a:SongTimeChange, b:SongTimeChange):Int { + return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.timeStamp, b.timeStamp); + }); + return timeChanges; + } + + /** + * Serialize note and event data and write it to the clipboard. + */ + public static function writeItemsToClipboard(data:SongClipboardItems):Void + { + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); + var dataString:String = writer.write(data, ' '); + + ClipboardUtil.setClipboard(dataString); + + trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.'); + } + + /** + * Read an array of note data from the clipboard and deserialize it. + */ + public static function readItemsFromClipboard():SongClipboardItems + { + var notesString = ClipboardUtil.getClipboard(); + + trace('Read ${notesString.length} characters from clipboard.'); + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + parser.fromJson(notesString, 'clipboard'); + if (parser.errors.length > 0) + { + trace('[SongDataUtils] Error parsing note JSON data from clipboard.'); + for (error in parser.errors) + DataError.printError(error); + return { + valid: false, + notes: [], + events: [] + }; + } + else + { + var data:SongClipboardItems = parser.value; + trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.'); + data.valid = true; + return data; + } + } + + /** + * Filter a list of notes to only include notes that are within the given time range. + */ + public static function getNotesInTimeRange(notes:Array, start:Float, end:Float):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return note.time >= start && note.time <= end; + }); + } + + /** + * Filter a list of events to only include events that are within the given time range. + */ + public static function getEventsInTimeRange(events:Array, start:Float, end:Float):Array + { + return events.filter(function(event:SongEventData):Bool { + return event.time >= start && event.time <= end; + }); + } + + /** + * Filter a list of notes to only include notes whose data is within the given range, inclusive. + */ + public static function getNotesInDataRange(notes:Array, start:Int, end:Int):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return note.data >= start && note.data <= end; + }); + } + + /** + * Filter a list of notes to only include notes whose data is one of the given values. + */ + public static function getNotesWithData(notes:Array, data:Array):Array + { + return notes.filter(function(note:SongNoteData):Bool { + return data.indexOf(note.data) != -1; + }); + } + + /** + * Filter a list of events to only include events whose kind is one of the given values. + */ + public static function getEventsWithKind(events:Array, kinds:Array):Array + { + return events.filter(function(event:SongEventData):Bool { + return kinds.indexOf(event.eventKind) != -1; + }); + } +} + +typedef SongClipboardItems = +{ + @:optional + var valid:Bool; + var notes:Array; + var events:Array; +} diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx new file mode 100644 index 00000000..fcdabc91 --- /dev/null +++ b/source/funkin/data/song/SongRegistry.hx @@ -0,0 +1,519 @@ +package funkin.data.song; + +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.song.SongData; +import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0; +import funkin.data.song.migrator.SongData_v2_1_0.SongMetadata_v2_1_0; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.play.song.ScriptedSong; +import funkin.play.song.Song; +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +using funkin.data.song.migrator.SongDataMigrator; + +@:nullSafety +class SongRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.4"; + + public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x"; + + public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static final SONG_MUSIC_DATA_VERSION:thx.semver.Version = "2.0.0"; + + public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; + + public static var DEFAULT_GENERATEDBY(get, never):String; + + static function get_DEFAULT_GENERATEDBY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + + public function new() + { + super('SONG', 'songs', SONG_METADATA_VERSION_RULE); + } + + public override function loadEntries():Void + { + clearEntries(); + + // + // SCRIPTED ENTRIES + // + var scriptedEntryClassNames:Array = getScriptedClassNames(); + log('Parsing ${scriptedEntryClassNames.length} scripted entries...'); + + for (entryCls in scriptedEntryClassNames) + { + var entry:Song = createScriptedEntry(entryCls); + + if (entry != null) + { + log('Successfully created scripted entry (${entryCls} = ${entry.id})'); + entries.set(entry.id, entry); + scriptedEntryIds.set(entry.id, entryCls); + } + else + { + log('Failed to create scripted entry (${entryCls})'); + } + } + + // + // UNSCRIPTED ENTRIES + // + var entryIdList:Array = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String { + return songDataPath.split('/')[0]; + }); + var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool { + return !entries.exists(entryId); + }); + log('Parsing ${unscriptedEntryIds.length} unscripted entries...'); + for (entryId in unscriptedEntryIds) + { + try + { + var entry:Null = createEntry(entryId); + if (entry != null) + { + trace(' Loaded entry data: ${entry}'); + entries.set(entry.id, entry); + } + } + catch (e:Dynamic) + { + // Print the error. + trace(' Failed to load entry data: ${entryId}'); + trace(e); + continue; + } + } + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null + { + return parseEntryMetadata(id); + } + + /** + * Parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String = 'raw'):Null + { + return parseEntryMetadataRaw(contents); + } + + public function parseEntryMetadata(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + + switch (loadEntryMetadataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return cleanMetadata(parser.value, variation); + } + + public function parseEntryMetadataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return cleanMetadata(parser.value, variation); + } + + public function parseEntryMetadataWithMigration(id:String, variation:String, version:thx.semver.Version):Null + { + variation = variation ?? Constants.DEFAULT_VARIATION; + + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadata(id, variation); + } + else if (VersionUtil.validateVersion(version, "2.1.x")) + { + return parseEntryMetadata_v2_1_0(id, variation); + } + else if (VersionUtil.validateVersion(version, "2.0.x")) + { + return parseEntryMetadata_v2_0_0(id, variation); + } + else + { + throw '[${registryId}] Metadata entry ${id}:${variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + public function parseEntryMetadataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version, + ?variation:String):Null + { + // If a version rule is not specified, do not check against it. + if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE)) + { + return parseEntryMetadataRaw(contents, fileName, variation); + } + else if (VersionUtil.validateVersion(version, "2.1.x")) + { + return parseEntryMetadataRaw_v2_1_0(contents, fileName); + } + else if (VersionUtil.validateVersion(version, "2.0.x")) + { + return parseEntryMetadataRaw_v2_0_0(contents, fileName); + } + else + { + throw '[${registryId}] Metadata entry "${fileName}" does not support migration to version ${SONG_METADATA_VERSION_RULE}.'; + } + } + + function parseEntryMetadata_v2_1_0(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + + switch (loadEntryMetadataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return cleanMetadata(parser.value.migrate(), variation); + } + + function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + + switch (loadEntryMetadataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return cleanMetadata(parser.value.migrate(), variation); + } + + function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value.migrate(); + } + + function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value.migrate(); + } + + public function parseMusicData(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + + switch (loadMusicDataFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + public function parseMusicDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + // If a version rule is not specified, do not check against it. + if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE)) + { + return parseMusicData(id, variation); + } + else + { + throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + public function parseMusicDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null + { + // If a version rule is not specified, do not check against it. + if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE)) + { + return parseMusicDataRaw(contents, fileName); + } + else + { + throw '[${registryId}] Chart entry "$fileName" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + public function parseEntryChartData(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + + switch (loadEntryChartFile(id, variation)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return cleanChartData(parser.value, variation); + } + + public function parseEntryChartDataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return cleanChartData(parser.value, variation); + } + + public function parseEntryChartDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return parseEntryChartData(id, variation); + } + else + { + throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + public function parseEntryChartDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null + { + // If a version rule is not specified, do not check against it. + if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE)) + { + return parseEntryChartDataRaw(contents, fileName); + } + else + { + throw '[${registryId}] Chart entry "${fileName}" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.'; + } + } + + function loadEntryMetadataFile(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); + if (!openfl.Assets.exists(entryFilePath)) + { + trace(' [WARN] Could not locate file $entryFilePath'); + return null; + } + var rawJson:Null = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function loadMusicDataFile(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json'); + if (!openfl.Assets.exists(entryFilePath)) return null; + var rawJson:String = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + function hasMusicDataFile(id:String, ?variation:String):Bool + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json'); + return openfl.Assets.exists(entryFilePath); + } + + function loadEntryChartFile(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); + if (!openfl.Assets.exists(entryFilePath)) return null; + var rawJson:String = openfl.Assets.getText(entryFilePath); + if (rawJson == null) return null; + rawJson = rawJson.trim(); + return {fileName: entryFilePath, contents: rawJson}; + } + + public function fetchEntryMetadataVersion(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryStr:Null = loadEntryMetadataFile(id, variation)?.contents; + var entryVersion:Null = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + public function fetchEntryChartVersion(id:String, ?variation:String):Null + { + variation = variation == null ? Constants.DEFAULT_VARIATION : variation; + var entryStr:Null = loadEntryChartFile(id, variation)?.contents; + var entryVersion:Null = VersionUtil.getVersionFromJSON(entryStr); + return entryVersion; + } + + function cleanMetadata(metadata:SongMetadata, variation:String):SongMetadata + { + metadata.variation = variation; + + return metadata; + } + + function cleanChartData(chartData:SongChartData, variation:String):SongChartData + { + chartData.variation = variation; + + return chartData; + } + + /** + * A list of all difficulties for a specific character. + */ + public function listAllDifficulties(characterId:String):Array + { + var allDifficulties:Array = Constants.DEFAULT_DIFFICULTY_LIST.copy(); + var character = PlayerRegistry.instance.fetchEntry(characterId); + + if (character == null) + { + trace(' [WARN] Could not locate character $characterId'); + return allDifficulties; + } + + allDifficulties = []; + for (songId in listEntryIds()) + { + var song = fetchEntry(songId); + if (song == null) continue; + + for (diff in song.listDifficulties(null, song.getVariationsByCharacter(character))) + { + if (!allDifficulties.contains(diff)) allDifficulties.push(diff); + } + } + + if (allDifficulties.length == 0) + { + trace(' [WARN] No difficulties found. Returning default difficulty list.'); + allDifficulties = Constants.DEFAULT_DIFFICULTY_LIST.copy(); + } + + return allDifficulties; + } +} diff --git a/source/funkin/data/song/importer/ChartManifestData.hx b/source/funkin/data/song/importer/ChartManifestData.hx new file mode 100644 index 00000000..04b5a1b6 --- /dev/null +++ b/source/funkin/data/song/importer/ChartManifestData.hx @@ -0,0 +1,93 @@ +package funkin.data.song.importer; + +/** + * A helper JSON blob found in `.fnfc` files. + */ +class ChartManifestData +{ + /** + * The current semantic version of the chart manifest data. + */ + public static final CHART_MANIFEST_DATA_VERSION:thx.semver.Version = "1.0.0"; + + @:default(funkin.data.song.importer.ChartManifestData.CHART_MANIFEST_DATA_VERSION) + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:thx.semver.Version; + + /** + * The internal song ID for this chart. + * The metadata and chart data file names are derived from this. + */ + public var songId:String; + + public function new(songId:String) + { + this.version = CHART_MANIFEST_DATA_VERSION; + this.songId = songId; + } + + public function getMetadataFileName(?variation:String):String + { + if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION; + + return '$songId-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}'; + } + + public function getChartDataFileName(?variation:String):String + { + if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION; + + return '$songId-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}'; + } + + public function getInstFileName(?variation:String):String + { + if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION; + + return 'Inst${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}'; + } + + public function getVocalsFileName(charId:String, ?variation:String):String + { + if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION; + + return 'Voices-$charId${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}'; + } + + /** + * Serialize this ChartManifestData into a JSON string. + * @return The JSON string. + */ + public function serialize(pretty:Bool = true):String + { + // Update generatedBy and version before writing. + updateVersionToLatest(); + + var writer = new json2object.JsonWriter(); + return writer.write(this, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.version = CHART_MANIFEST_DATA_VERSION; + } + + public static function deserialize(contents:String):Null + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + parser.fromJson(contents, 'manifest.json'); + + if (parser.errors.length > 0) + { + trace('[ChartManifest] Failed to parse chart file manifest'); + + for (error in parser.errors) + DataError.printError(error); + + return null; + } + return parser.value; + } +} diff --git a/source/funkin/data/song/importer/FNFLegacyData.hx b/source/funkin/data/song/importer/FNFLegacyData.hx new file mode 100644 index 00000000..52380d34 --- /dev/null +++ b/source/funkin/data/song/importer/FNFLegacyData.hx @@ -0,0 +1,125 @@ +package funkin.data.song.importer; + +import haxe.ds.Either; + +/** + * A data structure representing a song in the old chart format. + * This only works for charts compatible with Week 7, so you'll need a custom program + * to handle importing charts from mods or other engines. + */ +class FNFLegacyData +{ + public var song:LegacySongData; +} + +class LegacySongData +{ + public var player1:String; // Boyfriend + public var player2:String; // Opponent + + @:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds) + public var speed:Either; + @:optional + public var stageDefault:Null; + public var bpm:Float; + + @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData) + public var notes:Either, LegacyNoteData>; + public var song:String; // Song name + + public function new() {} + + public function toString():String + { + var notesStr:String = switch (notes) + { + case Left(sections): 'single difficulty w/ ${sections.length} sections'; + case Right(data): + var difficultyCount:Int = 0; + if (data.easy != null) difficultyCount++; + if (data.normal != null) difficultyCount++; + if (data.hard != null) difficultyCount++; + '${difficultyCount} difficulties'; + }; + return 'LegacySongData($player1, $player2, $notesStr)'; + } +} + +typedef LegacyScrollSpeeds = +{ + public var ?easy:Float; + public var ?normal:Float; + public var ?hard:Float; +}; + +typedef LegacyNoteData = +{ + /** + * The easy difficulty. + */ + public var ?easy:Array; + + /** + * The normal difficulty. + */ + public var ?normal:Array; + + /** + * The hard difficulty. + */ + public var ?hard:Array; +}; + +typedef LegacyNoteSection = +{ + /** + * Whether the section is a must-hit section. + * If true, 0-3 are boyfriends notes, 4-7 are opponents notes. + * If false, 0-3 are opponents notes, 4-7 are boyfriends notes. + */ + public var mustHitSection:Bool; + + /** + * Array of note data: + * - Direction + * - Time (ms) + * - Sustain Duration (ms) + * - Note kind (true = "alt", or string) + */ + public var sectionNotes:Array; + + public var ?typeOfSection:Int; + + public var ?lengthInSteps:Int; + + // BPM changes + public var ?changeBPM:Bool; + public var ?bpm:Float; +} + +/** + * Notes in the old format are stored as an Array + * We use a custom parser to manage this. + */ +@:jcustomparse(funkin.data.DataParse.legacyNote) +class LegacyNote +{ + public var time:Float; + public var data:Int; + public var length:Float; + public var alt:Bool; + + public function new(time:Float, data:Int, ?length:Float, ?alt:Bool) + { + this.time = time; + this.data = data; + + this.length = length ?? 0.0; + this.alt = alt ?? false; + } + + public inline function getKind():String + { + return this.alt ? 'alt' : 'normal'; + } +} diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx new file mode 100644 index 00000000..96a1051c --- /dev/null +++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx @@ -0,0 +1,224 @@ +package funkin.data.song.importer; // import is a reserved word dumbass + +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.importer.FNFLegacyData; +import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection; + +class FNFLegacyImporter +{ + public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData + { + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = true; // Set to true to ignore extra variables that might be included in the JSON. + parser.fromJson(input, fileName); + + if (parser.errors.length > 0) + { + trace('[FNFLegacyImporter] Error parsing JSON data from ' + fileName + ':'); + for (error in parser.errors) + DataError.printError(error); + return null; + } + return parser.value; + } + + /** + * @param data The raw parsed JSON data to migrate, as a Dynamic. + * @param difficulty + * @return SongMetadata + */ + public static function migrateMetadata(songData:FNFLegacyData, difficulty:String = 'normal'):SongMetadata + { + trace('Migrating song metadata from FNF Legacy.'); + + var songMetadata:SongMetadata = new SongMetadata('Import', Constants.DEFAULT_ARTIST, 'default'); + + var hadError:Bool = false; + + // Set generatedBy string for debugging. + songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)'; + + songMetadata.playData.stage = songData?.song?.stageDefault ?? 'mainStage'; + songMetadata.songName = songData?.song?.song ?? 'Import'; + songMetadata.playData.difficulties = []; + + if (songData?.song?.notes != null) + { + switch (songData.song.notes) + { + case Left(notes): + // One difficulty of notes. + songMetadata.playData.difficulties.push(difficulty); + case Right(difficulties): + if (difficulties.easy != null) songMetadata.playData.difficulties.push('easy'); + if (difficulties.normal != null) songMetadata.playData.difficulties.push('normal'); + if (difficulties.hard != null) songMetadata.playData.difficulties.push('hard'); + } + } + + songMetadata.playData.songVariations = []; + + songMetadata.timeChanges = rebuildTimeChanges(songData); + + songMetadata.playData.characters = new SongCharacterData(songData?.song?.player1 ?? 'bf', 'gf', songData?.song?.player2 ?? 'dad'); + + return songMetadata; + } + + public static function migrateChartData(songData:FNFLegacyData, difficulty:String = 'normal'):SongChartData + { + trace('Migrating song chart data from FNF Legacy.'); + + var songChartData:SongChartData = new SongChartData([difficulty => 1.0], [], [difficulty => []]); + + if (songData?.song?.notes != null) + { + switch (songData.song.notes) + { + case Left(notes): + // One difficulty of notes. + songChartData.notes.set(difficulty, migrateNoteSections(notes)); + case Right(difficulties): + var baseDifficulty = null; + if (difficulties.easy != null) songChartData.notes.set('easy', migrateNoteSections(difficulties.easy)); + if (difficulties.normal != null) songChartData.notes.set('normal', migrateNoteSections(difficulties.normal)); + if (difficulties.hard != null) songChartData.notes.set('hard', migrateNoteSections(difficulties.hard)); + } + } + + // Import event data. + songChartData.events = rebuildEventData(songData); + + switch (songData.song.speed) + { + case Left(speed): + // All difficulties will use the one scroll speed. + songChartData.scrollSpeed.set('default', speed); + case Right(speeds): + if (speeds.easy != null) songChartData.scrollSpeed.set('easy', speeds.easy); + if (speeds.normal != null) songChartData.scrollSpeed.set('normal', speeds.normal); + if (speeds.hard != null) songChartData.scrollSpeed.set('hard', speeds.hard); + } + + return songChartData; + } + + /** + * FNF Legacy doesn't have song events, but without them the song won't look right, + * so we insert camera events when the character changes. + */ + static function rebuildEventData(songData:FNFLegacyData):Array + { + var result:Array = []; + + var noteSections = []; + switch (songData.song.notes) + { + case Left(notes): + // All difficulties will use the one scroll speed. + noteSections = notes; + case Right(difficulties): + if (difficulties.normal != null) noteSections = difficulties.normal; + if (difficulties.hard != null) noteSections = difficulties.normal; + if (difficulties.easy != null) noteSections = difficulties.normal; + } + + if (noteSections == null || noteSections.length == 0) return result; + + // Add camera events. + var lastSectionWasMustHit:Null = null; + for (section in noteSections) + { + // Skip empty sections. + if (section.sectionNotes.length == 0) continue; + + if (section.mustHitSection != lastSectionWasMustHit) + { + lastSectionWasMustHit = section.mustHitSection; + + var firstNote:LegacyNote = section.sectionNotes[0]; + + result.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1})); + } + } + + return result; + } + + /** + * Port over time changes from FNF Legacy. + * If a section contains a BPM change, it will be applied at the timestamp of the first note in that section. + */ + static function rebuildTimeChanges(songData:FNFLegacyData):Array + { + var result:Array = []; + + result.push(new SongTimeChange(0, songData?.song?.bpm ?? Constants.DEFAULT_BPM)); + + var noteSections = []; + switch (songData.song.notes) + { + case Left(notes): + // All difficulties will use the one scroll speed. + noteSections = notes; + case Right(difficulties): + if (difficulties.normal != null) noteSections = difficulties.normal; + if (difficulties.hard != null) noteSections = difficulties.normal; + if (difficulties.easy != null) noteSections = difficulties.normal; + } + + if (noteSections == null || noteSections.length == 0) return result; + + for (noteSection in noteSections) + { + if (noteSection.changeBPM ?? false) + { + var firstNote:LegacyNote = noteSection.sectionNotes[0]; + if (firstNote != null) result.push(new SongTimeChange(firstNote.time, noteSection.bpm)); + } + } + + return result; + } + + static final STRUMLINE_SIZE = 4; + + static function migrateNoteSections(input:Array):Array + { + var result:Array = []; + + for (section in input) + { + var mustHitSection = section.mustHitSection ?? false; + for (note in section.sectionNotes) + { + // Handle the dumb logic for mustHitSection. + var noteData = note.data; + if (noteData < 0) continue; // Exclude Psych event notes. + if (noteData > (STRUMLINE_SIZE * 2)) noteData = noteData % (2 * STRUMLINE_SIZE); // Handle other engine event notes. + + // Flip notes if mustHitSection is FALSE (not true lol). + if (!mustHitSection) + { + if (noteData >= STRUMLINE_SIZE) + { + noteData -= STRUMLINE_SIZE; + } + else + { + noteData += STRUMLINE_SIZE; + } + } + + result.push(new SongNoteData(note.time, noteData, note.length, note.getKind())); + } + } + + return result; + } +} diff --git a/source/funkin/data/song/migrator/SongDataMigrator.hx b/source/funkin/data/song/migrator/SongDataMigrator.hx new file mode 100644 index 00000000..2603ab1f --- /dev/null +++ b/source/funkin/data/song/migrator/SongDataMigrator.hx @@ -0,0 +1,116 @@ +package funkin.data.song.migrator; + +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongPlayData; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0; +import funkin.data.song.migrator.SongData_v2_0_0.SongPlayData_v2_0_0; +import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0; + +using funkin.data.song.migrator.SongDataMigrator; // Does this even work lol? + +/** + * This class contains functions to migrate older data formats to the current one. + * + * Utilizes static extensions with overloaded inline functions to make migration as easy as `.migrate()`. + * @see https://try.haxe.org/#e1c1cf22 + */ +class SongDataMigrator +{ + public static overload extern inline function migrate(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata + { + return migrate_SongMetadata_v2_1_0(input); + } + + public static function migrate_SongMetadata_v2_1_0(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata + { + var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation); + result.version = SongRegistry.SONG_METADATA_VERSION; + result.timeFormat = input.timeFormat; + result.divisions = input.divisions; + result.timeChanges = input.timeChanges; + result.looped = input.looped; + result.playData = input.playData.migrate(); + result.generatedBy = input.generatedBy; + + return result; + } + + public static overload extern inline function migrate(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData + { + return migrate_SongPlayData_v2_1_0(input); + } + + public static function migrate_SongPlayData_v2_1_0(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData + { + var result:SongPlayData = new SongPlayData(); + result.songVariations = input.songVariations; + result.difficulties = input.difficulties; + result.stage = input.stage; + result.characters = input.characters; + + // Renamed + result.noteStyle = input.noteSkin; + + // Added + result.ratings = ['default' => 1]; + result.album = null; + + return result; + } + + public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata + { + return migrate_SongMetadata_v2_0_0(input); + } + + public static function migrate_SongMetadata_v2_0_0(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata + { + var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation); + result.version = SongRegistry.SONG_METADATA_VERSION; + result.timeFormat = input.timeFormat; + result.divisions = input.divisions; + result.timeChanges = input.timeChanges; + result.looped = input.looped; + result.playData = input.playData.migrate(); + result.generatedBy = input.generatedBy; + + return result; + } + + public static overload extern inline function migrate(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData + { + return migrate_SongPlayData_v2_0_0(input); + } + + public static function migrate_SongPlayData_v2_0_0(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData + { + var result:SongPlayData = new SongPlayData(); + result.songVariations = input.songVariations; + result.difficulties = input.difficulties; + result.stage = input.stage; + + // Added + result.ratings = ['default' => 1]; + result.album = null; + + // Renamed + result.noteStyle = input.noteSkin; + + // Fetch the first playable character and migrate it. + var firstCharKey:Null = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0]; + var firstCharData:Null = input.playableChars.get(firstCharKey); + + if (firstCharData == null) + { + // Fill in a default playable character. + result.characters = new SongCharacterData('bf', 'gf', 'dad'); + } + else + { + result.characters = new SongCharacterData(firstCharKey, firstCharData.girlfriend, firstCharData.opponent, firstCharData.inst); + } + + return result; + } +} diff --git a/source/funkin/data/song/migrator/SongData_v2_0_0.hx b/source/funkin/data/song/migrator/SongData_v2_0_0.hx new file mode 100644 index 00000000..751ba4a5 --- /dev/null +++ b/source/funkin/data/song/migrator/SongData_v2_0_0.hx @@ -0,0 +1,133 @@ +package funkin.data.song.migrator; + +import thx.semver.Version; +import funkin.data.song.SongData; + +class SongMetadata_v2_0_0 +{ + // ========== + // MODIFIED VALUES + // =========== + + /** + * In metadata `v2.1.0`, `SongPlayData` was refactored. + */ + public var playData:SongPlayData_v2_0_0; + + /** + * In metadata `v2.1.0`, `variation` was set to `ignore` when writing. + */ + @:optional + @:default('default') + public var variation:String; + + // ========== + // UNMODIFIED VALUES + // ========== + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public var timeFormat:SongData.SongTimeFormat; + + public var timeChanges:Array; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongMetadata[LEGACY:v2.0.0](${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +class SongPlayData_v2_0_0 +{ + // ========== + // MODIFIED VALUES + // =========== + + /** + * In metadata version `v2.1.0`, this was refactored to a single `SongCharacterData` object. + */ + public var playableChars:Map; + + /** + * In metadata version `v2.2.0`, this was renamed to `noteStyle`. + */ + public var noteSkin:String; + + // In 2.2.0, the ratings value was added. + // In 2.2.0, the album value was added. + // ========== + // UNMODIFIED VALUES + // ========== + @:default([]) + @:optional + public var songVariations:Array; + public var difficulties:Array; + + public var stage:String; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayData[LEGACY:v2.0.0](${this.songVariations}, ${this.difficulties})'; + } +} + +class SongPlayableChar_v2_0_0 +{ + @:alias('g') + @:optional + @:default('') + public var girlfriend:String = ''; + + @:alias('o') + @:optional + @:default('') + public var opponent:String = ''; + + @:alias('i') + @:optional + @:default('') + public var inst:String = ''; + + public function new(girlfriend:String = '', opponent:String = '', inst:String = '') + { + this.girlfriend = girlfriend; + this.opponent = opponent; + this.inst = inst; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayableChar[LEGACY:v2.0.0](${this.girlfriend}, ${this.opponent}, ${this.inst})'; + } +} diff --git a/source/funkin/data/song/migrator/SongData_v2_1_0.hx b/source/funkin/data/song/migrator/SongData_v2_1_0.hx new file mode 100644 index 00000000..1a61184e --- /dev/null +++ b/source/funkin/data/song/migrator/SongData_v2_1_0.hx @@ -0,0 +1,112 @@ +package funkin.data.song.migrator; + +import funkin.data.song.SongData; +import funkin.data.song.SongRegistry; +import thx.semver.Version; + +@:nullSafety +class SongMetadata_v2_1_0 +{ + // ========== + // MODIFIED VALUES + // =========== + + /** + * In metadata `v2.2.0`, `SongPlayData` was refactored. + */ + public var playData:SongPlayData_v2_1_0; + + // In metadata `v2.2.1`, `SongOffsets` was added. + // var offsets:SongOffsets; + // ========== + // UNMODIFIED VALUES + // ========== + @:jcustomparse(funkin.data.DataParse.semverVersion) + @:jcustomwrite(funkin.data.DataWrite.semverVersion) + public var version:Version; + + @:default("Unknown") + public var songName:String; + + @:default("Unknown") + public var artist:String; + + @:optional + @:default(96) + public var divisions:Null; // Optional field + + @:optional + @:default(false) + public var looped:Bool; + + @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY) + public var generatedBy:String; + + public var timeFormat:SongData.SongTimeFormat; + + public var timeChanges:Array; + + /** + * Defaults to `Constants.DEFAULT_VARIATION`. Populated later. + */ + @:jignored + public var variation:String; + + public function new(songName:String, artist:String, ?variation:String) + { + this.version = SongRegistry.SONG_METADATA_VERSION; + this.songName = songName; + this.artist = artist; + this.timeFormat = 'ms'; + this.divisions = null; + this.timeChanges = [new SongTimeChange(0, 100)]; + this.looped = false; + this.playData = new SongPlayData_v2_1_0(); + this.playData.songVariations = []; + this.playData.difficulties = []; + this.playData.characters = new SongCharacterData('bf', 'gf', 'dad'); + this.playData.stage = 'mainStage'; + this.playData.noteSkin = 'funkin'; + this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY; + // Variation ID. + this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongMetadata[LEGACY:v2.1.0](${this.songName} by ${this.artist}, variation ${this.variation})'; + } +} + +class SongPlayData_v2_1_0 +{ + /** + * In `v2.2.0`, this value was renamed to `noteStyle`. + */ + public var noteSkin:String; + + // In 2.2.0, the ratings value was added. + // In 2.2.0, the album value was added. + // ========== + // UNMODIFIED VALUES + // ========== + @:default([]) + @:optional + public var songVariations:Array; + public var difficulties:Array; + public var characters:SongData.SongCharacterData; + public var stage:String; + + public function new() {} + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongPlayData[LEGACY:v2.1.0](${this.songVariations}, ${this.difficulties})'; + } +} diff --git a/source/funkin/data/stage/CHANGELOG.md b/source/funkin/data/stage/CHANGELOG.md new file mode 100644 index 00000000..f83ce608 --- /dev/null +++ b/source/funkin/data/stage/CHANGELOG.md @@ -0,0 +1,24 @@ +# Story Mode Level Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.3] +### Added +- Added the `color` attribute on stage props to tint them. +- Added the `blend` attribute on stage props to apply blend modes. +- Added the `angle` attribute on stage props to apply a rotation to them. + +## [1.0.2] +### Added +- Added the ability to specify `flipX` and `flipY` on stage props to horizontally or vertically flip, respectively. + +## [1.0.1] +### Added +- Added the ability to specify a hexadecimal color in the `assetPath` field instead of a texture key. + - In this case, the `scale` property will be used to determine the size of the rectangle in pixels. + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx new file mode 100644 index 00000000..88cffc7e --- /dev/null +++ b/source/funkin/data/stage/StageData.hx @@ -0,0 +1,292 @@ +package funkin.data.stage; + +import funkin.data.animation.AnimationData; + +@:nullSafety +class StageData +{ + /** + * The sematic version number of the stage data JSON format. + * Supports fancy comparisons like NPM does it's neat. + */ + @:default(funkin.data.stage.StageRegistry.STAGE_DATA_VERSION) + public var version:String; + + public var name:String = 'Unknown'; + public var props:Array = []; + public var characters:StageDataCharacters; + + @:default(1.0) + @:optional + public var cameraZoom:Null; + + @:default("shared") + @:optional + public var directory:Null; + + public function new() + { + this.version = StageRegistry.STAGE_DATA_VERSION; + this.characters = makeDefaultCharacters(); + } + + function makeDefaultCharacters():StageDataCharacters + { + return { + bf: + { + zIndex: 0, + scale: 1, + position: [0, 0], + cameraOffsets: [-100, -100] + }, + dad: + { + zIndex: 0, + scale: 1, + position: [0, 0], + cameraOffsets: [100, -100] + }, + gf: + { + zIndex: 0, + scale: 1, + position: [0, 0], + cameraOffsets: [0, 0] + } + }; + } + + /** + * Convert this StageData into a JSON string. + */ + public function serialize(pretty:Bool = true):String + { + // Update generatedBy and version before writing. + updateVersionToLatest(); + + var writer = new json2object.JsonWriter(); + return writer.write(this, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.version = StageRegistry.STAGE_DATA_VERSION; + } +} + +typedef StageDataCharacters = +{ + var bf:StageDataCharacter; + var dad:StageDataCharacter; + var gf:StageDataCharacter; +}; + +typedef StageDataProp = +{ + /** + * The name of the prop for later lookup by scripts. + * Optional; if unspecified, the prop can't be referenced by scripts. + */ + @:optional + var name:String; + + /** + * The asset used to display the prop. + * NOTE: As of Stage data v1.0.1, you can also use a color here to create a rectangle, like "#ff0000". + * In this case, the `scale` property will be used to determine the size of the prop. + */ + var assetPath:String; + + /** + * The position of the prop as an [x, y] array of two floats. + */ + var position:Array; + + /** + * A number determining the stack order of the prop, relative to other props and the characters in the stage. + * Props with lower numbers render below those with higher numbers. + * This is just like CSS, it isn't hard. + * @default 0 + */ + @:optional + @:default(0) + var zIndex:Int; + + /** + * If set to true, anti-aliasing will be forcibly disabled on the sprite. + * This prevents blurry images on pixel-art levels. + * @default false + */ + @:optional + @:default(false) + var isPixel:Bool; + + /** + * If set to true, the prop will be flipped horizontally. + * @default false + */ + @:optional + @:default(false) + var flipX:Bool; + + /** + * If set to true, the prop will be flipped vertically. + * @default false + */ + @:optional + @:default(false) + var flipY:Bool; + + /** + * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats. + * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory. + */ + @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats) + @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats) + @:optional + @:default(Left(1.0)) + var scale:haxe.ds.Either>; + + /** + * The alpha of the prop, as a float. + * @default 1.0 + */ + @:optional + @:default(1.0) + var alpha:Float; + + /** + * If not zero, this prop will play an animation every X beats of the song. + * This requires animations to be defined. If `danceLeft` and `danceRight` are defined, + * they will alternated between, otherwise the `idle` animation will be used. + * Supports up to 0.25 precision. + * @default 0.0 + */ + @:default(0.0) + @:optional + var danceEvery:Float; + + /** + * How much the prop scrolls relative to the camera. Used to create a parallax effect. + * Represented as an [x, y] array of two floats. + * [1, 1] means the prop moves 1:1 with the camera. + * [0.5, 0.5] means the prop moves half as much as the camera. + * [0, 0] means the prop is not moved. + * @default [1, 1] + */ + @:optional + @:default([1, 1]) + var scroll:Array; + + /** + * An optional array of animations which the prop can play. + * @default Prop has no animations. + */ + @:optional + @:default([]) + var animations:Array; + + /** + * If animations are used, this is the name of the animation to play first. + * @default Don't play an animation. + */ + @:optional + var startingAnimation:Null; + + /** + * The animation type to use. + * Options: "sparrow", "packer" + * @default "sparrow" + */ + @:default("sparrow") + @:optional + var animType:String; + + /** + * The angle of the prop, as a float. + * @default 1.0 + */ + @:optional + @:default(0.0) + var angle:Float; + + /** + * The blend mode of the prop, as a string. + * Just like in photoshop. + * @default Nothing. + */ + @:default("") + @:optional + var blend:String; + + /** + * The color of the prop overlay, as a hex string. + * White overlays, or the ones with the value #FFFFFF, do not appear. + * @default `#FFFFFF` + */ + @:default("#FFFFFF") + @:optional + var color:String; +}; + +typedef StageDataCharacter = +{ + /** + * A number determining the stack order of the character, relative to props and other characters in the stage. + * Again, just like CSS. + * @default 0 + */ + @:optional + @:default(0) + var zIndex:Int; + + /** + * The position to render the character at. + */ + @:optional + @:default([0, 0]) + var position:Array; + + /** + * The scale to render the character at. + */ + @:optional + @:default(1) + var scale:Float; + + /** + * The camera offsets to apply when focusing on the character on this stage. + * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF + */ + @:optional + var cameraOffsets:Array; + + /** + * How much the character scrolls relative to the camera. Used to create a parallax effect. + * Represented as an [x, y] array of two floats. + * [1, 1] means the character moves 1:1 with the camera. + * [0.5, 0.5] means the character moves half as much as the camera. + * [0, 0] means the character is not moved. + * @default [1, 1] + */ + @:optional + @:default([1, 1]) + var scroll:Array; + + /** + * The alpha of the character, as a float. + * @default 1.0 + */ + @:optional + @:default(1.0) + var alpha:Float; + + /** + * The angle of the character, as a float. + * @default 1.0 + */ + @:optional + @:default(0.0) + var angle:Float; +}; diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx new file mode 100644 index 00000000..72c8cb00 --- /dev/null +++ b/source/funkin/data/stage/StageRegistry.hx @@ -0,0 +1,23 @@ +package funkin.data.stage; + +import funkin.play.stage.Stage; +import funkin.play.stage.ScriptedStage; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class StageRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + */ + public static final STAGE_DATA_VERSION:thx.semver.Version = "1.0.3"; + + public static final STAGE_DATA_VERSION_RULE:thx.semver.VersionRule = ">=1.0.0 <1.1.0"; + + public function new() + { + super('STAGE', 'stages', STAGE_DATA_VERSION_RULE); + } +} diff --git a/source/funkin/data/stickers/CHANGELOG.md b/source/funkin/data/stickers/CHANGELOG.md new file mode 100644 index 00000000..041ecf2f --- /dev/null +++ b/source/funkin/data/stickers/CHANGELOG.md @@ -0,0 +1,9 @@ +# Sticker Pack Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] +Initial release. Sticker packs have been restructured compared to previous versions to be less complicated. diff --git a/source/funkin/data/stickers/StickerData.hx b/source/funkin/data/stickers/StickerData.hx new file mode 100644 index 00000000..c23511e8 --- /dev/null +++ b/source/funkin/data/stickers/StickerData.hx @@ -0,0 +1,30 @@ +package funkin.data.stickers; + +/** + * A type definition for a sticker pack. + * It includes things like its name, the artist, and what sticker graphics to use. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef StickerData = +{ + /** + * Semantic version of the sticker set data. + */ + public var version:String; + + /** + * Readable name of the sticker set. + */ + public var name:String; + + /** + * The artist of the sticker set. + */ + public var artist:String; + + /** + * The stickers in the set. + * This is simply a list of asset files to use. + */ + public var stickers:Array; +} diff --git a/source/funkin/data/stickers/StickerRegistry.hx b/source/funkin/data/stickers/StickerRegistry.hx new file mode 100644 index 00000000..08c2b966 --- /dev/null +++ b/source/funkin/data/stickers/StickerRegistry.hx @@ -0,0 +1,89 @@ +package funkin.data.stickers; + +import funkin.data.stickers.StickerData; +import funkin.ui.transition.stickers.StickerPack; +import funkin.ui.transition.stickers.ScriptedStickerPack; + +class StickerRegistry extends BaseRegistry +{ + /** + * The current version string for the sticker pack data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStickerData()` function. + */ + public static final STICKER_DATA_VERSION:thx.semver.Version = '1.0.0'; + + public static final STICKER_DATA_VERSION_RULE:thx.semver.VersionRule = '1.0.x'; + + public static final instance:StickerRegistry = new StickerRegistry(); + + public function new() + { + super('STICKER', 'stickerpacks', STICKER_DATA_VERSION_RULE); + } + + public function fetchDefault():StickerPack + { + return fetchEntry(Constants.DEFAULT_STICKER_PACK); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + * @param id The ID of the entry to load. + * @return The parsed data object. + */ + public function parseEntryData(id:String):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser:json2object.JsonParser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Parse and validate the JSON data and produce the corresponding data object. + * + * NOTE: Must be implemented on the implementation class. + * @param contents The JSON as a string. + * @param fileName An optional file name for error reporting. + * @return The parsed data object. + */ + public function parseEntryDataRaw(contents:String, ?fileName:String):Null + { + var parser:json2object.JsonParser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + + function createScriptedEntry(clsName:String):StickerPack + { + return ScriptedStickerPack.init(clsName, 'unknown'); + } + + function getScriptedClassNames():Array + { + return ScriptedStickerPack.listScriptClasses(); + } +} diff --git a/source/funkin/data/story/level/CHANGELOG.md b/source/funkin/data/story/level/CHANGELOG.md new file mode 100644 index 00000000..7e0042aa --- /dev/null +++ b/source/funkin/data/story/level/CHANGELOG.md @@ -0,0 +1,12 @@ +# Story Mode Level Data Schema Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.1] +- Added `visible` attribute. + +## [1.0.0] +Initial release. diff --git a/source/funkin/data/story/level/LevelData.hx b/source/funkin/data/story/level/LevelData.hx new file mode 100644 index 00000000..d1b00bbe --- /dev/null +++ b/source/funkin/data/story/level/LevelData.hx @@ -0,0 +1,117 @@ +package funkin.data.story.level; + +import funkin.data.animation.AnimationData; + +/** + * A type definition for the data in a story mode level JSON file. + * @see https://lib.haxe.org/p/json2object/ + */ +typedef LevelData = +{ + /** + * The version number of the level data schema. + * When making changes to the level data format, this should be incremented, + * and a migration function should be added to LevelDataParser to handle old versions. + */ + @:default(funkin.data.story.level.LevelRegistry.LEVEL_DATA_VERSION) + var version:String; + + /** + * The title of the level, as seen in the top corner. + */ + var name:String; + + /** + * The graphic for the level, as seen in the scrolling list. + */ + @:jcustomparse(funkin.data.DataParse.stringNotEmpty) + var titleAsset:String; + + /** + * The props to display over the colored background. + * In the base game this is usually Boyfriend and the opponent. + */ + @:default([]) + var props:Array; + + /** + * Whether this week is visible in the story menu. + * @default `true` + */ + @:default(true) + @:optional + var visible:Bool; + + /** + * The list of song IDs included in this level. + */ + @:default(['bopeebo']) + var songs:Array; + + /** + * The background for the level behind the props. + */ + @:default('#F9CF51') + @:optional + var background:String; +} + +/** + * Data for a single prop for a story mode level. + */ +typedef LevelPropData = +{ + /** + * The image to use for the prop. May optionally be a sprite sheet. + */ + var assetPath:String; + + /** + * The scale to render the prop at. + * @default 1.0 + */ + @:default(1.0) + @:optional + var scale:Float; + + /** + * The opacity to render the prop at. + * @default 1.0 + */ + @:default(1.0) + @:optional + var alpha:Float; + + /** + * If true, the prop is a pixel sprite, and will be rendered without smoothing. + */ + @:default(false) + @:optional + var isPixel:Bool; + + /** + * The frequency to bop at, in beats. + * 1 = every beat, 2 = every other beat, etc. + * Supports up to 0.25 precision. + * @default 1.0 + */ + @:default(1.0) + @:optional + var danceEvery:Float; + + /** + * The offset on the position to render the prop at. + * @default [0.0, 0.0] + */ + @:default([0, 0]) + @:optional + var offsets:Array; + + /** + * A set of animations to play on the prop. + * If default/empty, the prop will be static. + */ + @:default([]) + @:optional + var animations:Array; +} diff --git a/source/funkin/data/story/level/LevelRegistry.hx b/source/funkin/data/story/level/LevelRegistry.hx new file mode 100644 index 00000000..4e4f6601 --- /dev/null +++ b/source/funkin/data/story/level/LevelRegistry.hx @@ -0,0 +1,57 @@ +package funkin.data.story.level; + +import funkin.util.SortUtil; +import funkin.ui.story.Level; +import funkin.ui.story.ScriptedLevel; +import funkin.util.tools.ISingleton; +import funkin.data.DefaultRegistryImpl; + +class LevelRegistry extends BaseRegistry implements ISingleton implements DefaultRegistryImpl +{ + /** + * The current version string for the level data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateLevelData()` function. + */ + public static final LEVEL_DATA_VERSION:thx.semver.Version = "1.0.1"; + + public static final LEVEL_DATA_VERSION_RULE:thx.semver.VersionRule = ">=1.0.0 <1.1.0"; + + public function new() + { + super('LEVEL', 'levels', LEVEL_DATA_VERSION_RULE); + } + + /** + * A list of all the story weeks from the base game, in order. + * @return Array + */ + public function listBaseGameEntryIds():Array + { + // This MUST be hard-coded (overriding the auto-generated method) + // because the auto-generated method spits out values in alphabetical order. + return [ + 'tutorial', + 'week1', + 'week2', + 'week3', + 'week4', + 'week5', + 'week6', + 'week7', + 'weekend1' + ]; + } + + /** + * A list of all the story weeks in the game, in order. + * Modded levels are in alphabetical order at the end of the list. + * @return Array + */ + public function listSortedLevelIds():Array + { + var result:Array = listEntryIds(); + result.sort(SortUtil.defaultsThenAlphabetically.bind(listBaseGameEntryIds())); + return result; + } +} diff --git a/source/funkin/effects/FunkTrail.hx b/source/funkin/effects/FunkTrail.hx new file mode 100644 index 00000000..42fe4b22 --- /dev/null +++ b/source/funkin/effects/FunkTrail.hx @@ -0,0 +1,56 @@ +package funkin.effects; + +import flixel.addons.effects.FlxTrail; +import funkin.play.stage.Bopper; +import flixel.FlxSprite; +import flixel.system.FlxAssets.FlxGraphicAsset; +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 +{ + /** + * Creates a new FunkTrail effect for a specific FlxSprite. + * + * @param target The FlxSprite the trail is attached to. + * @param graphic The image to use for the trailsprites. Optional, uses the sprite's graphic if null. + * @param length The amount of trailsprites to create. + * @param delay How often to update the trail. 0 updates every frame. + * @param alpha The alpha value for the very first trailsprite. + * @param diff How much lower the alpha of the next trailsprite is. + */ + public function new(target:FlxSprite, ?graphic:FlxGraphicAsset, length:Int = 10, delay:Int = 3, alpha:Float = 0.4, diff:Float = 0.05) + { + super(target, graphic, length, delay, alpha, diff); + } + + /** + * An offset applied to the target position whenever a new frame is saved. + */ + public final frameOffset:FlxPoint = FlxPoint.get(); + + override function destroy():Void + { + super.destroy(); + + frameOffset.put(); + } + + override function addTrailFrame():Void + { + super.addTrailFrame(); + + if (target is Bopper) + { + var targ:Bopper = cast target; + @:privateAccess + frameOffset.set((targ.animOffsets[0] - targ.globalOffsets[0]) * targ.scale.x, (targ.animOffsets[1] - targ.globalOffsets[1]) * targ.scale.y); + + _recentPositions[0]?.subtract(frameOffset.x, frameOffset.y); + } + } +} diff --git a/source/funkin/effects/IntervalShake.hx b/source/funkin/effects/IntervalShake.hx new file mode 100644 index 00000000..545739cc --- /dev/null +++ b/source/funkin/effects/IntervalShake.hx @@ -0,0 +1,240 @@ +package funkin.effects; + +import flixel.FlxObject; +import flixel.util.FlxDestroyUtil.IFlxDestroyable; +import flixel.util.FlxPool; +import flixel.util.FlxTimer; +import flixel.math.FlxPoint; +import flixel.util.FlxAxes; +import flixel.tweens.FlxEase.EaseFunction; +import flixel.math.FlxMath; + +/** + * pretty much a copy of FlxFlicker geared towards making sprites + * shake around at a set interval and slow down over time. + */ +class IntervalShake implements IFlxDestroyable +{ + static var _pool:FlxPool = new FlxPool(IntervalShake.new); + + /** + * Internal map for looking up which objects are currently shaking and getting their shake data. + */ + static var _boundObjects:Map = new Map(); + + /** + * An effect that shakes the sprite on a set interval and a starting intensity that goes down over time. + * + * @param Object The object to shake. + * @param Duration How long to shake for (in seconds). `0` means "forever". + * @param Interval In what interval to update the shake position. Set to `FlxG.elapsed` if `<= 0`! + * @param StartIntensity The starting intensity of the shake. + * @param EndIntensity The ending intensity of the shake. + * @param Ease Control the easing of the intensity over the shake. + * @param CompletionCallback Callback on shake completion + * @param ProgressCallback Callback on each shake interval + * @return The `IntervalShake` object. `IntervalShake`s are pooled internally, so beware of storing references. + */ + public static function shake(Object:FlxObject, Duration:Float = 1, Interval:Float = 0.04, StartIntensity:Float = 0, EndIntensity:Float = 0, + Ease:EaseFunction, ?CompletionCallback:IntervalShake->Void, ?ProgressCallback:IntervalShake->Void):IntervalShake + { + if (isShaking(Object)) + { + // if (ForceRestart) + // { + // stopShaking(Object); + // } + // else + // { + // Ignore this call if object is already flickering. + return _boundObjects[Object]; + // } + } + + if (Interval <= 0) + { + Interval = FlxG.elapsed; + } + + var shake:IntervalShake = _pool.get(); + shake.start(Object, Duration, Interval, StartIntensity, EndIntensity, Ease, CompletionCallback, ProgressCallback); + return _boundObjects[Object] = shake; + } + + /** + * Returns whether the object is shaking or not. + * + * @param Object The object to test. + */ + public static function isShaking(Object:FlxObject):Bool + { + return _boundObjects.exists(Object); + } + + /** + * Stops shaking the object. + * + * @param Object The object to stop shaking. + */ + public static function stopShaking(Object:FlxObject):Void + { + var boundShake:IntervalShake = _boundObjects[Object]; + if (boundShake != null) + { + boundShake.stop(); + } + } + + /** + * The shaking object. + */ + public var object(default, null):FlxObject; + + /** + * The shaking timer. You can check how many seconds has passed since shaking started etc. + */ + public var timer(default, null):FlxTimer; + + /** + * The starting intensity of the shake. + */ + public var startIntensity(default, null):Float; + + /** + * The ending intensity of the shake. + */ + public var endIntensity(default, null):Float; + + /** + * How long to shake for (in seconds). `0` means "forever". + */ + public var duration(default, null):Float; + + /** + * The interval of the shake. + */ + public var interval(default, null):Float; + + /** + * Defines on what axes to `shake()`. Default value is `XY` / both. + */ + public var axes(default, null):FlxAxes; + + /** + * Defines the initial position of the object at the beginning of the shake effect. + */ + public var initialOffset(default, null):FlxPoint; + + /** + * The callback that will be triggered after the shake has completed. + */ + public var completionCallback(default, null):IntervalShake->Void; + + /** + * The callback that will be triggered every time the object shakes. + */ + public var progressCallback(default, null):IntervalShake->Void; + + /** + * The easing of the intensity over the shake. + */ + public var ease(default, null):EaseFunction; + + /** + * Nullifies the references to prepare object for reuse and avoid memory leaks. + */ + public function destroy():Void + { + object = null; + timer = null; + ease = null; + completionCallback = null; + progressCallback = null; + } + + /** + * Starts shaking behavior. + */ + function start(Object:FlxObject, Duration:Float = 1, Interval:Float = 0.04, StartIntensity:Float = 0, EndIntensity:Float = 0, Ease:EaseFunction, + ?CompletionCallback:IntervalShake->Void, ?ProgressCallback:IntervalShake->Void):Void + { + object = Object; + duration = Duration; + interval = Interval; + completionCallback = CompletionCallback; + startIntensity = StartIntensity; + endIntensity = EndIntensity; + initialOffset = new FlxPoint(Object.x, Object.y); + ease = Ease; + axes = FlxAxes.XY; + _secondsSinceStart = 0; + timer = new FlxTimer().start(interval, shakeProgress, Std.int(duration / interval)); + } + + /** + * Prematurely ends shaking. + */ + public function stop():Void + { + timer.cancel(); + // object.visible = true; + object.x = initialOffset.x; + object.y = initialOffset.y; + release(); + } + + /** + * Unbinds the object from shaking and releases it into pool for reuse. + */ + function release():Void + { + _boundObjects.remove(object); + _pool.put(this); + } + + public var _secondsSinceStart(default, null):Float = 0; + + public var scale(default, null):Float = 0; + + /** + * Just a helper function for shake() to update object's position. + */ + function shakeProgress(timer:FlxTimer):Void + { + _secondsSinceStart += interval; + scale = _secondsSinceStart / duration; + if (ease != null) + { + scale = 1 - ease(scale); + // trace(scale); + } + + var curIntensity:Float = 0; + curIntensity = FlxMath.lerp(endIntensity, startIntensity, scale); + + if (axes.x) object.x = initialOffset.x + FlxG.random.float((-curIntensity) * object.width, (curIntensity) * object.width); + if (axes.y) object.y = initialOffset.y + FlxG.random.float((-curIntensity) * object.width, (curIntensity) * object.width); + + // object.visible = !object.visible; + + if (progressCallback != null) progressCallback(this); + + if (timer.loops > 0 && timer.loopsLeft == 0) + { + object.x = initialOffset.x; + object.y = initialOffset.y; + if (completionCallback != null) + { + completionCallback(this); + } + + if (this.timer == timer) release(); + } + } + + /** + * Internal constructor. Use static methods. + */ + @:keep + function new() {} +} diff --git a/source/funkin/effects/RetroCameraFade.hx b/source/funkin/effects/RetroCameraFade.hx new file mode 100644 index 00000000..ef65eef4 --- /dev/null +++ b/source/funkin/effects/RetroCameraFade.hx @@ -0,0 +1,107 @@ +package funkin.effects; + +import flixel.util.FlxTimer; +import flixel.FlxCamera; +import openfl.filters.ColorMatrixFilter; + +@:nullSafety +class RetroCameraFade +{ + // im lazy, but we only use this for week 6 + // and also sorta yoinked for djflixel, lol ! + public static function fadeWhite(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void + { + var steps:Int = 0; + var stepsTotal:Int = camSteps; + + new FlxTimer().start(time / stepsTotal, _ -> { + var V:Float = (1 / stepsTotal) * steps; + if (steps == stepsTotal) V = 1; + + var matrix = [ + 1, 0, 0, 0, V * 255, + 0, 1, 0, 0, V * 255, + 0, 0, 1, 0, V * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrix)]; + steps++; + }, stepsTotal + 1); + } + + public static function fadeFromWhite(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void + { + var steps:Int = camSteps; + var stepsTotal:Int = camSteps; + + var matrixDerp = [ + 1, 0, 0, 0, 1.0 * 255, + 0, 1, 0, 0, 1.0 * 255, + 0, 0, 1, 0, 1.0 * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrixDerp)]; + + new FlxTimer().start(time / stepsTotal, _ -> { + var V:Float = (1 / stepsTotal) * steps; + if (steps == stepsTotal) V = 1; + + var matrix = [ + 1, 0, 0, 0, V * 255, + 0, 1, 0, 0, V * 255, + 0, 0, 1, 0, V * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrix)]; + steps--; + }, camSteps); + } + + public static function fadeToBlack(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void + { + var steps:Int = 0; + var stepsTotal:Int = camSteps; + + new FlxTimer().start(time / stepsTotal, _ -> { + var V:Float = (1 / stepsTotal) * steps; + if (steps == stepsTotal) V = 1; + + var matrix = [ + 1, 0, 0, 0, -V * 255, + 0, 1, 0, 0, -V * 255, + 0, 0, 1, 0, -V * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrix)]; + steps++; + }, camSteps); + } + + public static function fadeBlack(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void + { + var steps:Int = camSteps; + var stepsTotal:Int = camSteps; + + var matrixDerp = [ + 1, 0, 0, 0, -1.0 * 255, + 0, 1, 0, 0, -1.0 * 255, + 0, 0, 1, 0, -1.0 * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrixDerp)]; + + new FlxTimer().start(time / stepsTotal, _ -> { + var V:Float = (1 / stepsTotal) * steps; + if (steps == stepsTotal) V = 1; + + var matrix = [ + 1, 0, 0, 0, -V * 255, + 0, 1, 0, 0, -V * 255, + 0, 0, 1, 0, -V * 255, + 0, 0, 0, 1, 0 + ]; + camera.filters = [new ColorMatrixFilter(matrix)]; + steps--; + }, camSteps + 1); + } +} diff --git a/source/funkin/graphics/FlxFilteredSprite.hx b/source/funkin/graphics/FlxFilteredSprite.hx new file mode 100644 index 00000000..1323f32c --- /dev/null +++ b/source/funkin/graphics/FlxFilteredSprite.hx @@ -0,0 +1,413 @@ +package funkin.graphics; + +import flixel.FlxBasic; +import flixel.FlxCamera; +import flixel.FlxG; +import flixel.FlxSprite; +import flixel.graphics.FlxGraphic; +import flixel.graphics.frames.FlxFrame; +import flixel.math.FlxMatrix; +import flixel.math.FlxRect; +import lime.graphics.cairo.Cairo; +import openfl.display.BitmapData; +import openfl.display.BlendMode; +import openfl.display.DisplayObjectRenderer; +import openfl.display.Graphics; +import openfl.display.OpenGLRenderer; +import openfl.display3D.Context3D; +import openfl.filters.BitmapFilter; +import openfl.geom.ColorTransform; +import openfl.geom.Matrix; +import openfl.geom.Rectangle; +#if (js && html5) +import lime._internal.graphics.ImageCanvasUtil; +import openfl.display.CanvasRenderer; +import openfl.display._internal.CanvasGraphics as GfxRenderer; +#else +import openfl.display.CairoRenderer; +import openfl.display._internal.CairoGraphics as GfxRenderer; +#end + +/** + * A modified `FlxSprite` that supports filters. + * The name's pretty much self-explanatory. + */ +@:access(openfl.geom.Rectangle) +@:access(openfl.filters.BitmapFilter) +@:access(flixel.graphics.frames.FlxFrame) +class FlxFilteredSprite extends FlxSprite +{ + @:noCompletion var _renderer:FlxAnimateFilterRenderer = new FlxAnimateFilterRenderer(); + + @:noCompletion var _filterMatrix:FlxMatrix; + + /** + * An `Array` of shader filters (aka `BitmapFilter`). + */ + public var filters(default, set):Array; + + /** + * a flag to update the image with the filters. + * Useful when trying to render a shader at all times. + */ + public var filterDirty:Bool = false; + + @:noCompletion var filtered:Bool; + + @:noCompletion var _blankFrame:FlxFrame; + + var _filterBmp1:BitmapData; + var _filterBmp2:BitmapData; + + override public function update(elapsed:Float) + { + super.update(elapsed); + if (!filterDirty && filters != null) + { + for (filter in filters) + { + if (filter.__renderDirty) + { + filterDirty = true; + break; + } + } + } + } + + @:noCompletion + override function initVars():Void + { + super.initVars(); + _filterMatrix = new FlxMatrix(); + filters = null; + filtered = false; + } + + override public function draw():Void + { + checkEmptyFrame(); + + if (alpha == 0 || _frame.type == FlxFrameType.EMPTY) return; + + if (dirty) // rarely + calcFrame(useFramePixels); + + if (filterDirty) filterFrame(); + + for (camera in cameras) + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) continue; + + getScreenPosition(_point, camera).subtractPoint(offset); + + if (isSimpleRender(camera)) drawSimple(camera); + else + drawComplex(camera); + + #if FLX_DEBUG + FlxBasic.visibleCount++; + #end + } + + #if FLX_DEBUG + if (FlxG.debugger.drawDebug) drawDebug(); + #end + } + + @:noCompletion + override function drawComplex(camera:FlxCamera):Void + { + _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY()); + _matrix.concat(_filterMatrix); + _matrix.translate(-origin.x, -origin.y); + _matrix.scale(scale.x, scale.y); + + if (bakedRotationAngle <= 0) + { + updateTrig(); + + if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle); + } + + _point.add(origin.x, origin.y); + _matrix.translate(_point.x, _point.y); + + if (isPixelPerfectRender(camera)) + { + _matrix.tx = Math.floor(_matrix.tx); + _matrix.ty = Math.floor(_matrix.ty); + } + + camera.drawPixels((filtered) ? _blankFrame : _frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader); + } + + @:noCompletion + function filterFrame() + { + filterDirty = false; + _filterMatrix.identity(); + + if (filters != null && filters.length > 0) + { + _flashRect.setEmpty(); + + for (filter in filters) + { + _flashRect.__expand(-filter.__leftExtension, + -filter.__topExtension, filter.__leftExtension + + filter.__rightExtension, + filter.__topExtension + + filter.__bottomExtension); + } + _flashRect.width += frameWidth; + _flashRect.height += frameHeight; + if (_blankFrame == null) _blankFrame = new FlxFrame(null); + + if (_blankFrame.parent == null || _flashRect.width > _blankFrame.parent.width || _flashRect.height > _blankFrame.parent.height) + { + if (_blankFrame.parent != null) + { + _blankFrame.parent.destroy(); + _filterBmp1.dispose(); + _filterBmp2.dispose(); + } + + _blankFrame.parent = FlxGraphic.fromRectangle(Math.ceil(_flashRect.width * 1.25), Math.ceil(_flashRect.height * 1.25), 0, true); + _filterBmp1 = new BitmapData(_blankFrame.parent.width, _blankFrame.parent.height, 0); + _filterBmp2 = new BitmapData(_blankFrame.parent.width, _blankFrame.parent.height, 0); + } + _blankFrame.offset.copyFrom(_frame.offset); + _blankFrame.parent.bitmap = _renderer.applyFilter(_blankFrame.parent.bitmap, _filterBmp1, _filterBmp2, frame.parent.bitmap, filters, _flashRect, + frame.frame.copyToFlash()); + _blankFrame.frame = FlxRect.get(0, 0, _blankFrame.parent.bitmap.width, _blankFrame.parent.bitmap.height); + _filterMatrix.translate(_flashRect.x, _flashRect.y); + _frame = _blankFrame.copyTo(); + filtered = true; + } + else + { + resetFrame(); + filtered = false; + } + } + + @:noCompletion + function set_filters(value:Array) + { + if (filters != value) filterDirty = true; + + return filters = value; + } + + @:noCompletion + override function set_frame(value:FlxFrame) + { + if (value != frame) filterDirty = true; + + return super.set_frame(value); + } + + override public function destroy() + { + super.destroy(); + } +} + +@:noCompletion +@:access(openfl.display.OpenGLRenderer) +@:access(openfl.filters.BitmapFilter) +@:access(openfl.geom.Rectangle) +@:access(openfl.display.Stage) +@:access(openfl.display.Graphics) +@:access(openfl.display.Shader) +@:access(openfl.display.BitmapData) +@:access(openfl.geom.ColorTransform) +@:access(openfl.display.DisplayObject) +@:access(openfl.display3D.Context3D) +@:access(openfl.display.CanvasRenderer) +@:access(openfl.display.CairoRenderer) +@:access(openfl.display3D.Context3D) +class FlxAnimateFilterRenderer +{ + var renderer:OpenGLRenderer; + var context:Context3D; + + public function new() + { + // context = new openfl.display3D.Context3D(null); + renderer = new OpenGLRenderer(FlxG.game.stage.context3D); + renderer.__worldTransform = new Matrix(); + renderer.__worldColorTransform = new ColorTransform(); + } + + @:noCompletion function setRenderer(renderer:DisplayObjectRenderer, rect:Rectangle) + { + @:privateAccess + if (true) + { + var displayObject = FlxG.game; + var pixelRatio = FlxG.game.stage.__renderer.__pixelRatio; + + var offsetX = rect.x > 0 ? Math.ceil(rect.x) : Math.floor(rect.x); + var offsetY = rect.y > 0 ? Math.ceil(rect.y) : Math.floor(rect.y); + if (renderer.__worldTransform == null) + { + renderer.__worldTransform = new Matrix(); + renderer.__worldColorTransform = new ColorTransform(); + } + if (displayObject.__cacheBitmapColorTransform == null) displayObject.__cacheBitmapColorTransform = new ColorTransform(); + + renderer.__stage = displayObject.stage; + + renderer.__allowSmoothing = true; + renderer.__setBlendMode(NORMAL); + renderer.__worldAlpha = 1 / displayObject.__worldAlpha; + + renderer.__worldTransform.identity(); + renderer.__worldTransform.invert(); + renderer.__worldTransform.concat(new Matrix()); + renderer.__worldTransform.tx -= offsetX; + renderer.__worldTransform.ty -= offsetY; + renderer.__worldTransform.scale(pixelRatio, pixelRatio); + + renderer.__pixelRatio = pixelRatio; + } + } + + public function applyFilter(target:BitmapData = null, target1:BitmapData = null, target2:BitmapData = null, bmp:BitmapData, filters:Array, + rect:Rectangle, bmpRect:Rectangle) + { + if (filters == null || filters.length == 0) return bmp; + + renderer.__setBlendMode(NORMAL); + renderer.__worldAlpha = 1; + + if (renderer.__worldTransform == null) + { + renderer.__worldTransform = new Matrix(); + renderer.__worldColorTransform = new ColorTransform(); + } + renderer.__worldTransform.identity(); + renderer.__worldColorTransform.__identity(); + + var bitmap:BitmapData = (target == null) ? new BitmapData(Math.ceil(rect.width * 1.25), Math.ceil(rect.height * 1.25), true, 0) : target; + + var bitmap2 = (target1 == null) ? new BitmapData(Math.ceil(rect.width * 1.25), Math.ceil(rect.height * 1.25), true, 0) : target1, + bitmap3 = (target2 == null) ? bitmap2.clone() : target2; + renderer.__setRenderTarget(bitmap); + + bmp.__renderTransform.translate(Math.abs(rect.x) - bmpRect.x, Math.abs(rect.y) - bmpRect.y); + bmpRect.x = Math.abs(rect.x); + bmpRect.y = Math.abs(rect.y); + + var bestResolution = renderer.__context3D.__backBufferWantsBestResolution; + renderer.__context3D.__backBufferWantsBestResolution = false; + renderer.__scissorRect(bmpRect); + renderer.__renderFilterPass(bmp, renderer.__defaultDisplayShader, true); + renderer.__scissorRect(); + + renderer.__context3D.__backBufferWantsBestResolution = bestResolution; + + bmp.__renderTransform.identity(); + + var shader, cacheBitmap = null; + for (filter in filters) + { + if (filter.__preserveObject) + { + renderer.__setRenderTarget(bitmap3); + renderer.__renderFilterPass(bitmap, renderer.__defaultDisplayShader, filter.__smooth); + } + + for (i in 0...filter.__numShaderPasses) + { + shader = filter.__initShader(renderer, i, (filter.__preserveObject) ? bitmap3 : null); + renderer.__setBlendMode(filter.__shaderBlendMode); + renderer.__setRenderTarget(bitmap2); + renderer.__renderFilterPass(bitmap, shader, filter.__smooth); + + cacheBitmap = bitmap; + bitmap = bitmap2; + bitmap2 = cacheBitmap; + } + filter.__renderDirty = false; + } + if (target1 == null) bitmap2.dispose(); + if (target2 == null) bitmap3.dispose(); + + // var gl = renderer.__gl; + + // var renderBuffer = bitmap.getTexture(renderer.__context3D); + // @:privateAccess + // gl.readPixels(0, 0, bitmap.width, bitmap.height, renderBuffer.__format, gl.UNSIGNED_BYTE, bitmap.image.data); + // bitmap.image.version = 0; + // @:privateAccess + // bitmap.__textureVersion = -1; + + return bitmap; + } + + public function applyBlend(blend:BlendMode, bitmap:BitmapData) + { + bitmap.__update(false, true); + var bmp = new BitmapData(bitmap.width, bitmap.height, 0); + + #if (js && html5) + ImageCanvasUtil.convertToCanvas(bmp.image); + @:privateAccess + var renderer = new CanvasRenderer(bmp.image.buffer.__srcContext); + #else + var renderer = new CairoRenderer(new Cairo(bmp.getSurface())); + #end + + // setRenderer(renderer, bmp.rect); + + var m = new Matrix(); + var c = new ColorTransform(); + renderer.__allowSmoothing = true; + renderer.__overrideBlendMode = blend; + renderer.__worldTransform = m; + renderer.__worldAlpha = 1; + renderer.__worldColorTransform = c; + + renderer.__setBlendMode(blend); + #if (js && html5) + bmp.__drawCanvas(bitmap, renderer); + #else + bmp.__drawCairo(bitmap, renderer); + #end + + return bitmap; + } + + public function graphicstoBitmapData(gfx:Graphics) + { + if (gfx.__bounds == null) return null; + // var cacheRTT = renderer.__context3D.__state.renderToTexture; + // var cacheRTTDepthStencil = renderer.__context3D.__state.renderToTextureDepthStencil; + // var cacheRTTAntiAlias = renderer.__context3D.__state.renderToTextureAntiAlias; + // var cacheRTTSurfaceSelector = renderer.__context3D.__state.renderToTextureSurfaceSelector; + + // var bmp = new BitmapData(Math.ceil(gfx.__width), Math.ceil(gfx.__height), 0); + // renderer.__context3D.setRenderToTexture(bmp.getTexture(renderer.__context3D)); + // gfx.__owner.__renderTransform.identity(); + // gfx.__renderTransform.identity(); + // Context3DGraphics.render(gfx, renderer); + GfxRenderer.render(gfx, cast renderer.__softwareRenderer); + var bmp = gfx.__bitmap; + + gfx.__bitmap = null; + + // if (cacheRTT != null) + // { + // renderer.__context3D.setRenderToTexture(cacheRTT, cacheRTTDepthStencil, cacheRTTAntiAlias, cacheRTTSurfaceSelector); + // } + // else + // { + // renderer.__context3D.setRenderToBackBuffer(); + // } + + return bmp; + } +} diff --git a/source/funkin/graphics/FunkinCamera.hx b/source/funkin/graphics/FunkinCamera.hx new file mode 100644 index 00000000..8c8a1c9a --- /dev/null +++ b/source/funkin/graphics/FunkinCamera.hx @@ -0,0 +1,261 @@ +package funkin.graphics; + +import flash.geom.ColorTransform; +import flixel.FlxCamera; +import flixel.graphics.FlxGraphic; +import flixel.graphics.frames.FlxFrame; +import flixel.math.FlxMatrix; +import flixel.math.FlxRect; +import flixel.system.FlxAssets.FlxShader; +import funkin.graphics.shaders.RuntimeCustomBlendShader; +import funkin.graphics.framebuffer.BitmapDataUtil; +import funkin.graphics.framebuffer.FixedBitmapData; +import openfl.Lib; +import openfl.display.BitmapData; +import openfl.display.BlendMode; +import openfl.display3D.textures.TextureBase; +import openfl.filters.BitmapFilter; +import openfl.filters.ShaderFilter; + +/** + * A FlxCamera with additional powerful features: + * - Grab the camera screen as a `BitmapData` and use it as a texture + * - Support `sprite.blend = DARKEN/HARDLIGHT/LIGHTEN/OVERLAY` to apply visual effects using certain sprites + * - NOTE: Several other blend modes work without FunkinCamera. Some still do not work. + * - NOTE: Framerate-independent camera tweening is fixed in Flixel 6.x. Rest in peace, SwagCamera. + */ +@:access(openfl.display.DisplayObject) +@:access(openfl.display.BitmapData) +@:access(openfl.display3D.Context3D) +@:access(openfl.display3D.textures.TextureBase) +@:access(flixel.graphics.FlxGraphic) +@:access(flixel.graphics.frames.FlxFrame) +class FunkinCamera extends FlxCamera +{ + final grabbed:Array = []; + final texturePool:Array = []; + + final bgTexture:TextureBase; + final bgBitmap:BitmapData; + final bgFrame:FlxFrame; + + final customBlendShader:RuntimeCustomBlendShader; + final customBlendFilter:ShaderFilter; + + var filtersApplied:Bool = false; + var bgItemCount:Int = 0; + + public var shouldDraw:Bool = true; + + // Used to identify the camera during debugging. + final id:String = 'unknown'; + + public function new(id:String = 'unknown', x:Int = 0, y:Int = 0, width:Int = 0, height:Int = 0, zoom:Float = 0) + { + super(x, y, width, height, zoom); + this.id = id; + bgTexture = pickTexture(width, height); + bgBitmap = FixedBitmapData.fromTexture(bgTexture); + bgFrame = new FlxFrame(new FlxGraphic('', null)); + bgFrame.parent.bitmap = bgBitmap; + bgFrame.frame = new FlxRect(); + customBlendShader = new RuntimeCustomBlendShader(); + customBlendFilter = new ShaderFilter(customBlendShader); + } + + /** + * Grabs the camera screen and returns it as a `BitmapData`. The returned bitmap + * will not be referred by the camera so, changing it will not affect the scene. + * The returned bitmap **will be reused in the next frame**, so the content is available + * only in the current frame. + * @param applyFilters if this is `true`, the camera's filters will be applied to the grabbed bitmap, + * and the camera's filters will be disabled until the beginning of the next frame + * @param isolate if this is `true`, sprites to be rendered will only be rendered to the grabbed bitmap, + * and the grabbed bitmap will not include any previously rendered sprites + * @return the grabbed bitmap data + */ + public function grabScreen(applyFilters:Bool, isolate:Bool = false):BitmapData + { + final texture = pickTexture(width, height); + final bitmap = FixedBitmapData.fromTexture(texture); + squashTo(bitmap, applyFilters, isolate); + grabbed.push(bitmap); + return bitmap; + } + + /** + * Applies the filter immediately to the camera. This will be done independently from + * the camera's filters. This method can only be called after the first `grabScreen` + * in the frame. + * @param filter the filter + */ + public function applyFilter(filter:BitmapFilter):Void + { + if (grabbed.length == 0) + { + FlxG.log.error('grab screen before you can apply a filter!'); + return; + } + BitmapDataUtil.applyFilter(bgBitmap, filter); + } + + function squashTo(bitmap:BitmapData, applyFilters:Bool, isolate:Bool, clearScreen:Bool = false):Void + { + if (applyFilters && isolate) + { + FlxG.log.error('cannot apply filters while isolating!'); + } + if (filtersApplied && applyFilters) + { + FlxG.log.warn('filters already applied!'); + } + static final matrix = new FlxMatrix(); + + // resize the background bitmap if needed + if (bgTexture.__width != width || bgTexture.__height != height) + { + BitmapDataUtil.resizeTexture(bgTexture, width, height); + bgBitmap.__resize(width, height); + bgFrame.parent.bitmap = bgBitmap; + } + + // grab the bitmap + renderSkipping(isolate ? bgItemCount : 0); + bitmap.fillRect(bitmap.rect, 0); + matrix.setTo(1, 0, 0, 1, flashSprite.x, flashSprite.y); + if (applyFilters) + { + bitmap.draw(flashSprite, matrix); + flashSprite.filters = null; + filtersApplied = true; + } + else + { + final tmp = flashSprite.filters; + flashSprite.filters = null; + bitmap.draw(flashSprite, matrix); + flashSprite.filters = tmp; + } + + if (!isolate) + { + // also copy to the background bitmap + bgBitmap.fillRect(bgBitmap.rect, 0); + bgBitmap.draw(bitmap); + } + + if (clearScreen) + { + // clear graphics data + super.clearDrawStack(); + canvas.graphics.clear(); + } + + // render the background bitmap + bgFrame.frame.set(0, 0, width, height); + matrix.setTo(viewWidth / width, 0, 0, viewHeight / height, viewMarginLeft, viewMarginTop); + drawPixels(bgFrame, matrix); + + // count background draw items for future isolation + bgItemCount = 0; + { + var item = _headOfDrawStack; + while (item != null) + { + item = item.next; + bgItemCount++; + } + } + } + + function renderSkipping(count:Int):Void + { + var item = _headOfDrawStack; + while (item != null) + { + if (--count < 0) item.render(this); + item = item.next; + } + } + + override function drawPixels(?frame:FlxFrame, ?pixels:BitmapData, matrix:FlxMatrix, ?transform:ColorTransform, ?blend:BlendMode, ?smoothing:Bool = false, + ?shader:FlxShader):Void + { + if (!shouldDraw) return; + + if ( switch blend + { + case DARKEN | HARDLIGHT | LIGHTEN | OVERLAY: true; + case _: false; + }) + { + // squash the screen + grabScreen(false); + // render without blend + super.drawPixels(frame, pixels, matrix, transform, null, smoothing, shader); + // get the isolated bitmap + final isolated = grabScreen(false, true); + // apply fullscreen blend + customBlendShader.blendSwag = blend; + customBlendShader.sourceSwag = isolated; + customBlendShader.updateViewInfo(FlxG.width, FlxG.height, this); + applyFilter(customBlendFilter); + } + else + { + super.drawPixels(frame, pixels, matrix, transform, blend, smoothing, shader); + } + } + + override function destroy():Void + { + super.destroy(); + disposeTextures(); + } + + override function clearDrawStack():Void + { + super.clearDrawStack(); + // also clear grabbed bitmaps + for (bitmap in grabbed) + { + texturePool.push(bitmap.__texture); + bitmap.dispose(); // this doesn't release the texture + } + grabbed.clear(); + // clear filters applied flag + filtersApplied = false; + bgItemCount = 0; + } + + function pickTexture(width:Int, height:Int):TextureBase + { + // zero-sized textures will be problematic + width = width < 1 ? 1 : width; + height = height < 1 ? 1 : height; + if (texturePool.length > 0) + { + final res = texturePool.pop(); + BitmapDataUtil.resizeTexture(res, width, height); + return res; + } + return Lib.current.stage.context3D.createTexture(width, height, BGRA, true); + } + + function disposeTextures():Void + { + trace('disposing textures'); + for (bitmap in grabbed) + { + bitmap.dispose(); + } + grabbed.clear(); + for (texture in texturePool) + { + texture.dispose(); + } + texturePool.resize(0); + bgTexture.dispose(); + bgBitmap.dispose(); + } +} diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx new file mode 100644 index 00000000..3425bbd5 --- /dev/null +++ b/source/funkin/graphics/FunkinSprite.hx @@ -0,0 +1,437 @@ +package funkin.graphics; + +import flixel.FlxSprite; +import flixel.util.FlxColor; +import flixel.graphics.FlxGraphic; +import flixel.tweens.FlxTween; +import openfl.display3D.textures.TextureBase; +import funkin.graphics.framebuffer.FixedBitmapData; +import openfl.display.BitmapData; +import flixel.math.FlxRect; +import flixel.math.FlxPoint; +import flixel.graphics.frames.FlxFrame.FlxFrameAngle; +import flixel.FlxCamera; + +/** + * An FlxSprite with additional functionality. + * - A more efficient method for creating solid color sprites. + * - TODO: Better cache handling for textures. + */ +class FunkinSprite extends FlxSprite +{ + /** + * An internal list of all the textures cached with `cacheTexture`. + * This excludes any temporary textures like those from `FlxText` or `makeSolidColor`. + */ + static var currentCachedTextures:Map = []; + + /** + * An internal list of textures that were cached in the previous state. + * We don't know whether we want to keep them cached or not. + */ + static var previousCachedTextures:Map = []; + + /** + * @param x Starting X position + * @param y Starting Y position + */ + public function new(?x:Float = 0, ?y:Float = 0) + { + super(x, y); + } + + /** + * Create a new FunkinSprite with a static texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function create(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadTexture(key); + return sprite; + } + + /** + * Create a new FunkinSprite with a Sparrow atlas animated texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function createSparrow(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadSparrow(key); + return sprite; + } + + /** + * Create a new FunkinSprite with a Packer atlas animated texture. + * @param x The starting X position. + * @param y The starting Y position. + * @param key The key of the texture to load. + * @return The new FunkinSprite. + */ + public static function createPacker(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite + { + var sprite:FunkinSprite = new FunkinSprite(x, y); + sprite.loadPacker(key); + return sprite; + } + + /** + * Load a static image as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadTexture(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + loadGraphic(graphicKey); + + return this; + } + + public function loadTextureAsync(key:String, fade:Bool = false):Void + { + var fadeTween:Null = null; + if (fade) + { + fadeTween = FlxTween.tween(this, {alpha: 0}, 0.25); + } + + trace('[ASYNC] Start loading image (${key})'); + graphic.persist = true; + openfl.Assets.loadBitmapData(key) + .onComplete(function(bitmapData:openfl.display.BitmapData) { + trace('[ASYNC] Finished loading image'); + var cache:Bool = false; + loadBitmapData(bitmapData, cache); + + if (fadeTween != null) + { + fadeTween.cancel(); + FlxTween.tween(this, {alpha: 1.0}, 0.25); + } + }) + .onError(function(error:Dynamic) { + trace('[ASYNC] Failed to load image: ${error}'); + if (fadeTween != null) + { + fadeTween.cancel(); + this.alpha = 1.0; + } + }) + .onProgress(function(progress:Int, total:Int) { + trace('[ASYNC] Loading image progress: ${progress}/${total}'); + }); + } + + /** + * Apply an OpenFL `BitmapData` to this sprite. + * @param input The OpenFL `BitmapData` to apply + * @return This sprite, for chaining + */ + public function loadBitmapData(input:BitmapData, cache:Bool = true):FunkinSprite + { + if (cache) + { + loadGraphic(input); + } + else + { + var graphic:FlxGraphic = FlxGraphic.fromBitmapData(input, false, null, false); + this.graphic = graphic; + this.frames = this.graphic.imageFrame; + } + + return this; + } + + /** + * Apply an OpenFL `TextureBase` to this sprite. + * @param input The OpenFL `TextureBase` to apply + * @return This sprite, for chaining + */ + public function loadTextureBase(input:TextureBase):FunkinSprite + { + var inputBitmap:FixedBitmapData = FixedBitmapData.fromTexture(input); + + return loadBitmapData(inputBitmap); + } + + /** + * Load an animated texture (Sparrow atlas spritesheet) as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadSparrow(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + this.frames = Paths.getSparrowAtlas(key); + + return this; + } + + /** + * Load an animated texture (Packer atlas spritesheet) as the sprite's texture. + * @param key The key of the texture to load. + * @return This sprite, for chaining. + */ + public function loadPacker(key:String):FunkinSprite + { + var graphicKey:String = Paths.image(key); + if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey'); + + this.frames = Paths.getPackerAtlas(key); + + return this; + } + + /** + * Determine whether the texture with the given key is cached. + * @param key The key of the texture to check. + * @return Whether the texture is cached. + */ + public static function isTextureCached(key:String):Bool + { + return FlxG.bitmap.get(key) != null; + } + + /** + * Ensure the texture with the given key is cached. + * @param key The key of the texture to cache. + */ + public static function cacheTexture(key:String):Void + { + // We don't want to cache the same texture twice. + if (currentCachedTextures.exists(key)) return; + + if (previousCachedTextures.exists(key)) + { + // Move the graphic from the previous cache to the current cache. + var graphic = previousCachedTextures.get(key); + previousCachedTextures.remove(key); + currentCachedTextures.set(key, graphic); + return; + } + + // Else, texture is currently uncached. + var graphic:FlxGraphic = FlxGraphic.fromAssetKey(key, false, null, true); + if (graphic == null) + { + FlxG.log.warn('Failed to cache graphic: $key'); + } + else + { + trace('Successfully cached graphic: $key'); + graphic.persist = true; + currentCachedTextures.set(key, graphic); + } + } + + public static function cacheSparrow(key:String):Void + { + cacheTexture(Paths.image(key)); + } + + public static function cachePacker(key:String):Void + { + cacheTexture(Paths.image(key)); + } + + /** + * Call this, then `cacheTexture` to keep the textures we still need, then `purgeCache` to remove the textures that we won't be using anymore. + */ + public static function preparePurgeCache():Void + { + previousCachedTextures = currentCachedTextures; + currentCachedTextures = []; + } + + public static function purgeCache():Void + { + // Everything that is in previousCachedTextures but not in currentCachedTextures should be destroyed. + for (graphicKey in previousCachedTextures.keys()) + { + var graphic = previousCachedTextures.get(graphicKey); + if (graphic == null) continue; + FlxG.bitmap.remove(graphic); + graphic.destroy(); + previousCachedTextures.remove(graphicKey); + } + } + + static function isGraphicCached(graphic:FlxGraphic):Bool + { + if (graphic == null) return false; + var result = FlxG.bitmap.get(graphic.key); + if (result == null) return false; + if (result != graphic) + { + FlxG.log.warn('Cached graphic does not match original: ${graphic.key}'); + return false; + } + return true; + } + + /** + * @param id The animation ID to check. + * @return Whether the animation is dynamic (has multiple frames). `false` for static, one-frame animations. + */ + public function isAnimationDynamic(id:String):Bool + { + if (this.animation == null) return false; + var animData = this.animation.getByName(id); + if (animData == null) return false; + return animData.numFrames > 1; + } + + /** + * Acts similarly to `makeGraphic`, but with improved memory usage, + * at the expense of not being able to paint onto the resulting sprite. + * + * @param width The target width of the sprite. + * @param height The target height of the sprite. + * @param color The color to fill the sprite with. + * @return This sprite, for chaining. + */ + public function makeSolidColor(width:Int, height:Int, color:FlxColor = FlxColor.WHITE):FunkinSprite + { + // Create a tiny solid color graphic and scale it up to the desired size. + var graphic:FlxGraphic = FlxG.bitmap.create(2, 2, color, false, 'solid#${color.toHexString(true, false)}'); + frames = graphic.imageFrame; + scale.set(width / 2.0, height / 2.0); + updateHitbox(); + + return this; + } + + /** + * Ensure scale is applied when cloning a sprite.R + * The default `clone()` method acts kinda weird TBH. + * @return A clone of this sprite. + */ + public override function clone():FunkinSprite + { + var result = new FunkinSprite(this.x, this.y); + result.frames = this.frames; + result.scale.set(this.scale.x, this.scale.y); + result.updateHitbox(); + + return result; + } + + @:access(flixel.FlxCamera) + override function getBoundingBox(camera:FlxCamera):FlxRect + { + getScreenPosition(_point, camera); + + _rect.set(_point.x, _point.y, width, height); + _rect = camera.transformRect(_rect); + + if (isPixelPerfectRender(camera)) + { + _rect.width = _rect.width / this.scale.x; + _rect.height = _rect.height / this.scale.y; + _rect.x = _rect.x / this.scale.x; + _rect.y = _rect.y / this.scale.y; + _rect.floor(); + _rect.x = _rect.x * this.scale.x; + _rect.y = _rect.y * this.scale.y; + _rect.width = _rect.width * this.scale.x; + _rect.height = _rect.height * this.scale.y; + } + + return _rect; + } + + /** + * Returns the screen position of this object. + * + * @param result Optional arg for the returning point + * @param camera The desired "screen" coordinate space. If `null`, `FlxG.camera` is used. + * @return The screen position of this object. + */ + public override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint + { + if (result == null) result = FlxPoint.get(); + + if (camera == null) camera = FlxG.camera; + + result.set(x, y); + if (pixelPerfectPosition) + { + _rect.width = _rect.width / this.scale.x; + _rect.height = _rect.height / this.scale.y; + _rect.x = _rect.x / this.scale.x; + _rect.y = _rect.y / this.scale.y; + _rect.round(); + _rect.x = _rect.x * this.scale.x; + _rect.y = _rect.y * this.scale.y; + _rect.width = _rect.width * this.scale.x; + _rect.height = _rect.height * this.scale.y; + } + + return result.subtract(camera.scroll.x * scrollFactor.x, camera.scroll.y * scrollFactor.y); + } + + override function drawSimple(camera:FlxCamera):Void + { + getScreenPosition(_point, camera).subtractPoint(offset); + if (isPixelPerfectRender(camera)) + { + _point.x = _point.x / this.scale.x; + _point.y = _point.y / this.scale.y; + _point.round(); + + _point.x = _point.x * this.scale.x; + _point.y = _point.y * this.scale.y; + } + + _point.copyToFlash(_flashPoint); + camera.copyPixels(_frame, framePixels, _flashRect, _flashPoint, colorTransform, blend, antialiasing); + } + + override function drawComplex(camera:FlxCamera):Void + { + _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY()); + _matrix.translate(-origin.x, -origin.y); + _matrix.scale(scale.x, scale.y); + + if (bakedRotationAngle <= 0) + { + updateTrig(); + + if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle); + } + + getScreenPosition(_point, camera).subtractPoint(offset); + _point.add(origin.x, origin.y); + _matrix.translate(_point.x, _point.y); + + if (isPixelPerfectRender(camera)) + { + _matrix.tx = Math.round(_matrix.tx / this.scale.x) * this.scale.x; + _matrix.ty = Math.round(_matrix.ty / this.scale.y) * this.scale.y; + } + + camera.drawPixels(_frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader); + } + + public override function destroy():Void + { + frames = null; + // Cancel all tweens so they don't continue to run on a destroyed sprite. + // This prevents crashes. + FlxTween.cancelTweensOf(this); + super.destroy(); + } +} diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx new file mode 100644 index 00000000..2447f5f2 --- /dev/null +++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx @@ -0,0 +1,415 @@ +package funkin.graphics.adobeanimate; + +import flixel.util.FlxSignal.FlxTypedSignal; +import flxanimate.FlxAnimate; +import flxanimate.FlxAnimate.Settings; +import flixel.graphics.frames.FlxFrame; +import flixel.system.FlxAssets.FlxGraphicAsset; +import flixel.math.FlxPoint; +import flxanimate.animate.FlxKeyFrame; + +/** + * A sprite which provides convenience functions for rendering a texture atlas with animations. + */ +class FlxAtlasSprite extends FlxAnimate +{ + static final SETTINGS:Settings = + { + // ?ButtonSettings:Map, + FrameRate: 24.0, + Reversed: false, + // ?OnComplete:Void -> Void, + ShowPivot: false, + Antialiasing: true, + ScrollFactor: null, + // Offset: new FlxPoint(0, 0), // This is just FlxSprite.offset + }; + + /** + * Signal dispatched when an animation advances to the next frame. + */ + public var onAnimationFrame:FlxTypedSignalInt->Void> = new FlxTypedSignal(); + + /** + * Signal dispatched when a non-looping animation finishes playing. + */ + public var onAnimationComplete:FlxTypedSignalVoid> = new FlxTypedSignal(); + + /** + * Signal dispatched when a looping animation finishes playing. + */ + public var onAnimationLoop:FlxTypedSignalVoid> = new FlxTypedSignal(); + + var currentAnimation:String; + + var canPlayOtherAnims:Bool = true; + + public function new(x:Float, y:Float, ?path:String, ?settings:Settings) + { + if (settings == null) settings = SETTINGS; + + if (path == null) + { + throw 'Null path specified for FlxAtlasSprite!'; + } + + // Validate asset path. + if (!Assets.exists('${path}/Animation.json')) + { + throw 'FlxAtlasSprite does not have an Animation.json file at the specified path (${path})'; + } + + super(x, y, path, settings); + + if (this.anim.stageInstance == null) + { + throw 'FlxAtlasSprite not initialized properly. Are you sure the path (${path}) exists?'; + } + + onAnimationComplete.add(cleanupAnimation); + + // This defaults the sprite to play the first animation in the atlas, + // then pauses it. This ensures symbols are intialized properly. + this.anim.play(''); + this.anim.pause(); + + this.anim.onComplete.add(_onAnimationComplete); + this.anim.onFrame.add(_onAnimationFrame); + } + + /** + * @return A list of all the animations this sprite has available. + */ + public function listAnimations():Array + { + var mainSymbol = this.anim.symbolDictionary[this.anim.stageInstance.symbol.name]; + if (mainSymbol == null) + { + FlxG.log.error('FlxAtlasSprite does not have its main symbol!'); + return []; + } + return mainSymbol.getFrameLabels().map(keyFrame -> keyFrame.name).filterNull(); + } + + /** + * @param id A string ID of the animation. + * @return Whether the animation was found on this sprite. + */ + public function hasAnimation(id:String):Bool + { + return getLabelIndex(id) != -1 || anim.symbolDictionary.exists(id); + } + + /** + * @return The current animation being played. + */ + public function getCurrentAnimation():String + { + return this.currentAnimation; + } + + var _completeAnim:Bool = false; + + var fr:FlxKeyFrame = null; + + var looping:Bool = false; + + public var ignoreExclusionPref:Array = []; + + /** + * Plays an animation. + * @param id A string ID of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing + * @param loop Whether to loop the animation + * @param startFrame The frame to start the animation on + * NOTE: `loop` and `ignoreOther` are not compatible with each other! + */ + public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, loop:Bool = false, startFrame:Int = 0):Void + { + // Skip if not allowed to play animations. + if ((!canPlayOtherAnims)) + { + if (this.currentAnimation == id && restart) {} + else if (ignoreExclusionPref != null && ignoreExclusionPref.length > 0) + { + var detected:Bool = false; + for (entry in ignoreExclusionPref) + { + if (StringTools.startsWith(id, entry)) + { + detected = true; + break; + } + } + if (!detected) return; + } + else + return; + } + + if (anim == null) return; + + if (id == null || id == '') id = this.currentAnimation; + + if (this.currentAnimation == id && !restart) + { + if (!anim.isPlaying) + { + if (fr != null) anim.curFrame = fr.index + startFrame; + else + anim.curFrame = startFrame; + + // Resume animation if it's paused. + anim.resume(); + } + + return; + } + else if (!hasAnimation(id)) + { + // Skip if the animation doesn't exist + trace('Animation ' + id + ' not found'); + return; + } + + this.currentAnimation = id; + anim.onComplete.removeAll(); + anim.onComplete.add(function() { + _onAnimationComplete(); + }); + + looping = loop; + + // Prevent other animations from playing if `ignoreOther` is true. + if (ignoreOther) canPlayOtherAnims = false; + + // Move to the first frame of the animation. + // goToFrameLabel(id); + // trace('Playing animation $id'); + if ((id == null || id == "") || this.anim.symbolDictionary.exists(id) || (this.anim.getByName(id) != null)) + { + this.anim.play(id, restart, false, startFrame); + + this.currentAnimation = anim.curSymbol.name; + + fr = null; + } + // Only call goToFrameLabel if there is a frame label with that name. This prevents annoying warnings! + if (getFrameLabelNames().indexOf(id) != -1) + { + goToFrameLabel(id); + fr = anim.getFrameLabel(id); + anim.curFrame += startFrame; + // Resume animation if it's paused. + anim.resume(); + } + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + } + + /** + * Returns true if the animation has finished playing. + * @return Whether the animation has finished playing. + */ + public function isAnimationFinished():Bool + { + return isLoopComplete(); + } + + /** + * Returns true if the animation has reached the last frame. + * Can be true even if animation is configured to loop. + * @return Whether the animation has reached the last frame. + */ + public function isLoopComplete():Bool + { + if (this.anim == null) return false; + if (!this.anim.isPlaying) return false; + + if (fr != null) + { + var curFrame = anim.curFrame; + + var startFrame = fr.index; + var endFrame = (fr.index + fr.duration); + + return (anim.reversed) ? (curFrame < startFrame) : (curFrame >= endFrame); + } + + return (anim.reversed && anim.curFrame == 0 || !(anim.reversed) && (anim.curFrame) >= (anim.length - 1)); + } + + /** + * Stops the current animation. + */ + public function stopAnimation():Void + { + if (this.currentAnimation == null) return; + + this.anim.removeAllCallbacksFrom(getNextFrameLabel(this.currentAnimation)); + + goToFrameIndex(0); + } + + function addFrameCallback(label:String, callback:Void->Void):Void + { + var frameLabel = this.anim.getFrameLabel(label); + frameLabel.add(callback); + } + + function goToFrameLabel(label:String):Void + { + this.anim.goToFrameLabel(label); + } + + function getFrameLabelNames(?layer:haxe.extern.EitherType):Array + { + var labels = this.anim.getFrameLabels(layer); + var array = []; + for (label in labels) + { + array.push(label.name); + } + + return array; + } + + function getNextFrameLabel(label:String):String + { + return listAnimations()[(getLabelIndex(label) + 1) % listAnimations().length]; + } + + function getLabelIndex(label:String):Int + { + return listAnimations().indexOf(label); + } + + function goToFrameIndex(index:Int):Void + { + this.anim.curFrame = index; + } + + public function cleanupAnimation(_:String):Void + { + canPlayOtherAnims = true; + // this.currentAnimation = null; + this.anim.pause(); + } + + function _onAnimationFrame(frame:Int):Void + { + if (currentAnimation != null) + { + onAnimationFrame.dispatch(currentAnimation, frame); + + if (isLoopComplete()) + { + anim.pause(); + + if (looping) + { + anim.curFrame = (fr != null) ? fr.index : 0; + anim.resume(); + _onAnimationLoop(); + } + else if (fr != null && anim.curFrame != anim.length - 1) + { + anim.curFrame--; + _onAnimationComplete(); + } + } + } + } + + function _onAnimationComplete():Void + { + if (currentAnimation != null) + { + onAnimationComplete.dispatch(currentAnimation); + } + else + { + onAnimationComplete.dispatch(''); + } + } + + function _onAnimationLoop():Void + { + if (currentAnimation != null) + { + onAnimationLoop.dispatch(currentAnimation); + } + else + { + onAnimationLoop.dispatch(''); + } + } + + var prevFrames:Map = []; + + public function replaceFrameGraphic(index:Int, ?graphic:FlxGraphicAsset):Void + { + var cond = false; + + if (graphic == null) cond = true; + else + { + if ((graphic is String)) cond = !Assets.exists(graphic) + else + cond = false; + } + if (cond) + { + var prevFrame:Null = prevFrames.get(index); + if (prevFrame == null) return; + + prevFrame.copyTo(frames.getByIndex(index)); + return; + } + + var prevFrame:FlxFrame = prevFrames.get(index) ?? frames.getByIndex(index).copyTo(); + prevFrames.set(index, prevFrame); + + var frame = FlxG.bitmap.add(graphic).imageFrame.frame; + frame.copyTo(frames.getByIndex(index)); + + // Additional sizing fix. + @:privateAccess + if (true) + { + var frame = frames.getByIndex(index); + frame.tileMatrix[0] = prevFrame.frame.width / frame.frame.width; + frame.tileMatrix[3] = prevFrame.frame.height / frame.frame.height; + } + } + + public function getBasePosition():Null + { + // var stagePos = new FlxPoint(anim.stageInstance.matrix.tx, anim.stageInstance.matrix.ty); + var instancePos = new FlxPoint(anim.curInstance.matrix.tx, anim.curInstance.matrix.ty); + var firstElement = anim.curSymbol.timeline?.get(0)?.get(0)?.get(0); + if (firstElement == null) return instancePos; + var firstElementPos = new FlxPoint(firstElement.matrix.tx, firstElement.matrix.ty); + + return instancePos + firstElementPos; + } + + public function getPivotPosition():Null + { + return anim.curInstance.symbol.transformationPoint; + } + + public override function destroy():Void + { + for (prevFrameId in prevFrames.keys()) + { + replaceFrameGraphic(prevFrameId, null); + } + + super.destroy(); + } +} diff --git a/source/funkin/graphics/framebuffer/BitmapDataUtil.hx b/source/funkin/graphics/framebuffer/BitmapDataUtil.hx new file mode 100644 index 00000000..7b49705e --- /dev/null +++ b/source/funkin/graphics/framebuffer/BitmapDataUtil.hx @@ -0,0 +1,123 @@ +package funkin.graphics.framebuffer; + +import flixel.FlxG; +import openfl.Lib; +import openfl.display.Bitmap; +import openfl.display.BitmapData; +import openfl.display.Sprite; +import openfl.display3D.Context3DTextureFormat; +import openfl.display3D.textures.TextureBase; +import openfl.filters.BitmapFilter; + +/** + * Provides cool stuff for `BitmapData`s that have a hardware texture internally. + */ +@:access(openfl.display.BitmapData) +@:access(openfl.display3D.textures.TextureBase) +@:access(openfl.display3D.Context3D) +class BitmapDataUtil +{ + static function getCache():{sprite:Sprite, bitmap:Bitmap} + { + static var cache:{sprite:Sprite, bitmap:Bitmap} = null; + if (cache == null) + { + final sprite = new Sprite(); + final bitmap = new Bitmap(); + sprite.addChild(bitmap); + cache = + { + sprite: sprite, + bitmap: bitmap + } + } + return cache; + } + + /** + * Applies a bitmap filter to a bitmap immediately. The bitmap filter may refer + * the bitmap itself as a shader input. + * @param bitmap the bitmap data + * @param filter the bitmap filter + */ + public static function applyFilter(bitmap:BitmapData, filter:BitmapFilter):Void + { + hardwareCheck(bitmap); + final cache = getCache(); + cache.bitmap.bitmapData = bitmap; + cache.sprite.filters = [filter]; + bitmap.draw(cache.sprite); + } + + /** + * Creates a bitmap with a hardware texture. + * @param width the width + * @param height the height + * @param format the format if the internal texture + * @return the bitmap + */ + public static function create(width:Int, height:Int, format:Context3DTextureFormat = BGRA):FixedBitmapData + { + final texture = Lib.current.stage.context3D.createTexture(width, height, format, true); + return FixedBitmapData.fromTexture(texture); + } + + /** + * Resizes the bitmap. + * @param bitmap the bitmap data + * @param width the width + * @param height the height + */ + public static function resize(bitmap:BitmapData, width:Int, height:Int):Void + { + hardwareCheck(bitmap); + if (bitmap.width == width && bitmap.height == height) return; + bitmap.width = width; + bitmap.height = height; + resizeTexture(bitmap.__texture, width, height); + } + + /** + * Resizes the texture. + * @param texture the texture + * @param width the width + * @param height the height + */ + public static function resizeTexture(texture:TextureBase, width:Int, height:Int):Void + { + if (texture.__width == width && texture.__height == height) return; + texture.__width = width; + texture.__height = height; + final context = texture.__context; + final gl = context.gl; + context.__bindGLTexture2D(texture.__textureID); + gl.texImage2D(gl.TEXTURE_2D, 0, texture.__internalFormat, width, height, 0, texture.__format, gl.UNSIGNED_BYTE, null); + context.__bindGLTexture2D(null); + } + + /** + * Copies the content of `src` to `dst`. The destination bitmap `dst` will be resized + * so that it has the same size as `src`. + * @param dst the destination bitmap + * @param src the source bitmap + */ + public static function copy(dst:BitmapData, src:BitmapData):Void + { + hardwareCheck(dst); + hardwareCheck(src); + final cache = getCache(); + cache.bitmap.bitmapData = src; + cache.sprite.filters = null; + resize(dst, src.width, src.height); + dst.fillRect(dst.rect, 0); + dst.draw(cache.sprite); + } + + static function hardwareCheck(bitmap:BitmapData):Void + { + if (bitmap.readable) + { + FlxG.log.error('do not use `BitmapDataUtil` for non-GPU bitmaps!'); + } + } +} diff --git a/source/funkin/graphics/framebuffer/FixedBitmapData.hx b/source/funkin/graphics/framebuffer/FixedBitmapData.hx new file mode 100644 index 00000000..4ffcbb86 --- /dev/null +++ b/source/funkin/graphics/framebuffer/FixedBitmapData.hx @@ -0,0 +1,42 @@ +package funkin.graphics.framebuffer; + +import openfl.display.BitmapData; +import openfl.display.DisplayObject; +import openfl.display.DisplayObjectContainer; +import openfl.display.IBitmapDrawable; +import openfl.display.OpenGLRenderer; +import openfl.display3D.textures.TextureBase; + +/** + * `BitmapData` is kinda broken so I fixed it. + */ +@:access(openfl.display3D.textures.TextureBase) +@:access(openfl.display.OpenGLRenderer) +class FixedBitmapData extends BitmapData +{ + override function __drawGL(source:IBitmapDrawable, renderer:OpenGLRenderer):Void + { + if (Std.isOfType(source, DisplayObject)) + { + final object:DisplayObjectContainer = cast source; + renderer.__stage = object.stage; + } + super.__drawGL(source, renderer); + } + + /** + * Never use `BitmapData.fromTexture`, always use this. + * @param texture the texture + * @return the bitmap data + */ + public static function fromTexture(texture:TextureBase):FixedBitmapData + { + if (texture == null) return null; + final bitmapData:FixedBitmapData = new FixedBitmapData(texture.__width, texture.__height, true, 0); + // bitmapData.readable = false; + bitmapData.__texture = texture; + bitmapData.__textureContext = texture.__textureContext; + // bitmapData.image = null; + return bitmapData; + } +} diff --git a/source/funkin/graphics/framebuffer/FrameBuffer.hx b/source/funkin/graphics/framebuffer/FrameBuffer.hx new file mode 100644 index 00000000..e99f72b7 --- /dev/null +++ b/source/funkin/graphics/framebuffer/FrameBuffer.hx @@ -0,0 +1,132 @@ +package funkin.graphics.framebuffer; + +import flixel.FlxSprite; +import flixel.FlxCamera; +import flixel.util.FlxColor; +import openfl.Lib; +import openfl.display.BitmapData; +import openfl.display3D.textures.TextureBase; +import openfl.geom.Matrix; +import openfl.geom.Rectangle; + +/** + * A single frame buffer. Used by `FrameBufferManager`. + */ +class FrameBuffer +{ + /** + * The bitmap data of the frame buffer. + */ + public var bitmap(default, null):BitmapData = null; + + var texture:TextureBase; + final camera:FlxCamera; + final spriteCopies:Array = []; + + public function new() + { + camera = new FlxCamera(); + camera.antialiasing = false; + camera.bgColor = FlxColor.TRANSPARENT; + @:privateAccess camera.flashSprite.stage = Lib.current.stage; + } + + /** + * Creates a frame buffer with the given size. + * @param width the width + * @param height the height + * @param bgColor the background color + */ + public function create(width:Int, height:Int, bgColor:FlxColor):Void + { + dispose(); + texture = Lib.current.stage.context3D.createTexture(width, height, BGRA, true); + bitmap = FixedBitmapData.fromTexture(texture); + camera.bgColor = bgColor; + } + + /** + * Makes the internal camera follows the target camera. + * @param target the target camera + */ + public function follow(target:FlxCamera):Void + { + camera.x = target.x; + camera.y = target.y; + camera.width = target.width; + camera.height = target.height; + camera.scroll.x = target.scroll.x; + camera.scroll.y = target.scroll.y; + camera.setScale(target.scaleX, target.scaleY); + } + + /** + * Locks the frame buffer and clears the buffer. + */ + @:access(flixel.FlxCamera) + public function lock():Void + { + camera.clearDrawStack(); + camera.canvas.graphics.clear(); + camera.fill(camera.bgColor.to24Bit(), camera.useBgAlphaBlending, camera.bgColor.alphaFloat); + #if FLX_DEBUG + camera.debugLayer.graphics.clear(); + #end + } + + /** + * Renders all sprite copies. + */ + @:access(flixel.FlxCamera) + public function render():Void + { + for (spriteCopy in spriteCopies) + { + spriteCopy.render(camera); + } + camera.render(); + } + + /** + * Unlocks the frame buffer and makes the bitmap ready to use. + */ + public function unlock():Void + { + bitmap.fillRect(new Rectangle(0, 0, bitmap.width, bitmap.height), 0); + bitmap.draw(camera.flashSprite, new Matrix(1, 0, 0, 1, camera.flashSprite.x, camera.flashSprite.y)); + } + + /** + * Diposes stuff. Call `create` again if you want to reuse the instance. + */ + public function dispose():Void + { + if (texture != null) + { + texture.dispose(); + texture = null; + bitmap.dispose(); + bitmap = null; + } + spriteCopies.resize(0); + } + + /** + * Adds a sprite copy to the frame buffer. + * @param spriteCopy the sprite copy + */ + public function addSpriteCopy(spriteCopy:SpriteCopy):Void + { + spriteCopies.push(spriteCopy); + } + + /** + * Adds the sprite to the frame buffer. The sprite will only be seen from + * the frame buffer. + * @param sprite the sprite + */ + public function moveSprite(sprite:FlxSprite):Void + { + sprite.cameras = [camera]; + } +} diff --git a/source/funkin/graphics/framebuffer/FrameBufferManager.hx b/source/funkin/graphics/framebuffer/FrameBufferManager.hx new file mode 100644 index 00000000..4d484fb8 --- /dev/null +++ b/source/funkin/graphics/framebuffer/FrameBufferManager.hx @@ -0,0 +1,127 @@ +package funkin.graphics.framebuffer; + +import flixel.FlxCamera; +import flixel.FlxG; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import openfl.display.BitmapData; + +/** + * Manages frame buffers and gives access to each frame buffer. + */ +class FrameBufferManager +{ + final camera:FlxCamera; + final frameBufferMap:Map = []; + + /** + * Creates a frame buffer manager that targets `camera`. + * @param camera the target camera. + */ + public function new(camera:FlxCamera) + { + this.camera = camera; + } + + /** + * Creates a new frame buffer with a name. + * @param name the name + * @param bgColor the background color + * @return the bitmap data of the frame buffer. the bitmap data instance + * will not be changed through frame buffer updates. + */ + public function createFrameBuffer(name:String, bgColor:FlxColor):BitmapData + { + if (frameBufferMap.exists(name)) + { + FlxG.log.warn('frame buffer "$name" already exists'); + frameBufferMap[name].dispose(); + frameBufferMap.remove(name); + } + final fb = new FrameBuffer(); + fb.create(camera.width, camera.height, bgColor); + frameBufferMap[name] = fb; + return fb.bitmap; + } + + /** + * Adds a copy of the sprite to the frame buffer. + * @param name the name of the frame buffer + * @param sprite the sprite + * @param color if this is not `null`, the sprite will be filled with the color. + * if this is `null`, the sprite will keep its original color. + */ + public function copySpriteTo(name:String, sprite:FlxSprite, color:Null = null):Void + { + if (!frameBufferMap.exists(name)) + { + FlxG.log.warn('frame buffer "$name" does not exist'); + return; + } + frameBufferMap[name].addSpriteCopy(new SpriteCopy(sprite, color)); + } + + /** + * Adds the sprite to the frame buffer. The sprite will only be seen from the frame buffer. + * @param name the name of the frame buffer + * @param sprite the sprite + */ + public function moveSpriteTo(name:String, sprite:FlxSprite):Void + { + if (!frameBufferMap.exists(name)) + { + FlxG.log.warn('frame buffer "$name" does not exist'); + return; + } + frameBufferMap[name].moveSprite(sprite); + } + + /** + * Call this before drawing anything. + */ + public function lock():Void + { + for (_ => fb in frameBufferMap) + { + fb.follow(camera); + fb.lock(); + } + } + + /** + * Unlocks the frame buffers. This updates the bitmap data of each frame buffer. + */ + public function unlock():Void + { + for (_ => fb in frameBufferMap) + { + fb.render(); + } + for (_ => fb in frameBufferMap) + { + fb.unlock(); + } + } + + /** + * Returns the bitmap data of the frame buffer + * @param name the name of the frame buffer + * @return the bitmap data + */ + public function getFrameBuffer(name:String):BitmapData + { + return frameBufferMap[name].bitmap; + } + + /** + * Disposes all frame buffers. The instance can be reused. + */ + public function dispose():Void + { + for (_ => fb in frameBufferMap) + { + fb.dispose(); + } + frameBufferMap.clear(); + } +} diff --git a/source/funkin/graphics/framebuffer/SpriteCopy.hx b/source/funkin/graphics/framebuffer/SpriteCopy.hx new file mode 100644 index 00000000..ea7de69d --- /dev/null +++ b/source/funkin/graphics/framebuffer/SpriteCopy.hx @@ -0,0 +1,59 @@ +package funkin.graphics.framebuffer; + +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.util.FlxColor; + +/** + * A copy of a `FlxSprite` with a specified color. Used to render the sprite to a frame buffer. + */ +class SpriteCopy +{ + final sprite:FlxSprite; + final color:Null; + + public function new(sprite:FlxSprite, color:Null) + { + this.sprite = sprite; + this.color = color; + } + + /** + * Renders the copy to the camera. + * @param camera the camera + */ + @:access(flixel.FlxSprite) + public function render(camera:FlxCamera):Void + { + if (color == null) + { + final tmpCameras = sprite._cameras; + sprite._cameras = [camera]; + sprite.draw(); + sprite._cameras = tmpCameras; + } + else + { + final rMult = sprite.colorTransform.redMultiplier; + final gMult = sprite.colorTransform.greenMultiplier; + final bMult = sprite.colorTransform.blueMultiplier; + final aMult = sprite.colorTransform.alphaMultiplier; + final rOff = Std.int(sprite.colorTransform.redOffset); + final gOff = Std.int(sprite.colorTransform.greenOffset); + final bOff = Std.int(sprite.colorTransform.blueOffset); + final aOff = Std.int(sprite.colorTransform.alphaOffset); + final tmpCameras = sprite._cameras; + final tmpShader = sprite.shader; + + sprite._cameras = [camera]; + sprite.shader = null; + + sprite.setColorTransform(0, 0, 0, 1, color.red, color.green, color.blue, 0); + sprite.draw(); + + sprite._cameras = tmpCameras; + sprite.shader = tmpShader; + sprite.setColorTransform(rMult, gMult, bMult, aMult, rOff, gOff, bOff, aOff); + } + } +} diff --git a/source/funkin/graphics/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx new file mode 100644 index 00000000..a06d5333 --- /dev/null +++ b/source/funkin/graphics/rendering/MeshRender.hx @@ -0,0 +1,105 @@ +package funkin.graphics.rendering; + +import flixel.FlxStrip; +import flixel.util.FlxColor; + +/** + * Yoinked from AustinEast, thanks hopefully u dont mind me using some of ur good code + * instead of my dumbass ugly code bro + */ +class MeshRender extends FlxStrip +{ + public var vertex_count(default, null):Int = 0; + public var index_count(default, null):Int = 0; + + public function new(x, y, ?col:FlxColor = FlxColor.WHITE) + { + super(x, y); + makeGraphic(1, 1, col); + } + + /** + * Add a vertex. + */ + public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int + { + final index = vertex_count; + final pos = index << 1; + + vertices[pos] = x; + vertices[pos + 1] = y; + + uvtData[pos] = u; + uvtData[pos + 1] = v; + + vertex_count++; + return index; + } + + /** + * Build a triangle from three vertex indexes. + * @param a + * @param b + * @param c + */ + public function add_tri(a:Int, b:Int, c:Int):Void + { + indices[index_count] = a; + indices[index_count + 1] = b; + indices[index_count + 2] = c; + + index_count += 3; + } + + public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0, + cv:Float = 0):Void + { + add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv)); + } + + /** + * @param a top left vertex + * @param b top right vertex + * @param c bottom right vertex + * @param d bottom left vertex + */ + public function add_quad(a:Int, b:Int, c:Int, d:Int):Void + { + add_tri(a, b, c); + add_tri(a, c, d); + } + + /** + * Build a quad from four points. + * + * top right - a + * top left - b + * bottom right - c + * bottom left - d + */ + public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, + bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void + { + // top left + var b = build_vertex(bx, by, bu, bv); + // top right + var a = build_vertex(ax, ay, au, av); + // bottom left + var c = build_vertex(cx, cy, cu, cv); + // bottom right + var d = build_vertex(dx, dy, du, dv); + + add_tri(a, b, c); + add_tri(a, c, d); + } + + public function clear() + { + vertices.length = 0; + indices.length = 0; + uvtData.length = 0; + colors.length = 0; + vertex_count = 0; + index_count = 0; + } +} diff --git a/source/funkin/graphics/shaders/AdjustColorShader.hx b/source/funkin/graphics/shaders/AdjustColorShader.hx new file mode 100644 index 00000000..cd3ac228 --- /dev/null +++ b/source/funkin/graphics/shaders/AdjustColorShader.hx @@ -0,0 +1,58 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; + +class AdjustColorShader extends FlxRuntimeShader +{ + public var hue(default, set):Float; + public var saturation(default, set):Float; + public var brightness(default, set):Float; + public var contrast(default, set):Float; + + public function new() + { + super(Assets.getText(Paths.frag('adjustColor'))); + // FlxG.debugger.addTrackerProfile(new TrackerProfile(HSVShader, ['hue', 'saturation', 'brightness', 'contrast'])); + hue = 0; + saturation = 0; + brightness = 0; + contrast = 0; + } + + function set_hue(value:Float):Float + { + this.setFloat('hue', value); + this.hue = value; + + return this.hue; + } + + function set_saturation(value:Float):Float + { + this.setFloat('saturation', value); + this.saturation = value; + + return this.saturation; + } + + function set_brightness(value:Float):Float + { + this.setFloat('brightness', value); + this.brightness = value; + + return this.brightness; + } + + function set_contrast(value:Float):Float + { + this.setFloat('contrast', value); + this.contrast = value; + + return this.contrast; + } + + public override function toString():String + { + return 'AdjustColorShader(${this.hue}, ${this.saturation}, ${this.brightness}, ${this.contrast})'; + } +} diff --git a/source/funkin/graphics/shaders/AngleMask.hx b/source/funkin/graphics/shaders/AngleMask.hx new file mode 100644 index 00000000..ce27311c --- /dev/null +++ b/source/funkin/graphics/shaders/AngleMask.hx @@ -0,0 +1,96 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; + +class AngleMask extends FlxShader +{ + public var extraColor(default, set):FlxColor = 0xFFFFFFFF; + + function set_extraColor(value:FlxColor):FlxColor + { + extraTint.value = [value.redFloat, value.greenFloat, value.blueFloat]; + this.extraColor = value; + + return this.extraColor; + } + + @:glFragmentSource(' + #pragma header + + uniform vec3 extraTint; + + uniform vec2 endPosition; + vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); + } + + + + // ====== GAMMA CORRECTION ====== // + // Helps with color mixing -- good to have by default in almost any shader + // See https://www.shadertoy.com/view/lscSzl + vec3 gamma(in vec3 color) { + return pow(color, vec3(1.0 / 2.2)); + } + + vec4 mainPass(vec2 fragCoord) { + vec4 base = texture2D(bitmap, fragCoord); + + vec2 uv = fragCoord.xy; + + vec2 start = vec2(0.0, 0.0); + vec2 end = vec2(endPosition.x / openfl_TextureSize.x, 1.0); + + float dx = end.x - start.x; + float dy = end.y - start.y; + + float angle = atan(dy, dx); + + uv.x -= start.x; + uv.y -= start.y; + + float uvA = atan(uv.y, uv.x); + + if (uvA < angle) + return base; + else + return vec4(0.0); + } + + vec4 antialias(vec2 fragCoord) { + + const float AA_STAGES = 2.0; + + const float AA_TOTAL_PASSES = AA_STAGES * AA_STAGES + 1.0; + const float AA_JITTER = 0.5; + + // Run the shader multiple times with a random subpixel offset each time and average the results + vec4 color = mainPass(fragCoord); + for (float x = 0.0; x < AA_STAGES; x++) + { + for (float y = 0.0; y < AA_STAGES; y++) + { + vec2 offset = AA_JITTER * (2.0 * hash22(vec2(x, y)) - 1.0) / openfl_TextureSize.xy; + color += mainPass(fragCoord + offset); + } + } + return color / AA_TOTAL_PASSES; + } + + void main() { + vec4 col = antialias(openfl_TextureCoordv); + col.xyz = col.xyz * extraTint.xyz; + // col.xyz = gamma(col.xyz); + gl_FragColor = col; + }') + public function new() + { + super(); + + endPosition.value = [90, 100]; // 100 AS DEFAULT WORKS NICELY FOR FREEPLAY? + extraTint.value = [1, 1, 1]; + } +} diff --git a/source/funkin/graphics/shaders/BlendModeEffect.hx b/source/funkin/graphics/shaders/BlendModeEffect.hx new file mode 100644 index 00000000..bf224679 --- /dev/null +++ b/source/funkin/graphics/shaders/BlendModeEffect.hx @@ -0,0 +1,34 @@ +package funkin.graphics.shaders; + +import flixel.util.FlxColor; +import openfl.display.ShaderParameter; + +typedef BlendModeShader = +{ + var uBlendColor:ShaderParameter; +} + +class BlendModeEffect +{ + public var shader(default, null):BlendModeShader; + + @:isVar + public var color(default, set):FlxColor; + + public function new(shader:BlendModeShader, color:FlxColor):Void + { + shader.uBlendColor.value = []; + this.shader = shader; + this.color = color; + } + + function set_color(color:FlxColor):FlxColor + { + shader.uBlendColor.value[0] = color.redFloat; + shader.uBlendColor.value[1] = color.greenFloat; + shader.uBlendColor.value[2] = color.blueFloat; + shader.uBlendColor.value[3] = color.alphaFloat; + + return this.color = color; + } +} diff --git a/source/funkin/graphics/shaders/BlendModesShader.hx b/source/funkin/graphics/shaders/BlendModesShader.hx new file mode 100644 index 00000000..e6ae45dd --- /dev/null +++ b/source/funkin/graphics/shaders/BlendModesShader.hx @@ -0,0 +1,23 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; +import openfl.display.BitmapData; +import openfl.display.ShaderInput; + +class BlendModesShader extends FlxRuntimeShader +{ + public var camera:ShaderInput; + public var cameraData:BitmapData; + + public function new() + { + super(Assets.getText(Paths.frag('blendModes'))); + } + + public function setCamera(cameraData:BitmapData):Void + { + this.cameraData = cameraData; + + this.setBitmapData('camera', this.cameraData); + } +} diff --git a/source/funkin/graphics/shaders/BlueFade.hx b/source/funkin/graphics/shaders/BlueFade.hx new file mode 100644 index 00000000..08675092 --- /dev/null +++ b/source/funkin/graphics/shaders/BlueFade.hx @@ -0,0 +1,50 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.tweens.FlxTween; + +class BlueFade extends FlxShader +{ + public var fadeVal(default, set):Float; + + function set_fadeVal(val:Float):Float + { + fadeAmt.value = [val]; + fadeVal = val; + // trace(fadeVal); + + return val; + } + + public function fade(startAmt:Float = 0, targetAmt:Float = 1, duration:Float, _options:TweenOptions):Void + { + fadeVal = startAmt; + FlxTween.tween(this, {fadeVal: targetAmt}, duration, _options); + } + + @:glFragmentSource(' + #pragma header + + // Value from (0, 1) + uniform float fadeAmt; + + // fade the image to blue as it fades to black + + void main() + { + vec4 tex = flixel_texture2D(bitmap, openfl_TextureCoordv); + + vec4 finalColor = mix(vec4(vec4(0.0, 0.0, tex.b, tex.a) * fadeAmt), vec4(tex * fadeAmt), fadeAmt); + + // Output to screen + gl_FragColor = finalColor; + } + + ') + public function new() + { + super(); + + this.fadeVal = 1; + } +} diff --git a/source/funkin/graphics/shaders/ColorSwap.hx b/source/funkin/graphics/shaders/ColorSwap.hx new file mode 100644 index 00000000..1be4d542 --- /dev/null +++ b/source/funkin/graphics/shaders/ColorSwap.hx @@ -0,0 +1,159 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; + +class ColorSwap +{ + public var shader(default, null):ColorSwapShader; + public var colorToReplace(default, set):FlxColor; + public var newColor(default, set):FlxColor; + public var daTime(default, set):Float; + + public var hasOutline(default, set):Bool = false; + + public var hueShit:Float = 0; + + public function new():Void + { + shader = new ColorSwapShader(); + shader.uTime.value = [0]; + shader.money.value = [0]; + shader.awesomeOutline.value = [hasOutline]; + } + + public function update(elapsed:Float):Void + { + shader.uTime.value[0] += elapsed; + hueShit += elapsed; + // trace(shader.money.value[0]); + } + + function set_colorToReplace(color:FlxColor):FlxColor + { + colorToReplace = color; + + return color; + } + + function set_hasOutline(lol:Bool):Bool + { + shader.awesomeOutline.value = [lol]; + return lol; + } + + function set_daTime(daTime:Float):Float + { + return daTime; + } + + function set_newColor(color:FlxColor):FlxColor + { + newColor = color; + + return color; + } +} + +class ColorSwapShader extends FlxShader +{ + @:glFragmentSource(' + #pragma header + + uniform float uTime; + uniform float money; + uniform bool awesomeOutline; + + + const float offset = 1.0 / 128.0; + + + + vec3 normalizeColor(vec3 color) + { + return vec3( + color[0] / 255.0, + color[1] / 255.0, + color[2] / 255.0 + ); + } + + vec3 rgb2hsv(vec3 c) + { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); + } + + vec3 hsv2rgb(vec3 c) + { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); + } + + void main() + { + vec4 color = flixel_texture2D(bitmap, openfl_TextureCoordv); + + vec4 swagColor = vec4(rgb2hsv(vec3(color[0], color[1], color[2])), color[3]); + + // [0] is the hue??? + swagColor[0] += uTime; + // swagColor[1] += uTime; + + // money += swagColor[0]; + + color = vec4(hsv2rgb(vec3(swagColor[0], swagColor[1], swagColor[2])), swagColor[3]); + + + if (awesomeOutline) + { + // Outline bullshit? + vec2 size = vec2(3, 3); + + if (color.a <= 0.5) { + float w = size.x / openfl_TextureSize.x; + float h = size.y / openfl_TextureSize.y; + + if (flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x + w, openfl_TextureCoordv.y)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x - w, openfl_TextureCoordv.y)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x, openfl_TextureCoordv.y + h)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x, openfl_TextureCoordv.y - h)).a != 0.) + color = vec4(1.0, 1.0, 1.0, 1.0); + } + + + } + + + + gl_FragColor = color; + + + /* + if (color.a > 0.5) + gl_FragColor = color; + else + { + float a = flixel_texture2D(bitmap, vec2(openfl_TextureCoordv + offset, openfl_TextureCoordv.y)).a + + flixel_texture2D(bitmap, vec2(openfl_TextureCoordv, openfl_TextureCoordv.y - offset)).a + + flixel_texture2D(bitmap, vec2(openfl_TextureCoordv - offset, openfl_TextureCoordv.y)).a + + flixel_texture2D(bitmap, vec2(openfl_TextureCoordv, openfl_TextureCoordv.y + offset)).a; + if (color.a < 1.0 && a > 0.0) + gl_FragColor = vec4(0.0, 0.0, 0.0, 0.8); + else + gl_FragColor = color; + } */ + } + + ') + public function new() + { + super(); + } +} diff --git a/source/funkin/graphics/shaders/DropShadowScreenspace.hx b/source/funkin/graphics/shaders/DropShadowScreenspace.hx new file mode 100644 index 00000000..799a54df --- /dev/null +++ b/source/funkin/graphics/shaders/DropShadowScreenspace.hx @@ -0,0 +1,227 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; +import flixel.FlxSprite; +import flixel.math.FlxAngle; +import flixel.graphics.frames.FlxFrame; +import openfl.display.BitmapData; + +/* + A screenspace version of the DropShadowShader.. currently the only way to use this effect with + FlxAnimate :( + */ +class DropShadowScreenspace extends DropShadowShader +{ + /* + The current zoom of the camera. Needed to figure out how much to multiply the drop shadow size. + */ + public var curZoom(default, set):Float; + + function set_curZoom(val:Float):Float + { + curZoom = val; + zoom.value = [val]; + return val; + } + + @:glFragmentSource(' + #pragma header + + // This shader aims to mostly recreate how Adobe Animate/Flash handles drop shadows, but its main use here is for rim lighting. + + // this shader also includes a recreation of the Animate/Flash "Adjust Color" filter, + // which was kindly provided and written by Rozebud https://github.com/ThatRozebudDude ( thank u rozebud :) ) + // Adapted from Andrey-Postelzhuks shader found here: https://forum.unity.com/threads/hue-saturation-brightness-contrast-shader.260649/ + // Hue rotation stuff is from here: https://www.w3.org/TR/filter-effects/#feColorMatrixElement + + // equals (frame.left, frame.top, frame.right, frame.bottom) + uniform vec4 uFrameBounds; + + uniform float ang; + uniform float dist; + uniform float str; + uniform float thr; + + // need to account for rotated frames... oops + uniform float angOffset; + + uniform sampler2D altMask; + uniform bool useMask; + uniform float thr2; + + uniform vec3 dropColor; + + uniform float hue; + uniform float saturation; + uniform float brightness; + uniform float contrast; + + uniform float zoom; + + uniform float AA_STAGES; + + const vec3 grayscaleValues = vec3(0.3098039215686275, 0.607843137254902, 0.0823529411764706); + const float e = 2.718281828459045; + + vec3 applyHueRotate(vec3 aColor, float aHue){ + float angle = radians(aHue); + + mat3 m1 = mat3(0.213, 0.213, 0.213, 0.715, 0.715, 0.715, 0.072, 0.072, 0.072); + mat3 m2 = mat3(0.787, -0.213, -0.213, -0.715, 0.285, -0.715, -0.072, -0.072, 0.928); + mat3 m3 = mat3(-0.213, 0.143, -0.787, -0.715, 0.140, 0.715, 0.928, -0.283, 0.072); + mat3 m = m1 + cos(angle) * m2 + sin(angle) * m3; + + return m * aColor; + } + + vec3 applySaturation(vec3 aColor, float value){ + if(value > 0.0){ value = value * 3.0; } + value = (1.0 + (value / 100.0)); + vec3 grayscale = vec3(dot(aColor, grayscaleValues)); + return clamp(mix(grayscale, aColor, value), 0.0, 1.0); + } + + vec3 applyContrast(vec3 aColor, float value){ + value = (1.0 + (value / 100.0)); + if(value > 1.0){ + value = (((0.00852259 * pow(e, 4.76454 * (value - 1.0))) * 1.01) - 0.0086078159) * 10.0; //Just roll with it... + value += 1.0; + } + return clamp((aColor - 0.25) * value + 0.25, 0.0, 1.0); + } + + vec3 applyHSBCEffect(vec3 color){ + + //Brightness + color = color + ((brightness) / 255.0); + + //Hue + color = applyHueRotate(color, hue); + + //Contrast + color = applyContrast(color, contrast); + + //Saturation + color = applySaturation(color, saturation); + + return color; + } + + vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); + } + + float intensityPass(vec2 fragCoord, float curThreshold, bool useMask) { + vec4 col = texture2D(bitmap, fragCoord); + + float maskIntensity = 0.0; + if(useMask == true){ + maskIntensity = mix(0.0, 1.0, texture2D(altMask, fragCoord).b); + } + + if(col.a == 0.0){ + return 0.0; + } + + float intensity = dot(col.rgb, vec3(0.3098, 0.6078, 0.0823)); + + intensity = maskIntensity > 0.0 ? float(intensity > thr2) : float(intensity > thr); + + return intensity; + } + + // essentially just stole this from the AngleMask shader but repurposed it to smooth + // the threshold because without any sort of smoothing it produces horrible edges + float antialias(vec2 fragCoord, float curThreshold, bool useMask) { + + // In GLSL 100, we need to use constant loop bounds + // Well assume a reasonable maximum for AA_STAGES and use a fixed loop + // The actual number of iterations will be controlled by a condition inside + const int MAX_AA = 8; // This should be large enough for most uses + + float AA_TOTAL_PASSES = AA_STAGES * AA_STAGES + 1.0; + const float AA_JITTER = 0.5; + + // Run the shader multiple times with a random subpixel offset each time and average the results + float color = intensityPass(fragCoord, curThreshold, useMask); + for (int i = 0; i < MAX_AA * MAX_AA; i++) { + // Calculate x and y from i + int x = i / MAX_AA; + int y = i - (MAX_AA * int(i/MAX_AA)); // poor mans modulus + + // Skip iterations beyond our desired AA_STAGES + if (float(x) >= AA_STAGES || float(y) >= AA_STAGES) { + continue; + } + + vec2 offset = AA_JITTER * (2.0 * hash22(vec2(float(x), float(y))) - 1.0) / openfl_TextureSize.xy; + color += intensityPass(fragCoord + offset, curThreshold, useMask); + } + + return color / AA_TOTAL_PASSES; + } + + vec3 createDropShadow(vec3 col, float curThreshold, bool useMask) { + + // essentially a mask so that areas under the threshold dont show the rimlight (mainly the outlines) + float intensity = antialias(openfl_TextureCoordv, curThreshold, useMask); + + // the distance the dropshadow moves needs to be correctly scaled based on the texture size + vec2 imageRatio = vec2(1.0/openfl_TextureSize.x, 1.0/openfl_TextureSize.y); + + // check the pixel in the direction and distance specified + vec2 checkedPixel = vec2(openfl_TextureCoordv.x + ((dist*zoom) * cos(ang + angOffset) * imageRatio.x), openfl_TextureCoordv.y - ((dist*zoom) * sin(ang + angOffset) * imageRatio.y)); + + // multiplier for the intensity of the drop shadow + float dropShadowAmount = 0.0; + + dropShadowAmount = texture2D(bitmap, checkedPixel).a; + + // add the dropshadow color based on the amount, strength, and intensity + col.rgb += dropColor.rgb * ((1.0 - (dropShadowAmount * str))*intensity); + + return col; + } + + void main() + { + vec4 col = texture2D(bitmap, openfl_TextureCoordv); + + vec3 unpremultipliedColor = col.a > 0.0 ? col.rgb / col.a : col.rgb; + + vec3 outColor = applyHSBCEffect(unpremultipliedColor); + + outColor = createDropShadow(outColor, thr, useMask); + + gl_FragColor = vec4(outColor.rgb * col.a, col.a); + } + + ') + override public function new() + { + super(); + + angle = 90; + strength = 1; + distance = 15; + threshold = 0.1; + + baseHue = 0; + baseSaturation = 0; + baseBrightness = 0; + baseContrast = 0; + + useAltMask = false; + + color = 0xFFDFEF3C; + + antialiasAmt = 2; + + curZoom = 1; + + angOffset.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/DropShadowShader.hx b/source/funkin/graphics/shaders/DropShadowShader.hx new file mode 100644 index 00000000..c57eadff --- /dev/null +++ b/source/funkin/graphics/shaders/DropShadowShader.hx @@ -0,0 +1,451 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; +import flixel.FlxSprite; +import flixel.math.FlxAngle; +import flixel.graphics.frames.FlxFrame; +import openfl.display.BitmapData; + +/* + A shader that aims to *mostly recreate how Adobe Animate/Flash handles drop shadows, but its main use here is for rim lighting. + + Has options for color, angle, distance, and a threshold to not cast the shadow on parts like outlines. + Can also be supplied a secondary mask which can then have an alternate threshold, for when sprites have too many conflicting colors + for the drop shadow to look right (e.g. the tankmen on GF's speakers). + + Also has an Adjust Color shader in here so they can work together when needed. + */ +class DropShadowShader extends FlxShader +{ + /* + The color of the drop shadow. + */ + public var color(default, set):FlxColor; + + /* + The angle of the drop shadow. + + for reference, depending on the angle, the affected side will be: + 0 = RIGHT + 90 = UP + 180 = LEFT + 270 = DOWN + */ + public var angle(default, set):Float; + + /* + The distance or size of the drop shadow, in pixels, + relative to the texture itself... NOT the camera. + */ + public var distance(default, set):Float; + + /* + The strength of the drop shadow. + Effectively just an alpha multiplier. + */ + public var strength(default, set):Float; + + /* + The brightness threshold for the drop shadow. + Anything below this number will NOT be affected by the drop shadow shader. + A value of 0 effectively means theres no threshold, and vice versa. + */ + public var threshold(default, set):Float; + + /* + The amount of antialias samples per-pixel, + used to smooth out any hard edges the brightness thresholding creates. + Defaults to 2, and 0 will remove any smoothing. + */ + public var antialiasAmt(default, set):Float; + + /* + Whether the shader should try and use the alternate mask. + False by default. + */ + public var useAltMask(default, set):Bool; + + /* + The image for the alternate mask. + At the moment, it uses the blue channel to specify what is or isnt going to use the alternate threshold. + (its kinda sloppy rn i need to make it work a little nicer) + TODO: maybe have a sort of "threshold intensity texture" as well? where higher/lower values indicate threshold strength.. + */ + public var altMaskImage(default, set):BitmapData; + + /* + An alternate brightness threshold for the drop shadow. + Anything below this number will NOT be affected by the drop shadow shader, + but ONLY when the pixel is within the mask. + */ + public var maskThreshold(default, set):Float; + + /* + The FlxSprite that the shader should get the frame data from. + Needed to keep the drop shadow shader in the correct bounds and rotation. + */ + public var attachedSprite(default, set):FlxSprite; + + /* + The hue component of the Adjust Color part of the shader. + */ + public var baseHue(default, set):Float; + + /* + The saturation component of the Adjust Color part of the shader. + */ + public var baseSaturation(default, set):Float; + + /* + The brightness component of the Adjust Color part of the shader. + */ + public var baseBrightness(default, set):Float; + + /* + The contrast component of the Adjust Color part of the shader. + */ + public var baseContrast(default, set):Float; + + /* + Sets all 4 adjust color values. + */ + public function setAdjustColor(b:Float, h:Float, c:Float, s:Float) + { + baseBrightness = b; + baseHue = h; + baseContrast = c; + baseSaturation = s; + } + + function set_baseHue(val:Float):Float + { + baseHue = val; + hue.value = [val]; + return val; + } + + function set_baseSaturation(val:Float):Float + { + baseSaturation = val; + saturation.value = [val]; + return val; + } + + function set_baseBrightness(val:Float):Float + { + baseBrightness = val; + brightness.value = [val]; + return val; + } + + function set_baseContrast(val:Float):Float + { + baseContrast = val; + contrast.value = [val]; + return val; + } + + function set_threshold(val:Float):Float + { + threshold = val; + thr.value = [val]; + return val; + } + + function set_antialiasAmt(val:Float):Float + { + antialiasAmt = val; + AA_STAGES.value = [val]; + return val; + } + + function set_color(col:FlxColor):FlxColor + { + color = col; + dropColor.value = [color.red / 255, color.green / 255, color.blue / 255]; + + return color; + } + + function set_angle(val:Float):Float + { + angle = val; + ang.value = [angle * FlxAngle.TO_RAD]; + return angle; + } + + function set_distance(val:Float):Float + { + distance = val; + dist.value = [val]; + return val; + } + + function set_strength(val:Float):Float + { + strength = val; + str.value = [val]; + return val; + } + + function set_attachedSprite(spr:FlxSprite):FlxSprite + { + attachedSprite = spr; + updateFrameInfo(attachedSprite.frame); + return spr; + } + + /* + Loads an image for the mask. + While you *could* directly set the value of the mask, this function works for both HTML5 and desktop targets. + */ + public function loadAltMask(path:String) + { + #if html5 + BitmapData.loadFromFile(path).onComplete(function(bmp:BitmapData) { + altMaskImage = bmp; + }); + #else + altMaskImage = BitmapData.fromFile(path); + #end + } + + /* + Should be called on the animation.callback of the attached sprite. + TODO: figure out why the reference to the attachedSprite breaks on web?? + */ + public function onAttachedFrame(name, frameNum, frameIndex) + { + if (attachedSprite != null) updateFrameInfo(attachedSprite.frame); + } + + /* + Updates the frame bounds and angle offset of the sprite for the shader. + */ + public function updateFrameInfo(frame:FlxFrame) + { + // NOTE: uv.width is actually the right pos and uv.height is the bottom pos + uFrameBounds.value = [frame.uv.x, frame.uv.y, frame.uv.width, frame.uv.height]; + + // if a frame is rotated the shader will look completely wrong lol + angOffset.value = [frame.angle * FlxAngle.TO_RAD]; + } + + function set_altMaskImage(_bitmapData:BitmapData):BitmapData + { + altMask.input = _bitmapData; + + return _bitmapData; + } + + function set_maskThreshold(val:Float):Float + { + maskThreshold = val; + thr2.value = [val]; + return val; + } + + function set_useAltMask(val:Bool):Bool + { + useAltMask = val; + useMask.value = [val]; + return val; + } + + @:glFragmentSource(' + #pragma header + + // This shader aims to mostly recreate how Adobe Animate/Flash handles drop shadows, but its main use here is for rim lighting. + + // this shader also includes a recreation of the Animate/Flash "Adjust Color" filter, + // which was kindly provided and written by Rozebud https://github.com/ThatRozebudDude ( thank u rozebud :) ) + // Adapted from Andrey-Postelzhuks shader found here: https://forum.unity.com/threads/hue-saturation-brightness-contrast-shader.260649/ + // Hue rotation stuff is from here: https://www.w3.org/TR/filter-effects/#feColorMatrixElement + + // equals (frame.left, frame.top, frame.right, frame.bottom) + uniform vec4 uFrameBounds; + + uniform float ang; + uniform float dist; + uniform float str; + uniform float thr; + + // need to account for rotated frames... oops + uniform float angOffset; + + uniform sampler2D altMask; + uniform bool useMask; + uniform float thr2; + + uniform vec3 dropColor; + + uniform float hue; + uniform float saturation; + uniform float brightness; + uniform float contrast; + + uniform float AA_STAGES; + + const vec3 grayscaleValues = vec3(0.3098039215686275, 0.607843137254902, 0.0823529411764706); + const float e = 2.718281828459045; + + vec3 applyHueRotate(vec3 aColor, float aHue){ + float angle = radians(aHue); + + mat3 m1 = mat3(0.213, 0.213, 0.213, 0.715, 0.715, 0.715, 0.072, 0.072, 0.072); + mat3 m2 = mat3(0.787, -0.213, -0.213, -0.715, 0.285, -0.715, -0.072, -0.072, 0.928); + mat3 m3 = mat3(-0.213, 0.143, -0.787, -0.715, 0.140, 0.715, 0.928, -0.283, 0.072); + mat3 m = m1 + cos(angle) * m2 + sin(angle) * m3; + + return m * aColor; + } + + vec3 applySaturation(vec3 aColor, float value){ + if(value > 0.0){ value = value * 3.0; } + value = (1.0 + (value / 100.0)); + vec3 grayscale = vec3(dot(aColor, grayscaleValues)); + return clamp(mix(grayscale, aColor, value), 0.0, 1.0); + } + + vec3 applyContrast(vec3 aColor, float value){ + value = (1.0 + (value / 100.0)); + if(value > 1.0){ + value = (((0.00852259 * pow(e, 4.76454 * (value - 1.0))) * 1.01) - 0.0086078159) * 10.0; //Just roll with it... + value += 1.0; + } + return clamp((aColor - 0.25) * value + 0.25, 0.0, 1.0); + } + + vec3 applyHSBCEffect(vec3 color){ + + //Brightness + color = color + ((brightness) / 255.0); + + //Hue + color = applyHueRotate(color, hue); + + //Contrast + color = applyContrast(color, contrast); + + //Saturation + color = applySaturation(color, saturation); + + return color; + } + + vec2 hash22(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); + } + + float intensityPass(vec2 fragCoord, float curThreshold, bool useMask) { + vec4 col = texture2D(bitmap, fragCoord); + + float maskIntensity = 0.0; + if(useMask == true){ + maskIntensity = mix(0.0, 1.0, texture2D(altMask, fragCoord).b); + } + + if(col.a == 0.0){ + return 0.0; + } + + float intensity = dot(col.rgb, vec3(0.3098, 0.6078, 0.0823)); + + intensity = maskIntensity > 0.0 ? float(intensity > thr2) : float(intensity > thr); + + return intensity; + } + + // essentially just stole this from the AngleMask shader but repurposed it to smooth + // the threshold because without any sort of smoothing it produces horrible edges + float antialias(vec2 fragCoord, float curThreshold, bool useMask) { + + // In GLSL 100, we need to use constant loop bounds + // Well assume a reasonable maximum for AA_STAGES and use a fixed loop + // The actual number of iterations will be controlled by a condition inside + const int MAX_AA = 8; // This should be large enough for most uses + + float AA_TOTAL_PASSES = AA_STAGES * AA_STAGES + 1.0; + const float AA_JITTER = 0.5; + + // Run the shader multiple times with a random subpixel offset each time and average the results + float color = intensityPass(fragCoord, curThreshold, useMask); + for (int i = 0; i < MAX_AA * MAX_AA; i++) { + // Calculate x and y from i + int x = i / MAX_AA; + int y = i - (MAX_AA * int(i/MAX_AA)); // poor mans modulus + + // Skip iterations beyond our desired AA_STAGES + if (float(x) >= AA_STAGES || float(y) >= AA_STAGES) { + continue; + } + + vec2 offset = AA_JITTER * (2.0 * hash22(vec2(float(x), float(y))) - 1.0) / openfl_TextureSize.xy; + color += intensityPass(fragCoord + offset, curThreshold, useMask); + } + + return color / AA_TOTAL_PASSES; + } + + vec3 createDropShadow(vec3 col, float curThreshold, bool useMask) { + + // essentially a mask so that areas under the threshold dont show the rimlight (mainly the outlines) + float intensity = antialias(openfl_TextureCoordv, curThreshold, useMask); + + // the distance the dropshadow moves needs to be correctly scaled based on the texture size + vec2 imageRatio = vec2(1.0/openfl_TextureSize.x, 1.0/openfl_TextureSize.y); + + // check the pixel in the direction and distance specified + vec2 checkedPixel = vec2(openfl_TextureCoordv.x + (dist * cos(ang + angOffset) * imageRatio.x), openfl_TextureCoordv.y - (dist * sin(ang + angOffset) * imageRatio.y)); + + // multiplier for the intensity of the drop shadow + float dropShadowAmount = 0.0; + + if(checkedPixel.x > uFrameBounds.x && checkedPixel.y > uFrameBounds.y && checkedPixel.x < uFrameBounds.z && checkedPixel.y < uFrameBounds.w){ + dropShadowAmount = texture2D(bitmap, checkedPixel).a; + } + + // add the dropshadow color based on the amount, strength, and intensity + col.rgb += dropColor.rgb * ((1.0 - (dropShadowAmount * str))*intensity); + + return col; + } + + void main() + { + vec4 col = texture2D(bitmap, openfl_TextureCoordv); + + vec3 unpremultipliedColor = col.a > 0.0 ? col.rgb / col.a : col.rgb; + + vec3 outColor = applyHSBCEffect(unpremultipliedColor); + + outColor = createDropShadow(outColor, thr, useMask); + + gl_FragColor = vec4(outColor.rgb * col.a, col.a); + } + + ') + public function new() + { + super(); + + angle = 0; + strength = 1; + distance = 15; + threshold = 0.1; + + baseHue = 0; + baseSaturation = 0; + baseBrightness = 0; + baseContrast = 0; + + antialiasAmt = 2; + + useAltMask = false; + + angOffset.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/GaussianBlurShader.hx b/source/funkin/graphics/shaders/GaussianBlurShader.hx new file mode 100644 index 00000000..023a6685 --- /dev/null +++ b/source/funkin/graphics/shaders/GaussianBlurShader.hx @@ -0,0 +1,23 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; + +/** + * Note... not actually gaussian! + */ +class GaussianBlurShader extends FlxRuntimeShader +{ + public var amount:Float; + + public function new(amount:Float = 1.0) + { + super(Assets.getText(Paths.frag("gaussianBlur"))); + setAmount(amount); + } + + public function setAmount(value:Float):Void + { + this.amount = value; + this.setFloat("_amount", amount); + } +} diff --git a/source/funkin/graphics/shaders/Grayscale.hx b/source/funkin/graphics/shaders/Grayscale.hx new file mode 100644 index 00000000..6fbdd188 --- /dev/null +++ b/source/funkin/graphics/shaders/Grayscale.hx @@ -0,0 +1,20 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; + +class Grayscale extends FlxRuntimeShader +{ + public var amount:Float = 1; + + public function new(amount:Float = 1) + { + super(Assets.getText(Paths.frag("grayscale"))); + setAmount(amount); + } + + public function setAmount(value:Float):Void + { + amount = value; + this.setFloat("_amount", amount); + } +} diff --git a/source/funkin/graphics/shaders/HSVShader.hx b/source/funkin/graphics/shaders/HSVShader.hx new file mode 100644 index 00000000..f7ce513f --- /dev/null +++ b/source/funkin/graphics/shaders/HSVShader.hx @@ -0,0 +1,43 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; + +class HSVShader extends FlxRuntimeShader +{ + public var hue(default, set):Float; + public var saturation(default, set):Float; + public var value(default, set):Float; + + public function new(h:Float = 1, s:Float = 1, v:Float = 1) + { + super(Assets.getText(Paths.frag('hsv'))); + FlxG.debugger.addTrackerProfile(new TrackerProfile(HSVShader, ['hue', 'saturation', 'value'])); + hue = h; + saturation = s; + value = v; + } + + function set_hue(value:Float):Float + { + this.setFloat('_hue', value); + this.hue = value; + + return this.hue; + } + + function set_saturation(value:Float):Float + { + this.setFloat('_sat', value); + this.saturation = value; + + return this.saturation; + } + + function set_value(value:Float):Float + { + this.setFloat('_val', value); + this.value = value; + + return this.value; + } +} diff --git a/source/funkin/graphics/shaders/InverseDotsShader.hx b/source/funkin/graphics/shaders/InverseDotsShader.hx new file mode 100644 index 00000000..d77808cf --- /dev/null +++ b/source/funkin/graphics/shaders/InverseDotsShader.hx @@ -0,0 +1,23 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; + +/** + * Create a little dotting effect. + */ +class InverseDotsShader extends FlxRuntimeShader +{ + public var amount:Float; + + public function new(amount:Float = 1.0) + { + super(Assets.getText(Paths.frag("InverseDots"))); + setAmount(amount); + } + + public function setAmount(value:Float):Void + { + this.amount = value; + this.setFloat("_amount", amount); + } +} diff --git a/source/funkin/graphics/shaders/LeftMaskShader.hx b/source/funkin/graphics/shaders/LeftMaskShader.hx new file mode 100644 index 00000000..f82a5c20 --- /dev/null +++ b/source/funkin/graphics/shaders/LeftMaskShader.hx @@ -0,0 +1,76 @@ +package funkin.graphics.shaders; + +import flixel.math.FlxRect; +import flixel.system.FlxAssets.FlxShader; + +class LeftMaskShader extends FlxShader +{ + public var swagMaskX(default, set):Float = 0; + public var swagSprX(default, set):Float = 0; + public var frameUV(default, set):FlxRect; + + function set_swagSprX(x:Float):Float + { + sprX.value[0] = x; + + return x; + } + + function set_swagMaskX(x:Float):Float + { + maskX.value[0] = x; + + return x; + } + + function set_frameUV(uv:FlxRect):FlxRect + { + trace("SETTING FRAMEUV"); + trace(uv); + + uvFrameX.value[0] = uv.x; + uvFrameY.value[0] = uv.y; + + return uv; + } + + @:glFragmentSource(' + #pragma header + + uniform float sprX; + uniform float maskX; + + uniform float uvFrameX; + uniform float uvFrameY; + + void main() + { + + float cutOff = maskX - sprX; + float sprPos = cutOff / openfl_TextureSize.x; + + vec2 uv = openfl_TextureCoordv.xy; + + vec4 color = flixel_texture2D(bitmap, uv); + + if (uv.x < sprPos + uvFrameX) + { + color = vec4(0.0, 0.0, 0.0, 0.0); + } + + gl_FragColor = color; + // vec4 testCol = vec4(openfl_Position.x, openfl_Position.y, openfl_Position.z, 1.0); + //gl_FragColor = vec4(1.0, openfl_TextureSize.x, 1.0, 1.0); + + } + ') + public function new() + { + super(); + + sprX.value = [0]; + maskX.value = [0]; + uvFrameX.value = [0]; + uvFrameY.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/MosaicEffect.hx b/source/funkin/graphics/shaders/MosaicEffect.hx new file mode 100644 index 00000000..76a6b6f3 --- /dev/null +++ b/source/funkin/graphics/shaders/MosaicEffect.hx @@ -0,0 +1,21 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; +import flixel.math.FlxPoint; + +class MosaicEffect extends FlxRuntimeShader +{ + public var blockSize:FlxPoint = FlxPoint.get(1.0, 1.0); + + public function new() + { + super(Assets.getText(Paths.frag('mosaic'))); + setBlockSize(1.0, 1.0); + } + + public function setBlockSize(w:Float, h:Float) + { + blockSize.set(w, h); + setFloatArray("uBlocksize", [w, h]); + } +} diff --git a/source/funkin/graphics/shaders/MultiplyShader.hx b/source/funkin/graphics/shaders/MultiplyShader.hx new file mode 100644 index 00000000..5fe95f04 --- /dev/null +++ b/source/funkin/graphics/shaders/MultiplyShader.hx @@ -0,0 +1,31 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; + +class MultiplyShader extends FlxShader +{ + @:glFragmentSource(' + #pragma header + uniform sampler2D funnyImage; + uniform vec4 uBlendColor; + + vec4 blendMultiply(vec4 base, vec4 blend) + { + return base * blend; + } + + vec4 blendMultiply(vec4 base, vec4 blend, float opacity) + { + return (blendMultiply(base, blend) * opacity + base * (1.0 - opacity)); + } + + void main() + { + vec4 base = texture2D(bitmap, openfl_TextureCoordv); + gl_FragColor = blendMultiply(base, uBlendColor, uBlendColor.a); + }') + public function new() + { + super(); + } +} diff --git a/source/funkin/graphics/shaders/OverlayBlend.hx b/source/funkin/graphics/shaders/OverlayBlend.hx new file mode 100644 index 00000000..e44f3152 --- /dev/null +++ b/source/funkin/graphics/shaders/OverlayBlend.hx @@ -0,0 +1,69 @@ +package funkin.graphics.shaders; + +import flixel.math.FlxPoint; +import flixel.system.FlxAssets.FlxShader; + +@:keep +class OverlayBlend extends FlxShader +{ + // these r copypaste + public var funnyX(default, set):Float = 0; + public var funnyY(default, set):Float = 0; + + function set_funnyX(x:Float):Float + { + xPos.value[0] = x; + + return x; + } + + function set_funnyY(y:Float):Float + { + yPos.value[0] = y; + + return y; + } + + @:glFragmentSource(' + #pragma header + + uniform float alphaShit; + uniform float yPos; + uniform float xPos; + + uniform sampler2D funnyShit; + + + vec4 blendOverlay(vec4 base, vec4 blend) + { + vec4 mixed = mix(1.0 - 2.0 * (1.0 - base) * (1.0 - blend), 2.0 * base * blend, step(base, vec4(0.5))); + + // mixed = mix(mixed, blend, base.a); // proper alpha mixing? + + return mixed; + } + + void main() + { + vec2 funnyUv = openfl_TextureCoordv; + vec4 color = flixel_texture2D(bitmap, funnyUv); + + vec2 reallyFunnyUv = vec2(vec2(0.0, 0.0) - gl_FragCoord.xy / openfl_TextureSize.xy); + + vec4 gf = flixel_texture2D(funnyShit, openfl_TextureCoordv.xy + vec2(0.1, 0.2)); + + + vec4 mixedCol = blendOverlay(color, gf); + + gl_FragColor = mixedCol; + } + + ') + public function new() + { + super(); + + xPos.value = [0]; + yPos.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/PuddleShader.hx b/source/funkin/graphics/shaders/PuddleShader.hx new file mode 100644 index 00000000..35256873 --- /dev/null +++ b/source/funkin/graphics/shaders/PuddleShader.hx @@ -0,0 +1,12 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; +import openfl.Assets; + +class PuddleShader extends FlxRuntimeShader +{ + public function new() + { + super(Assets.getText(Paths.frag('puddle'))); + } +} diff --git a/source/funkin/graphics/shaders/PureColor.hx b/source/funkin/graphics/shaders/PureColor.hx new file mode 100644 index 00000000..1d2216a8 --- /dev/null +++ b/source/funkin/graphics/shaders/PureColor.hx @@ -0,0 +1,48 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; + +class PureColor extends FlxShader +{ + public var col(default, set):FlxColor; + public var colorSet(default, set):Bool; + + function set_colorSet(bol:Bool):Bool + { + colSet.value = [bol]; + + return bol; + } + + function set_col(val:FlxColor):FlxColor + { + funnyColor.value = [val.red, val.green, val.blue, val.alpha]; + + return val; + } + + @:glFragmentSource(' + #pragma header + + uniform vec4 funnyColor; + uniform bool colSet; + + void main() + { + vec4 color = flixel_texture2D(bitmap, openfl_TextureCoordv); + + if (color.a > 0.0 && colSet) + color = vec4(funnyColor.r, funnyColor.g, funnyColor.b, color.a); + + gl_FragColor = color; + } + ') + public function new(colr:FlxColor) + { + super(); + + this.col = colr; + this.colorSet = false; + } +} diff --git a/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx b/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx new file mode 100644 index 00000000..74ce7f4e --- /dev/null +++ b/source/funkin/graphics/shaders/RuntimeCustomBlendShader.hx @@ -0,0 +1,30 @@ +package funkin.graphics.shaders; + +import openfl.display.BitmapData; +import openfl.display.BlendMode; + +class RuntimeCustomBlendShader extends RuntimePostEffectShader +{ + // only different name purely for hashlink fix + public var sourceSwag(default, set):BitmapData; + + function set_sourceSwag(value:BitmapData):BitmapData + { + this.setBitmapData("sourceSwag", value); + return sourceSwag = value; + } + + // name change make sure it's not the same variable name as whatever is in the shader file + public var blendSwag(default, set):BlendMode; + + function set_blendSwag(value:BlendMode):BlendMode + { + this.setInt("blendMode", cast value); + return blendSwag = value; + } + + public function new() + { + super(Assets.getText("assets/shaders/customBlend.frag")); + } +} diff --git a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx new file mode 100644 index 00000000..d39f57ef --- /dev/null +++ b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx @@ -0,0 +1,136 @@ +package funkin.graphics.shaders; + +import flixel.FlxCamera; +import flixel.FlxG; +import flixel.graphics.frames.FlxFrame; +import flixel.addons.display.FlxRuntimeShader; +import lime.graphics.opengl.GLProgram; +import lime.utils.Log; + +class RuntimePostEffectShader extends FlxRuntimeShader +{ + @:glVertexHeader(' + // normalized screen coord + // (0, 0) is the top left of the window + // (1, 1) is the bottom right of the window + varying vec2 screenCoord; + ', true) + @:glVertexBody(' + screenCoord = vec2( + openfl_TextureCoord.x > 0.0 ? 1.0 : 0.0, + openfl_TextureCoord.y > 0.0 ? 1.0 : 0.0 + ); + ') + @:glFragmentHeader(' + // normalized screen coord + // (0, 0) is the top left of the window + // (1, 1) is the bottom right of the window + varying vec2 screenCoord; + + // equals (FlxG.width, FlxG.height) + uniform vec2 uScreenResolution; + + // equals (camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom) + uniform vec4 uCameraBounds; + + // equals (frame.left, frame.top, frame.right, frame.bottom) + uniform vec4 uFrameBounds; + + // screen coord -> world coord conversion + // returns world coord in px + vec2 screenToWorld(vec2 screenCoord) { + float left = uCameraBounds.x; + float top = uCameraBounds.y; + float right = uCameraBounds.z; + float bottom = uCameraBounds.w; + vec2 scale = vec2(right - left, bottom - top); + vec2 offset = vec2(left, top); + return screenCoord * scale + offset; + } + + // world coord -> screen coord conversion + // returns normalized screen coord + vec2 worldToScreen(vec2 worldCoord) { + float left = uCameraBounds.x; + float top = uCameraBounds.y; + float right = uCameraBounds.z; + float bottom = uCameraBounds.w; + vec2 scale = vec2(right - left, bottom - top); + vec2 offset = vec2(left, top); + return (worldCoord - offset) / scale; + } + + // screen coord -> frame coord conversion + // returns normalized frame coord + vec2 screenToFrame(vec2 screenCoord) { + float left = uFrameBounds.x; + float top = uFrameBounds.y; + float right = uFrameBounds.z; + float bottom = uFrameBounds.w; + float width = right - left; + float height = bottom - top; + + float clampedX = clamp(screenCoord.x, left, right); + float clampedY = clamp(screenCoord.y, top, bottom); + + return vec2( + (clampedX - left) / (width), + (clampedY - top) / (height) + ); + } + + // internally used to get the maximum `openfl_TextureCoordv` + vec2 bitmapCoordScale() { + return openfl_TextureCoordv / screenCoord; + } + + // internally used to compute bitmap coord + vec2 screenToBitmap(vec2 screenCoord) { + return screenCoord * bitmapCoordScale(); + } + + // samples the frame buffer using a screen coord + vec4 sampleBitmapScreen(vec2 screenCoord) { + return texture2D(bitmap, screenToBitmap(screenCoord)); + } + + // samples the frame buffer using a world coord + vec4 sampleBitmapWorld(vec2 worldCoord) { + return sampleBitmapScreen(worldToScreen(worldCoord)); + } + ', true) + public function new(fragmentSource:String = null, glVersion:String = null) + { + super(fragmentSource, null, glVersion); + uScreenResolution.value = [FlxG.width, FlxG.height]; + uCameraBounds.value = [0, 0, FlxG.width, FlxG.height]; + uFrameBounds.value = [0, 0, FlxG.width, FlxG.height]; + } + + // basically `updateViewInfo(FlxG.width, FlxG.height, FlxG.camera)` is good + public function updateViewInfo(screenWidth:Float, screenHeight:Float, camera:FlxCamera):Void + { + uScreenResolution.value = [screenWidth, screenHeight]; + uCameraBounds.value = [camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom]; + } + + public function updateFrameInfo(frame:FlxFrame) + { + // NOTE: uv.width is actually the right pos and uv.height is the bottom pos + uFrameBounds.value = [frame.uv.x, frame.uv.y, frame.uv.width, frame.uv.height]; + } + + override function __createGLProgram(vertexSource:String, fragmentSource:String):GLProgram + { + try + { + final res = super.__createGLProgram(vertexSource, fragmentSource); + return res; + } + catch (error) + { + Log.warn(error); // prevent the app from dying immediately + return null; + } + } +} diff --git a/source/funkin/graphics/shaders/RuntimeRainShader.hx b/source/funkin/graphics/shaders/RuntimeRainShader.hx new file mode 100644 index 00000000..aab6807f --- /dev/null +++ b/source/funkin/graphics/shaders/RuntimeRainShader.hx @@ -0,0 +1,160 @@ +package funkin.graphics.shaders; + +import openfl.display.BitmapData; +import openfl.display.ShaderParameter; +import openfl.display.ShaderParameterType; +import flixel.util.FlxColor; + +typedef Light = +{ + var position:Array; + var color:Array; + var radius:Float; +} + +class RuntimeRainShader extends RuntimePostEffectShader +{ + static final MAX_LIGHTS:Int = 8; + + public var lights:Array< + { + position:ShaderParameter, + color:ShaderParameter, + radius:ShaderParameter, + }>; + + public var time(default, set):Float = 1; + + function set_time(value:Float):Float + { + this.setFloat('uTime', value); + return time = value; + } + + public var spriteMode(default, set):Bool = false; + + function set_spriteMode(value:Bool):Bool + { + this.setBool('uSpriteMode', value); + return spriteMode = value; + } + + // The scale of the rain depends on the world coordinate system, so higher resolution makes + // the raindrops smaller. This parameter can be used to adjust the total scale of the scene. + // The size of the raindrops is proportional to the value of this parameter. + public var scale(default, set):Float = 1; + + function set_scale(value:Float):Float + { + this.setFloat('uScale', value); + return scale = value; + } + + // The intensity of the rain. Zero means no rain and one means the maximum amount of rain. + public var intensity(default, set):Float = 0.5; + + function set_intensity(value:Float):Float + { + this.setFloat('uIntensity', value); + return intensity = value; + } + + // the y coord of the puddle, used to mirror things + public var puddleY(default, set):Float = 0; + + function set_puddleY(value:Float):Float + { + this.setFloat('uPuddleY', value); + return puddleY = value; + } + + // the y scale of the puddle, the less this value the more the puddle effects squished + public var puddleScaleY(default, set):Float = 0; + + function set_puddleScaleY(value:Float):Float + { + this.setFloat('uPuddleScaleY', value); + return puddleScaleY = value; + } + + public var blurredScreen(default, set):BitmapData; + + function set_blurredScreen(value:BitmapData):BitmapData + { + this.setBitmapData('uBlurredScreen', value); + return blurredScreen = value; + } + + public var mask(default, set):BitmapData; + + function set_mask(value:BitmapData):BitmapData + { + this.setBitmapData('uMask', value); + return mask = value; + } + + public var rainColor(default, set):FlxColor; + + function set_rainColor(color:FlxColor):FlxColor + { + this.setFloatArray("uRainColor", [color.red / 255, color.green / 255, color.blue / 255]); + return rainColor = color; + } + + public var lightMap(default, set):BitmapData; + + function set_lightMap(value:BitmapData):BitmapData + { + this.setBitmapData('uLightMap', value); + return lightMap = value; + } + + public var numLightsSwag(default, set):Int = 0; // swag heads, we have never been more back (needs different name purely for hashlink casting fix) + + function set_numLightsSwag(value:Int):Int + { + this.setInt('numLights', value); + return numLightsSwag = value; + } + + public function new() + { + super(Assets.getText(Paths.frag('rain'))); + this.rainColor = 0xFF6680cc; + } + + public function update(elapsed:Float):Void + { + time += elapsed; + } + + override function __processGLData(source:String, storageType:String):Void + { + super.__processGLData(source, storageType); + if (storageType == 'uniform') + { + lights = [ + for (i in 0...MAX_LIGHTS) + { + position: addFloatUniform('lights[$i].position', 2), + color: addFloatUniform('lights[$i].color', 3), + radius: addFloatUniform('lights[$i].radius', 1), + } + ]; + } + } + + @:access(openfl.display.ShaderParameter) + function addFloatUniform(name:String, length:Int):ShaderParameter + { + final res = new ShaderParameter(); + res.name = name; + res.type = [null, FLOAT, FLOAT2, FLOAT3, FLOAT4][length]; + res.__arrayLength = 1; + res.__isFloat = true; + res.__isUniform = true; + res.__length = length; + __paramFloat.push(res); + return res; + } +} diff --git a/source/funkin/graphics/shaders/ScreenWipeShader.hx b/source/funkin/graphics/shaders/ScreenWipeShader.hx new file mode 100644 index 00000000..bc45f0ef --- /dev/null +++ b/source/funkin/graphics/shaders/ScreenWipeShader.hx @@ -0,0 +1,72 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; + +class ScreenWipeShader extends FlxShader +{ + public var daAlphaShit(default, set):Float = 0; + + function set_daAlphaShit(alpha:Float):Float + { + alphaShit.value[0] = alpha; + + return alpha; + } + + @:glFragmentSource(' + #pragma header + + uniform float alphaShit; + uniform float yPos; + uniform float xPos; + + uniform sampler2D funnyShit; + + + vec3 rgb2hsv(vec3 c) + { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); + } + + vec3 hsv2rgb(vec3 c) + { + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); + } + + void main() + { + vec2 funnyUv = openfl_TextureCoordv; + vec4 color = flixel_texture2D(bitmap, funnyUv); + + vec2 reallyFunnyUv = vec2(vec2(0.0, 0.0) - gl_FragCoord.xy / openfl_TextureSize.xy); + + vec4 gf = flixel_texture2D(funnyShit, openfl_TextureCoordv); + + + vec3 hsvTypeBeat = rgb2hsv(vec3(gf.r, gf.g, gf.b)); + + vec4 output = color; + + // .b here actually means value? + if (hsvTypeBeat.b <= alphaShit) + color = vec4(0.0, 0.0, 0.0, 0.0); + + + gl_FragColor = color; + } + ') + public function new() + { + super(); + + alphaShit.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/StrokeShader.hx b/source/funkin/graphics/shaders/StrokeShader.hx new file mode 100644 index 00000000..d110e7de --- /dev/null +++ b/source/funkin/graphics/shaders/StrokeShader.hx @@ -0,0 +1,65 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxColor; + +class StrokeShader extends FlxShader +{ + // MOSTLY STOLEN FROM AUSTIN EAST LOL! + // https://gist.github.com/AustinEast/d3892fdf6a6079366fffde071f0c2bae + public var width(default, set):Float = 0; + public var height(default, set):Float = 0; + + public var col(default, set):FlxColor = 0xFFFFFFFF; + + function set_width(val):Float + { + size.value = [val, height]; + + return val; + } + + function set_height(val):Float + { + size.value = [width, val]; + return val; + } + + function set_col(val:FlxColor):FlxColor + { + color.value = [val.red, val.green, val.blue, val.alpha]; + + return val; + } + + @:glFragmentSource(' + #pragma header + + uniform vec2 size; + uniform vec4 color; + + void main() + { + vec4 gay = flixel_texture2D(bitmap, openfl_TextureCoordv); + if (gay.a == 0.) { + float w = size.x / openfl_TextureSize.x; + float h = size.y / openfl_TextureSize.y; + + if (flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x + w, openfl_TextureCoordv.y)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x - w, openfl_TextureCoordv.y)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x, openfl_TextureCoordv.y + h)).a != 0. + || flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x, openfl_TextureCoordv.y - h)).a != 0.) + gay = color; + } + gl_FragColor = gay; + } + ') + public function new(color:FlxColor = 0xFFFFFFFF, width:Float = 1, height:Float = 1) + { + super(); + + col = color; + this.width = width; + this.height = height; + } +} diff --git a/source/funkin/graphics/shaders/TextureSwap.hx b/source/funkin/graphics/shaders/TextureSwap.hx new file mode 100644 index 00000000..e5f5ea8d --- /dev/null +++ b/source/funkin/graphics/shaders/TextureSwap.hx @@ -0,0 +1,58 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; +import openfl.display.BitmapData; + +class TextureSwap extends FlxShader +{ + public var swappedImage(default, set):BitmapData; + public var amount(default, set):Float; + + public function loadSwapImage(path:String) + { + #if html5 + BitmapData.loadFromFile(path).onComplete(function(bmp:BitmapData) { + swappedImage = bmp; + }); + #else + swappedImage = BitmapData.fromFile(path); + #end + } + + function set_swappedImage(_bitmapData:BitmapData):BitmapData + { + image.input = _bitmapData; + + return _bitmapData; + } + + function set_amount(val:Float):Float + { + fadeAmount.value = [val]; + + return val; + } + + @:glFragmentSource(' + #pragma header + + uniform sampler2D image; + uniform float fadeAmount; + + void main() + { + vec4 tex = flixel_texture2D(bitmap, openfl_TextureCoordv); + vec4 tex2 = flixel_texture2D(image, openfl_TextureCoordv); + + vec4 finalColor = mix(tex, vec4(tex2.rgb, tex.a), fadeAmount); + + gl_FragColor = finalColor; + } + ') + public function new() + { + super(); + + this.amount = 1; + } +} diff --git a/source/funkin/graphics/shaders/TitleOutline.hx b/source/funkin/graphics/shaders/TitleOutline.hx new file mode 100644 index 00000000..ad688a50 --- /dev/null +++ b/source/funkin/graphics/shaders/TitleOutline.hx @@ -0,0 +1,73 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; + +class TitleOutline extends FlxShader +{ + public var funnyX(default, set):Float = 0; + public var funnyY(default, set):Float = 0; + + function set_funnyX(x:Float):Float + { + xPos.value[0] = x; + + return x; + } + + function set_funnyY(y:Float):Float + { + yPos.value[0] = y; + + return y; + } + + @:glFragmentSource(' + #pragma header + + // uniform float alphaShit; + uniform float xPos; + uniform float yPos; + + uniform int numoutlines = 1; + + vec3 rgb2hsv(vec3 c) + { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); + } + + void main() + { + vec4 color = flixel_texture2D(bitmap, openfl_TextureCoordv); + vec2 size = vec2(xPos, yPos); + + if (color.a == 0.0) { + float w = size.x / openfl_TextureSize.x; + float h = size.y / openfl_TextureSize.y; + + vec4 colorOffset = flixel_texture2D(bitmap, vec2(openfl_TextureCoordv.x - w, openfl_TextureCoordv.y - h)); + + + vec3 hsvShit = rgb2hsv(vec3(colorOffset.r, colorOffset.g, colorOffset.b)); + + if (hsvShit.b <= 0.1 && colorOffset.a != 0.) + color = vec4(0.0, 1.0, 0.8, color.a); + } + + gl_FragColor = color; + } + + ') + public function new() + { + super(); + + xPos.value = [0]; + yPos.value = [0]; + } +} diff --git a/source/funkin/graphics/shaders/WaveShader.hx b/source/funkin/graphics/shaders/WaveShader.hx new file mode 100644 index 00000000..8738cc40 --- /dev/null +++ b/source/funkin/graphics/shaders/WaveShader.hx @@ -0,0 +1,22 @@ +package funkin.graphics.shaders; + +import flixel.system.FlxAssets.FlxShader; + +class WaveShader extends FlxShader +{ + @:glFragmentSource(' + #pragma header + + void main() + { + vec4 color = flixel_texture2D(bitmap, openfl_TextureCoordv); + + gl_FragColor = color; + } + + ') + public function new() + { + super(); + } +} diff --git a/source/funkin/graphics/shaders/WiggleEffectRuntime.hx b/source/funkin/graphics/shaders/WiggleEffectRuntime.hx new file mode 100644 index 00000000..a04941df --- /dev/null +++ b/source/funkin/graphics/shaders/WiggleEffectRuntime.hx @@ -0,0 +1,84 @@ +package funkin.graphics.shaders; + +import flixel.addons.display.FlxRuntimeShader; +import openfl.Assets; + +enum WiggleEffectType +{ + DREAMY; // 0 + WAVY; // 1 + HEAT_WAVE_HORIZONTAL; // 2 + HEAT_WAVE_VERTICAL; // 3 + FLAG; // 4 +} + +/** + * To use: + * 1. Create an instance of the class, specifying speed, frequency, and amplitude. + * 2. Call `sprite.shader = wiggleEffect` on the target sprite. + * 3. Call the update() method on the instance every frame. + */ +class WiggleEffectRuntime extends FlxRuntimeShader +{ + public static function getEffectTypeId(v:WiggleEffectType):Int + { + return WiggleEffectType.getConstructors().indexOf(Std.string(v)); + } + + public var effectType(default, set):WiggleEffectType = DREAMY; + + function set_effectType(v:WiggleEffectType):WiggleEffectType + { + this.setInt('effectType', getEffectTypeId(v)); + return effectType = v; + } + + public var waveSpeed(default, set):Float = 0; + + function set_waveSpeed(v:Float):Float + { + this.setFloat('uSpeed', v); + return waveSpeed = v; + } + + public var waveFrequency(default, set):Float = 0; + + function set_waveFrequency(v:Float):Float + { + this.setFloat('uFrequency', v); + return waveFrequency = v; + } + + public var waveAmplitude(default, set):Float = 0; + + function set_waveAmplitude(v:Float):Float + { + this.setFloat('uWaveAmplitude', v); + return waveAmplitude = v; + } + + var time(default, set):Float = 0; + + function set_time(v:Float):Float + { + this.setFloat('uTime', v); + return time = v; + } + + public function new(speed:Float, freq:Float, amplitude:Float, ?effect:WiggleEffectType = DREAMY):Void + { + super(Assets.getText(Paths.frag('wiggle'))); + + // These values may not propagate to the shader until later. + this.waveSpeed = speed; + this.waveFrequency = freq; + this.waveAmplitude = amplitude; + this.effectType = effect; + } + + public function update(elapsed:Float) + { + // The setter tied to this value automatically propagates the value to the shader. + this.time += elapsed; + } +} diff --git a/source/funkin/graphics/video/FlxVideo.hx b/source/funkin/graphics/video/FlxVideo.hx new file mode 100644 index 00000000..7efd72ca --- /dev/null +++ b/source/funkin/graphics/video/FlxVideo.hx @@ -0,0 +1,162 @@ +package funkin.graphics.video; + +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import openfl.events.NetStatusEvent; +import openfl.media.SoundTransform; +import openfl.media.Video; +import openfl.net.NetConnection; +import openfl.net.NetStream; + +/** + * Plays a video via a NetStream. Only works on HTML5. + * This does NOT replace hxvlc, nor does hxvlc replace this. + * hxvlc only works on desktop and does not work on HTML5! + */ +class FlxVideo extends FunkinSprite +{ + var video:Video; + var netStream:NetStream; + var videoPath:String; + + /** + * A callback to execute when the video finishes. + */ + public var finishCallback:Void->Void; + + public function new(videoPath:String) + { + super(); + + this.videoPath = videoPath; + + makeGraphic(2, 2, FlxColor.TRANSPARENT); + + video = new Video(); + video.x = 0; + video.y = 0; + video.alpha = 0; + + FlxG.game.addChild(video); + + var netConnection:NetConnection = new NetConnection(); + netConnection.connect(null); + + netStream = new NetStream(netConnection); + netStream.client = {onMetaData: onClientMetaData}; + netConnection.addEventListener(NetStatusEvent.NET_STATUS, onNetConnectionNetStatus); + netStream.play(videoPath); + } + + /** + * Tell the FlxVideo to pause playback. + */ + public function pauseVideo():Void + { + if (netStream != null) + { + netStream.pause(); + } + } + + /** + * Tell the FlxVideo to resume if it is paused. + */ + public function resumeVideo():Void + { + // Resume playing the video. + if (netStream != null) + { + netStream.resume(); + } + } + + var videoAvailable:Bool = false; + var frameTimer:Float; + + static final FRAME_RATE:Float = 60; + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (frameTimer >= (1 / FRAME_RATE)) + { + frameTimer = 0; + // TODO: We just draw the video buffer to the sprite 60 times a second. + // Can we copy the video buffer instead somehow? + pixels.draw(video); + } + + if (videoAvailable) frameTimer += elapsed; + } + + /** + * Tell the FlxVideo to seek to the beginning. + */ + public function restartVideo():Void + { + // Seek to the beginning of the video. + if (netStream != null) + { + netStream.seek(0); + } + } + + /** + * Tell the FlxVideo to end. + */ + public function finishVideo():Void + { + netStream.dispose(); + FlxG.removeChild(video); + + if (finishCallback != null) finishCallback(); + } + + public override function destroy():Void + { + if (netStream != null) + { + netStream.dispose(); + + if (FlxG.game.contains(video)) FlxG.game.removeChild(video); + } + + super.destroy(); + } + + /** + * Callback executed when the video stream loads. + * @param metaData The metadata of the video + */ + public function onClientMetaData(metaData:Dynamic):Void + { + video.attachNetStream(netStream); + + onVideoReady(); + } + + function onVideoReady():Void + { + video.width = FlxG.width; + video.height = FlxG.height; + + videoAvailable = true; + + FunkinSound.onVolumeChanged.add(onVolumeChanged); + onVolumeChanged(FlxG.sound.muted ? 0 : FlxG.sound.volume); + + makeGraphic(Std.int(video.width), Std.int(video.height), FlxColor.TRANSPARENT); + } + + function onVolumeChanged(volume:Float):Void + { + netStream.soundTransform = new SoundTransform(volume); + } + + function onNetConnectionNetStatus(event:NetStatusEvent):Void + { + if (event.info.code == 'NetStream.Play.Complete') finishVideo(); + } +} diff --git a/source/funkin/graphics/video/FunkinVideoSprite.hx b/source/funkin/graphics/video/FunkinVideoSprite.hx new file mode 100644 index 00000000..6e4eb880 --- /dev/null +++ b/source/funkin/graphics/video/FunkinVideoSprite.hx @@ -0,0 +1,17 @@ +package funkin.graphics.video; + +#if hxvlc +import hxvlc.flixel.FlxVideoSprite; + +/** + * Not to be confused with FlxVideo, this is a hxvlc based video class + * We override it simply to correct/control our volume easier. + */ +class FunkinVideoSprite extends FlxVideoSprite +{ + public function new(x:Float = 0, y:Float = 0) + { + super(x, y); + } +} +#end diff --git a/source/funkin/import.hx b/source/funkin/import.hx new file mode 100644 index 00000000..a29e1997 --- /dev/null +++ b/source/funkin/import.hx @@ -0,0 +1,26 @@ +package; + +#if !macro +// Only import these when we aren't in a macro. +import funkin.util.Constants; +import funkin.Assets; +import funkin.Paths; +import funkin.Preferences; +import flixel.FlxG; // This one in particular causes a compile error if you're using macros. +import flixel.system.debug.watch.Tracker; + +// These are great. +using Lambda; +using StringTools; +using thx.Arrays; +using funkin.util.tools.ArraySortTools; +using funkin.util.tools.ArrayTools; +using funkin.util.tools.FloatTools; +using funkin.util.tools.Int64Tools; +using funkin.util.tools.IntTools; +using funkin.util.tools.IteratorTools; +using funkin.util.tools.MapTools; +using funkin.util.tools.SongEventDataArrayTools; +using funkin.util.tools.SongNoteDataArrayTools; +using funkin.util.tools.StringTools; +#end diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx new file mode 100644 index 00000000..9a15c743 --- /dev/null +++ b/source/funkin/input/Controls.hx @@ -0,0 +1,1744 @@ +package funkin.input; + +import flixel.input.gamepad.FlxGamepad; +import flixel.util.FlxDirectionFlags; +import flixel.input.FlxInput.FlxInputState; +import flixel.input.actions.FlxAction; +import flixel.input.actions.FlxActionInput; +import flixel.input.actions.FlxActionInputDigital; +import flixel.input.actions.FlxActionManager; +import flixel.input.actions.FlxActionSet; +import flixel.input.android.FlxAndroidKey; +import flixel.input.gamepad.FlxGamepadInputID; +import flixel.input.keyboard.FlxKey; +import flixel.math.FlxAngle; +import flixel.math.FlxPoint; +import lime.ui.Haptic; + +/** + * A core class which handles receiving player input and interpreting it into game actions. + */ +class Controls extends FlxActionSet +{ + /** + * A list of actions that a player would invoke via some input device. + * Uses FlxActions to funnel various inputs to a single action. + */ + var _ui_up = new FunkinAction(Action.UI_UP); + + var _ui_left = new FunkinAction(Action.UI_LEFT); + var _ui_right = new FunkinAction(Action.UI_RIGHT); + var _ui_down = new FunkinAction(Action.UI_DOWN); + var _ui_upP = new FunkinAction(Action.UI_UP_P); + var _ui_leftP = new FunkinAction(Action.UI_LEFT_P); + var _ui_rightP = new FunkinAction(Action.UI_RIGHT_P); + var _ui_downP = new FunkinAction(Action.UI_DOWN_P); + var _ui_upR = new FunkinAction(Action.UI_UP_R); + var _ui_leftR = new FunkinAction(Action.UI_LEFT_R); + var _ui_rightR = new FunkinAction(Action.UI_RIGHT_R); + var _ui_downR = new FunkinAction(Action.UI_DOWN_R); + var _note_up = new FunkinAction(Action.NOTE_UP); + var _note_left = new FunkinAction(Action.NOTE_LEFT); + var _note_right = new FunkinAction(Action.NOTE_RIGHT); + var _note_down = new FunkinAction(Action.NOTE_DOWN); + var _note_upP = new FunkinAction(Action.NOTE_UP_P); + var _note_leftP = new FunkinAction(Action.NOTE_LEFT_P); + var _note_rightP = new FunkinAction(Action.NOTE_RIGHT_P); + var _note_downP = new FunkinAction(Action.NOTE_DOWN_P); + var _note_upR = new FunkinAction(Action.NOTE_UP_R); + var _note_leftR = new FunkinAction(Action.NOTE_LEFT_R); + var _note_rightR = new FunkinAction(Action.NOTE_RIGHT_R); + var _note_downR = new FunkinAction(Action.NOTE_DOWN_R); + var _accept = new FunkinAction(Action.ACCEPT); + var _back = new FunkinAction(Action.BACK); + var _pause = new FunkinAction(Action.PAUSE); + var _reset = new FunkinAction(Action.RESET); + #if FEATURE_SCREENSHOTS + var _window_screenshot = new FunkinAction(Action.WINDOW_SCREENSHOT); + #end + var _window_fullscreen = new FunkinAction(Action.WINDOW_FULLSCREEN); + var _freeplay_favorite = new FunkinAction(Action.FREEPLAY_FAVORITE); + var _freeplay_left = new FunkinAction(Action.FREEPLAY_LEFT); + var _freeplay_right = new FunkinAction(Action.FREEPLAY_RIGHT); + var _freeplay_char_select = new FunkinAction(Action.FREEPLAY_CHAR_SELECT); + var _freeplay_jump_to_top = new FunkinAction(Action.FREEPLAY_JUMP_TO_TOP); + var _freeplay_jump_to_bottom = new FunkinAction(Action.FREEPLAY_JUMP_TO_BOTTOM); + var _cutscene_advance = new FunkinAction(Action.CUTSCENE_ADVANCE); + var _debug_menu = new FunkinAction(Action.DEBUG_MENU); + #if FEATURE_CHART_EDITOR + var _debug_chart = new FunkinAction(Action.DEBUG_CHART); + #end + #if FEATURE_STAGE_EDITOR + var _debug_stage = new FunkinAction(Action.DEBUG_STAGE); + #end + var _volume_up = new FunkinAction(Action.VOLUME_UP); + var _volume_down = new FunkinAction(Action.VOLUME_DOWN); + var _volume_mute = new FunkinAction(Action.VOLUME_MUTE); + + var byName:Map = new Map(); + + public var gamepadsAdded:Array = []; + public var keyboardScheme = KeyboardScheme.None; + + public var UI_UP(get, never):Bool; + + inline function get_UI_UP() + return _ui_up.checkPressed(); + + public var UI_LEFT(get, never):Bool; + + inline function get_UI_LEFT() + return _ui_left.checkPressed(); + + public var UI_RIGHT(get, never):Bool; + + inline function get_UI_RIGHT() + return _ui_right.checkPressed(); + + public var UI_DOWN(get, never):Bool; + + inline function get_UI_DOWN() + return _ui_down.checkPressed(); + + public var UI_UP_P(get, never):Bool; + + inline function get_UI_UP_P() + return _ui_up.checkJustPressed(); + + public var UI_LEFT_P(get, never):Bool; + + inline function get_UI_LEFT_P() + return _ui_left.checkJustPressed(); + + public var UI_RIGHT_P(get, never):Bool; + + inline function get_UI_RIGHT_P() + return _ui_right.checkJustPressed(); + + public var UI_DOWN_P(get, never):Bool; + + inline function get_UI_DOWN_P() + return _ui_down.checkJustPressed(); + + public var UI_UP_R(get, never):Bool; + + inline function get_UI_UP_R() + return _ui_up.checkJustReleased(); + + public var UI_LEFT_R(get, never):Bool; + + inline function get_UI_LEFT_R() + return _ui_left.checkJustReleased(); + + public var UI_RIGHT_R(get, never):Bool; + + inline function get_UI_RIGHT_R() + return _ui_right.checkJustReleased(); + + public var UI_DOWN_R(get, never):Bool; + + inline function get_UI_DOWN_R() + return _ui_down.checkJustReleased(); + + public var UI_UP_GAMEPAD(get, never):Bool; + + inline function get_UI_UP_GAMEPAD() + return _ui_up.checkPressedGamepad(); + + public var UI_LEFT_GAMEPAD(get, never):Bool; + + inline function get_UI_LEFT_GAMEPAD() + return _ui_left.checkPressedGamepad(); + + public var UI_RIGHT_GAMEPAD(get, never):Bool; + + inline function get_UI_RIGHT_GAMEPAD() + return _ui_right.checkPressedGamepad(); + + public var UI_DOWN_GAMEPAD(get, never):Bool; + + inline function get_UI_DOWN_GAMEPAD() + return _ui_down.checkPressedGamepad(); + + public var NOTE_UP(get, never):Bool; + + inline function get_NOTE_UP() + return _note_up.checkPressed(); + + public var NOTE_LEFT(get, never):Bool; + + inline function get_NOTE_LEFT() + return _note_left.checkPressed(); + + public var NOTE_RIGHT(get, never):Bool; + + inline function get_NOTE_RIGHT() + return _note_right.checkPressed(); + + public var NOTE_DOWN(get, never):Bool; + + inline function get_NOTE_DOWN() + return _note_down.checkPressed(); + + public var NOTE_UP_P(get, never):Bool; + + inline function get_NOTE_UP_P() + return _note_up.checkJustPressed(); + + public var NOTE_LEFT_P(get, never):Bool; + + inline function get_NOTE_LEFT_P() + return _note_left.checkJustPressed(); + + public var NOTE_RIGHT_P(get, never):Bool; + + inline function get_NOTE_RIGHT_P() + return _note_right.checkJustPressed(); + + public var NOTE_DOWN_P(get, never):Bool; + + inline function get_NOTE_DOWN_P() + return _note_down.checkJustPressed(); + + public var NOTE_UP_R(get, never):Bool; + + inline function get_NOTE_UP_R() + return _note_up.checkJustReleased(); + + public var NOTE_LEFT_R(get, never):Bool; + + inline function get_NOTE_LEFT_R() + return _note_left.checkJustReleased(); + + public var NOTE_RIGHT_R(get, never):Bool; + + inline function get_NOTE_RIGHT_R() + return _note_right.checkJustReleased(); + + public var NOTE_DOWN_R(get, never):Bool; + + inline function get_NOTE_DOWN_R() + return _note_down.checkJustReleased(); + + public var ACCEPT(get, never):Bool; + + inline function get_ACCEPT() + return _accept.check(); + + public var BACK(get, never):Bool; + + inline function get_BACK() + return _back.check(); + + public var PAUSE(get, never):Bool; + + inline function get_PAUSE() + return _pause.check(); + + public var RESET(get, never):Bool; + + inline function get_RESET() + return _reset.check(); + + public var WINDOW_FULLSCREEN(get, never):Bool; + + inline function get_WINDOW_FULLSCREEN() + return _window_fullscreen.check(); + + #if FEATURE_SCREENSHOTS + public var WINDOW_SCREENSHOT(get, never):Bool; + + inline function get_WINDOW_SCREENSHOT() + return _window_screenshot.check(); + #end + + public var FREEPLAY_FAVORITE(get, never):Bool; + + inline function get_FREEPLAY_FAVORITE() + return _freeplay_favorite.check(); + + public var FREEPLAY_LEFT(get, never):Bool; + + inline function get_FREEPLAY_LEFT() + return _freeplay_left.check(); + + public var FREEPLAY_RIGHT(get, never):Bool; + + inline function get_FREEPLAY_RIGHT() + return _freeplay_right.check(); + + public var FREEPLAY_CHAR_SELECT(get, never):Bool; + + inline function get_FREEPLAY_CHAR_SELECT() + return _freeplay_char_select.check(); + + public var FREEPLAY_JUMP_TO_TOP(get, never):Bool; + + inline function get_FREEPLAY_JUMP_TO_TOP() + return _freeplay_jump_to_top.check(); + + public var FREEPLAY_JUMP_TO_BOTTOM(get, never):Bool; + + inline function get_FREEPLAY_JUMP_TO_BOTTOM() + return _freeplay_jump_to_bottom.check(); + + public var CUTSCENE_ADVANCE(get, never):Bool; + + inline function get_CUTSCENE_ADVANCE() + return _cutscene_advance.check(); + + public var DEBUG_MENU(get, never):Bool; + + inline function get_DEBUG_MENU() + return _debug_menu.check(); + + #if FEATURE_CHART_EDITOR + public var DEBUG_CHART(get, never):Bool; + + inline function get_DEBUG_CHART() + return _debug_chart.check(); + #end + + #if FEATURE_STAGE_EDITOR + public var DEBUG_STAGE(get, never):Bool; + + inline function get_DEBUG_STAGE() + return _debug_stage.check(); + #end + + public var VOLUME_UP(get, never):Bool; + + inline function get_VOLUME_UP() + return _volume_up.check(); + + public var VOLUME_DOWN(get, never):Bool; + + inline function get_VOLUME_DOWN() + return _volume_down.check(); + + public var VOLUME_MUTE(get, never):Bool; + + inline function get_VOLUME_MUTE() + return _volume_mute.check(); + + public function new(name, scheme:KeyboardScheme = null) + { + super(name); + + add(_ui_up); + add(_ui_left); + add(_ui_right); + add(_ui_down); + add(_note_up); + add(_note_left); + add(_note_right); + add(_note_down); + add(_accept); + add(_back); + add(_pause); + add(_reset); + #if FEATURE_SCREENSHOTS add(_window_screenshot); #end + add(_window_fullscreen); + add(_freeplay_favorite); + add(_freeplay_left); + add(_freeplay_right); + add(_freeplay_char_select); + add(_freeplay_jump_to_top); + add(_freeplay_jump_to_bottom); + add(_cutscene_advance); + add(_debug_menu); + #if FEATURE_CHART_EDITOR add(_debug_chart); #end + #if FEATURE_STAGE_EDITOR add(_debug_stage); #end + add(_volume_up); + add(_volume_down); + add(_volume_mute); + + for (action in digitalActions) + { + if (Std.isOfType(action, FunkinAction)) + { + var funkinAction:FunkinAction = cast action; + byName[funkinAction.name] = funkinAction; + if (funkinAction.namePressed != null) byName[funkinAction.namePressed] = funkinAction; + if (funkinAction.nameReleased != null) byName[funkinAction.nameReleased] = funkinAction; + } + } + + if (scheme == null) scheme = None; + + setKeyboardScheme(scheme, false); + } + + override function update() + { + super.update(); + } + + public function check(name:Action, trigger:FlxInputState = JUST_PRESSED, gamepadOnly:Bool = false):Bool + { + #if FEATURE_DEBUG_FUNCTIONS + if (!byName.exists(name)) throw 'Invalid name: $name'; + #end + + var action = byName[name]; + if (gamepadOnly) return action.checkFiltered(trigger, GAMEPAD); + else + return action.checkFiltered(trigger); + } + + public function getKeysForAction(name:Action):Array + { + #if FEATURE_DEBUG_FUNCTIONS + if (!byName.exists(name)) throw 'Invalid name: $name'; + #end + + // TODO: Revert to `.map().filter()` once HashLink doesn't complain anymore. + var result:Array = []; + for (input in byName[name].inputs) + { + if (input.device == KEYBOARD) result.push(input.inputID); + } + return result; + } + + public function getButtonsForAction(name:Action):Array + { + #if FEATURE_DEBUG_FUNCTIONS + if (!byName.exists(name)) throw 'Invalid name: $name'; + #end + + var result:Array = []; + for (input in byName[name].inputs) + { + if (input.device == GAMEPAD) result.push(input.inputID); + } + return result; + } + + public function getDialogueName(action:FlxActionDigital, ?ignoreSurrounding:Bool = false):String + { + if (action.inputs.length == 0) return 'N/A'; + var input = action.inputs[0]; + if (ignoreSurrounding == false) + { + return switch (input.device) + { + case KEYBOARD: return '[${(input.inputID : FlxKey)}]'; + case GAMEPAD: return '(${(input.inputID : FlxGamepadInputID)})'; + case device: throw 'unhandled device: $device'; + } + } + else + { + return switch (input.device) + { + case KEYBOARD: return '${(input.inputID : FlxKey)}'; + case GAMEPAD: return '${(input.inputID : FlxGamepadInputID)}'; + case device: throw 'unhandled device: $device'; + } + } + } + + public function getDialogueNameFromToken(token:String, ?ignoreSurrounding:Bool = false):String + { + return getDialogueName(getActionFromControl(Control.createByName(token.toUpperCase())), ignoreSurrounding); + } + + public function getDialogueNameFromControl(control:Control, ?ignoreSurrounding:Bool = false):String + { + return getDialogueName(getActionFromControl(control), ignoreSurrounding); + } + + function getActionFromControl(control:Control):FlxActionDigital + { + return switch (control) + { + case UI_UP: _ui_up; + case UI_DOWN: _ui_down; + case UI_LEFT: _ui_left; + case UI_RIGHT: _ui_right; + case NOTE_UP: _note_up; + case NOTE_DOWN: _note_down; + case NOTE_LEFT: _note_left; + case NOTE_RIGHT: _note_right; + case ACCEPT: _accept; + case BACK: _back; + case PAUSE: _pause; + case RESET: _reset; + #if FEATURE_SCREENSHOTS case WINDOW_SCREENSHOT: _window_screenshot; #end + case WINDOW_FULLSCREEN: _window_fullscreen; + case FREEPLAY_FAVORITE: _freeplay_favorite; + case FREEPLAY_LEFT: _freeplay_left; + case FREEPLAY_RIGHT: _freeplay_right; + case FREEPLAY_CHAR_SELECT: _freeplay_char_select; + case FREEPLAY_JUMP_TO_TOP: _freeplay_jump_to_top; + case FREEPLAY_JUMP_TO_BOTTOM: _freeplay_jump_to_bottom; + case CUTSCENE_ADVANCE: _cutscene_advance; + case DEBUG_MENU: _debug_menu; + #if FEATURE_CHART_EDITOR case DEBUG_CHART: _debug_chart; #end + #if FEATURE_STAGE_EDITOR case DEBUG_STAGE: _debug_stage; #end + case VOLUME_UP: _volume_up; + case VOLUME_DOWN: _volume_down; + case VOLUME_MUTE: _volume_mute; + } + } + + static function init():Void + { + var actions = new FlxActionManager(); + FlxG.inputs.add(actions); + } + + /** + * Calls a function passing each action bound by the specified control + * @param control + * @param func + * @return ->Void) + */ + function forEachBound(control:Control, func:FlxActionDigital->FlxInputState->Void) + { + switch (control) + { + case UI_UP: + func(_ui_up, PRESSED); + func(_ui_up, JUST_PRESSED); + func(_ui_up, JUST_RELEASED); + case UI_LEFT: + func(_ui_left, PRESSED); + func(_ui_left, JUST_PRESSED); + func(_ui_left, JUST_RELEASED); + case UI_RIGHT: + func(_ui_right, PRESSED); + func(_ui_right, JUST_PRESSED); + func(_ui_right, JUST_RELEASED); + case UI_DOWN: + func(_ui_down, PRESSED); + func(_ui_down, JUST_PRESSED); + func(_ui_down, JUST_RELEASED); + case NOTE_UP: + func(_note_up, PRESSED); + func(_note_up, JUST_PRESSED); + func(_note_up, JUST_RELEASED); + case NOTE_LEFT: + func(_note_left, PRESSED); + func(_note_left, JUST_PRESSED); + func(_note_left, JUST_RELEASED); + case NOTE_RIGHT: + func(_note_right, PRESSED); + func(_note_right, JUST_PRESSED); + func(_note_right, JUST_RELEASED); + case NOTE_DOWN: + func(_note_down, PRESSED); + func(_note_down, JUST_PRESSED); + func(_note_down, JUST_RELEASED); + case ACCEPT: + func(_accept, JUST_PRESSED); + case BACK: + func(_back, JUST_PRESSED); + case PAUSE: + func(_pause, JUST_PRESSED); + case RESET: + func(_reset, JUST_PRESSED); + #if FEATURE_SCREENSHOTS + case WINDOW_SCREENSHOT: + func(_window_screenshot, JUST_PRESSED); + #end + case WINDOW_FULLSCREEN: + func(_window_fullscreen, JUST_PRESSED); + case FREEPLAY_FAVORITE: + func(_freeplay_favorite, JUST_PRESSED); + case FREEPLAY_LEFT: + func(_freeplay_left, JUST_PRESSED); + case FREEPLAY_RIGHT: + func(_freeplay_right, JUST_PRESSED); + case FREEPLAY_CHAR_SELECT: + func(_freeplay_char_select, JUST_PRESSED); + case FREEPLAY_JUMP_TO_TOP: + func(_freeplay_jump_to_top, JUST_PRESSED); + case FREEPLAY_JUMP_TO_BOTTOM: + func(_freeplay_jump_to_bottom, JUST_PRESSED); + case CUTSCENE_ADVANCE: + func(_cutscene_advance, JUST_PRESSED); + case DEBUG_MENU: + func(_debug_menu, JUST_PRESSED); + #if FEATURE_CHART_EDITOR + case DEBUG_CHART: + func(_debug_chart, JUST_PRESSED); + #end + #if FEATURE_STAGE_EDITOR + case DEBUG_STAGE: + func(_debug_stage, JUST_PRESSED); + #end + case VOLUME_UP: + func(_volume_up, JUST_PRESSED); + case VOLUME_DOWN: + func(_volume_down, JUST_PRESSED); + case VOLUME_MUTE: + func(_volume_mute, JUST_PRESSED); + } + } + + public function replaceBinding(control:Control, device:Device, toAdd:Int, toRemove:Int) + { + if (toAdd == toRemove) return; + + switch (device) + { + case Keys: + forEachBound(control, function(action, state) replaceKey(action, toAdd, toRemove, state)); + + case Gamepad(id): + forEachBound(control, function(action, state) replaceButton(action, id, toAdd, toRemove, state)); + } + } + + function replaceKey(action:FlxActionDigital, toAdd:FlxKey, toRemove:FlxKey, state:FlxInputState) + { + if (action.inputs.length == 0) + { + // Add the keybind, don't replace. + addKeys(action, [toAdd], state); + return; + } + + var hasReplaced:Bool = false; + for (i in 0...action.inputs.length) + { + var input = action.inputs[i]; + if (input == null) continue; + + if (input.device == KEYBOARD && input.inputID == toRemove) + { + if (toAdd == FlxKey.NONE) + { + // Remove the keybind, don't replace. + action.inputs.remove(input); + } + else + { + // Replace the keybind. + @:privateAccess + action.inputs[i].inputID = toAdd; + } + hasReplaced = true; + } + else if (input.device == KEYBOARD && input.inputID == toAdd) + { + // This key is already bound! + if (hasReplaced) + { + // Remove the duplicate keybind, don't replace. + action.inputs.remove(input); + } + else + { + hasReplaced = true; + } + } + } + + if (!hasReplaced) + { + addKeys(action, [toAdd], state); + } + } + + function replaceButton(action:FlxActionDigital, deviceID:Int, toAdd:FlxGamepadInputID, toRemove:FlxGamepadInputID, state:FlxInputState) + { + if (action.inputs.length == 0) + { + addButtons(action, [toAdd], state, deviceID); + return; + } + + var hasReplaced:Bool = false; + for (i in 0...action.inputs.length) + { + var input = action.inputs[i]; + if (input == null) continue; + + if (isGamepad(input, deviceID) && input.inputID == toRemove) + { + @:privateAccess + action.inputs[i].inputID = toAdd; + hasReplaced = true; + } + } + + if (!hasReplaced) + { + addButtons(action, [toAdd], state, deviceID); + } + } + + public function copyFrom(controls:Controls, ?device:Device) + { + for (name in controls.byName.keys()) + { + var action = controls.byName[name]; + for (input in action.inputs) + { + if (device == null || isDevice(input, device)) byName[name].add(cast input); + } + } + + switch (device) + { + case null: + // add all + for (gamepad in controls.gamepadsAdded) + if (gamepadsAdded.indexOf(gamepad) == -1) gamepadsAdded.push(gamepad); + + mergeKeyboardScheme(controls.keyboardScheme); + + case Gamepad(id): + gamepadsAdded.push(id); + case Keys: + mergeKeyboardScheme(controls.keyboardScheme); + } + } + + inline public function copyTo(controls:Controls, ?device:Device) + { + controls.copyFrom(this, device); + } + + function mergeKeyboardScheme(scheme:KeyboardScheme):Void + { + if (scheme != None) + { + switch (keyboardScheme) + { + case None: + keyboardScheme = scheme; + default: + keyboardScheme = Custom; + } + } + } + + /** + * Sets all actions that pertain to the binder to trigger when the supplied keys are used. + * If binder is a literal you can inline this + */ + public function bindKeys(control:Control, keys:Array) + { + forEachBound(control, function(action, state) addKeys(action, keys, state)); + } + + public function bindSwipe(control:Control, swipeDir:FlxDirectionFlags = FlxDirectionFlags.UP, ?swpLength:Float = 90) + { + forEachBound(control, function(action, press) action.add(new FlxActionInputDigitalMobileSwipeGameplay(swipeDir, press, swpLength))); + } + + /** + * Sets all actions that pertain to the binder to trigger when the supplied keys are used. + * If binder is a literal you can inline this + */ + public function unbindKeys(control:Control, keys:Array) + { + forEachBound(control, function(action, _) removeKeys(action, keys)); + } + + static function addKeys(action:FlxActionDigital, keys:Array, state:FlxInputState) + { + for (key in keys) + { + if (key == FlxKey.NONE) continue; // Ignore unbound keys. + action.addKey(key, state); + } + } + + static function removeKeys(action:FlxActionDigital, keys:Array) + { + var i = action.inputs.length; + while (i-- > 0) + { + var input = action.inputs[i]; + if (input.device == KEYBOARD && keys.indexOf(cast input.inputID) != -1) action.remove(input); + } + } + + public function setKeyboardScheme(scheme:KeyboardScheme, reset = true) + { + if (reset) removeKeyboard(); + + keyboardScheme = scheme; + + bindKeys(Control.UI_UP, getDefaultKeybinds(scheme, Control.UI_UP)); + bindKeys(Control.UI_DOWN, getDefaultKeybinds(scheme, Control.UI_DOWN)); + bindKeys(Control.UI_LEFT, getDefaultKeybinds(scheme, Control.UI_LEFT)); + bindKeys(Control.UI_RIGHT, getDefaultKeybinds(scheme, Control.UI_RIGHT)); + bindKeys(Control.NOTE_UP, getDefaultKeybinds(scheme, Control.NOTE_UP)); + bindKeys(Control.NOTE_DOWN, getDefaultKeybinds(scheme, Control.NOTE_DOWN)); + bindKeys(Control.NOTE_LEFT, getDefaultKeybinds(scheme, Control.NOTE_LEFT)); + bindKeys(Control.NOTE_RIGHT, getDefaultKeybinds(scheme, Control.NOTE_RIGHT)); + bindKeys(Control.ACCEPT, getDefaultKeybinds(scheme, Control.ACCEPT)); + bindKeys(Control.BACK, getDefaultKeybinds(scheme, Control.BACK)); + bindKeys(Control.PAUSE, getDefaultKeybinds(scheme, Control.PAUSE)); + bindKeys(Control.RESET, getDefaultKeybinds(scheme, Control.RESET)); + #if FEATURE_SCREENSHOTS + bindKeys(Control.WINDOW_SCREENSHOT, getDefaultKeybinds(scheme, Control.WINDOW_SCREENSHOT)); + #end + bindKeys(Control.WINDOW_FULLSCREEN, getDefaultKeybinds(scheme, Control.WINDOW_FULLSCREEN)); + bindKeys(Control.FREEPLAY_FAVORITE, getDefaultKeybinds(scheme, Control.FREEPLAY_FAVORITE)); + bindKeys(Control.FREEPLAY_LEFT, getDefaultKeybinds(scheme, Control.FREEPLAY_LEFT)); + bindKeys(Control.FREEPLAY_RIGHT, getDefaultKeybinds(scheme, Control.FREEPLAY_RIGHT)); + bindKeys(Control.FREEPLAY_CHAR_SELECT, getDefaultKeybinds(scheme, Control.FREEPLAY_CHAR_SELECT)); + bindKeys(Control.FREEPLAY_JUMP_TO_TOP, getDefaultKeybinds(scheme, Control.FREEPLAY_JUMP_TO_TOP)); + bindKeys(Control.FREEPLAY_JUMP_TO_BOTTOM, getDefaultKeybinds(scheme, Control.FREEPLAY_JUMP_TO_BOTTOM)); + bindKeys(Control.CUTSCENE_ADVANCE, getDefaultKeybinds(scheme, Control.CUTSCENE_ADVANCE)); + bindKeys(Control.DEBUG_MENU, getDefaultKeybinds(scheme, Control.DEBUG_MENU)); + #if FEATURE_CHART_EDITOR + bindKeys(Control.DEBUG_CHART, getDefaultKeybinds(scheme, Control.DEBUG_CHART)); + #end + #if FEATURE_STAGE_EDITOR + bindKeys(Control.DEBUG_STAGE, getDefaultKeybinds(scheme, Control.DEBUG_STAGE)); + #end + bindKeys(Control.VOLUME_UP, getDefaultKeybinds(scheme, Control.VOLUME_UP)); + bindKeys(Control.VOLUME_DOWN, getDefaultKeybinds(scheme, Control.VOLUME_DOWN)); + bindKeys(Control.VOLUME_MUTE, getDefaultKeybinds(scheme, Control.VOLUME_MUTE)); + + bindMobileLol(); + } + + function getDefaultKeybinds(scheme:KeyboardScheme, control:Control):Array + { + switch (scheme) + { + case Solo: + switch (control) + { + case Control.UI_UP: return [W, FlxKey.UP]; + case Control.UI_DOWN: return [S, FlxKey.DOWN]; + case Control.UI_LEFT: return [A, FlxKey.LEFT]; + case Control.UI_RIGHT: return [D, FlxKey.RIGHT]; + case Control.NOTE_UP: return [W, FlxKey.UP]; + case Control.NOTE_DOWN: return [S, FlxKey.DOWN]; + case Control.NOTE_LEFT: return [A, FlxKey.LEFT]; + case Control.NOTE_RIGHT: return [D, FlxKey.RIGHT]; + case Control.ACCEPT: return [Z, SPACE, ENTER]; + case Control.BACK: return [X, BACKSPACE, ESCAPE]; + case Control.PAUSE: return [P, ENTER, ESCAPE]; + case Control.RESET: return [R]; + case Control.WINDOW_FULLSCREEN: return [F11]; // We use F for other things LOL. + #if FEATURE_SCREENSHOTS case Control.WINDOW_SCREENSHOT: return [F3]; #end + case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu + case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu + case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu + case Control.FREEPLAY_CHAR_SELECT: return [TAB]; + case Control.FREEPLAY_JUMP_TO_TOP: return [HOME]; + case Control.FREEPLAY_JUMP_TO_BOTTOM: return [END]; + case Control.CUTSCENE_ADVANCE: return [Z, ENTER]; + case Control.DEBUG_MENU: return [GRAVEACCENT]; + #if FEATURE_CHART_EDITOR case Control.DEBUG_CHART: return []; #end + #if FEATURE_STAGE_EDITOR case Control.DEBUG_STAGE: return []; #end + case Control.VOLUME_UP: return [PLUS, NUMPADPLUS]; + case Control.VOLUME_DOWN: return [MINUS, NUMPADMINUS]; + case Control.VOLUME_MUTE: return [ZERO, NUMPADZERO]; + } + case Duo(true): + switch (control) + { + case Control.UI_UP: return [W]; + case Control.UI_DOWN: return [S]; + case Control.UI_LEFT: return [A]; + case Control.UI_RIGHT: return [D]; + case Control.NOTE_UP: return [W]; + case Control.NOTE_DOWN: return [S]; + case Control.NOTE_LEFT: return [A]; + case Control.NOTE_RIGHT: return [D]; + case Control.ACCEPT: return [G, Z]; + case Control.BACK: return [H, X]; + case Control.PAUSE: return [ONE]; + case Control.RESET: return [R]; + #if FEATURE_SCREENSHOTS case Control.WINDOW_SCREENSHOT: return [F3]; #end + case Control.WINDOW_FULLSCREEN: return [F11]; + case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu + case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu + case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu + case Control.FREEPLAY_CHAR_SELECT: return [TAB]; + case Control.FREEPLAY_JUMP_TO_TOP: return [HOME]; + case Control.FREEPLAY_JUMP_TO_BOTTOM: return [END]; + case Control.CUTSCENE_ADVANCE: return [G, Z]; + case Control.DEBUG_MENU: return [GRAVEACCENT]; + #if FEATURE_CHART_EDITOR case Control.DEBUG_CHART: return []; #end + #if FEATURE_STAGE_EDITOR case Control.DEBUG_STAGE: return []; #end + case Control.VOLUME_UP: return [PLUS]; + case Control.VOLUME_DOWN: return [MINUS]; + case Control.VOLUME_MUTE: return [ZERO]; + } + case Duo(false): + switch (control) + { + case Control.UI_UP: return [FlxKey.UP]; + case Control.UI_DOWN: return [FlxKey.DOWN]; + case Control.UI_LEFT: return [FlxKey.LEFT]; + case Control.UI_RIGHT: return [FlxKey.RIGHT]; + case Control.NOTE_UP: return [FlxKey.UP]; + case Control.NOTE_DOWN: return [FlxKey.DOWN]; + case Control.NOTE_LEFT: return [FlxKey.LEFT]; + case Control.NOTE_RIGHT: return [FlxKey.RIGHT]; + case Control.ACCEPT: return [ENTER]; + case Control.BACK: return [ESCAPE]; + case Control.PAUSE: return [ONE]; + case Control.RESET: return [R]; + #if FEATURE_SCREENSHOTS case Control.WINDOW_SCREENSHOT: return []; #end + case Control.WINDOW_FULLSCREEN: return []; + case Control.FREEPLAY_FAVORITE: return []; + case Control.FREEPLAY_LEFT: return []; + case Control.FREEPLAY_RIGHT: return []; + case Control.FREEPLAY_CHAR_SELECT: return []; + case Control.FREEPLAY_JUMP_TO_TOP: return []; + case Control.FREEPLAY_JUMP_TO_BOTTOM: return []; + case Control.CUTSCENE_ADVANCE: return [ENTER]; + case Control.DEBUG_MENU: return []; + #if FEATURE_CHART_EDITOR case Control.DEBUG_CHART: return []; #end + #if FEATURE_STAGE_EDITOR case Control.DEBUG_STAGE: return []; #end + case Control.VOLUME_UP: return [NUMPADPLUS]; + case Control.VOLUME_DOWN: return [NUMPADMINUS]; + case Control.VOLUME_MUTE: return [NUMPADZERO]; + } + default: + // Fallthrough. + } + + return []; + } + + function bindMobileLol() + { + #if FLX_TOUCH + // MAKE BETTER TOUCH BIND CODE + + bindSwipe(Control.NOTE_UP, FlxDirectionFlags.UP, 40); + bindSwipe(Control.NOTE_DOWN, FlxDirectionFlags.DOWN, 40); + bindSwipe(Control.NOTE_LEFT, FlxDirectionFlags.LEFT, 40); + bindSwipe(Control.NOTE_RIGHT, FlxDirectionFlags.RIGHT, 40); + + // feels more like drag when up/down are inversed + bindSwipe(Control.UI_UP, FlxDirectionFlags.DOWN); + bindSwipe(Control.UI_DOWN, FlxDirectionFlags.UP); + bindSwipe(Control.UI_LEFT, FlxDirectionFlags.LEFT); + bindSwipe(Control.UI_RIGHT, FlxDirectionFlags.RIGHT); + #end + + #if android + forEachBound(Control.BACK, function(action, pres) { + action.add(new FlxActionInputDigitalAndroid(FlxAndroidKey.BACK, JUST_PRESSED)); + }); + #end + } + + function removeKeyboard() + { + for (action in this.digitalActions) + { + var i = action.inputs.length; + while (i-- > 0) + { + var input = action.inputs[i]; + if (input.device == KEYBOARD) action.remove(input); + } + } + } + + public function addGamepadWithSaveData(id:Int, ?padData:Dynamic):Void + { + gamepadsAdded.push(id); + + fromSaveData(padData, Gamepad(id)); + } + + public function getGamepadIds():Array + { + return gamepadsAdded; + } + + public function getGamepads():Array + { + return [for (id in gamepadsAdded) FlxG.gamepads.getByID(id)]; + } + + inline function addGamepadLiteral(id:Int, ?buttonMap:Map>):Void + { + gamepadsAdded.push(id); + + for (control in buttonMap.keys()) + bindButtons(control, id, buttonMap[control]); + } + + public function removeGamepad(deviceID:Int = FlxInputDeviceID.ALL):Void + { + for (action in this.digitalActions) + { + var i = action.inputs.length; + while (i-- > 0) + { + var input = action.inputs[i]; + if (isGamepad(input, deviceID)) action.remove(input); + } + } + + gamepadsAdded.remove(deviceID); + } + + public function addDefaultGamepad(id):Void + { + addGamepadLiteral(id, [ + Control.ACCEPT => getDefaultGamepadBinds(Control.ACCEPT), + Control.BACK => getDefaultGamepadBinds(Control.BACK), + Control.UI_UP => getDefaultGamepadBinds(Control.UI_UP), + Control.UI_DOWN => getDefaultGamepadBinds(Control.UI_DOWN), + Control.UI_LEFT => getDefaultGamepadBinds(Control.UI_LEFT), + Control.UI_RIGHT => getDefaultGamepadBinds(Control.UI_RIGHT), + Control.NOTE_UP => getDefaultGamepadBinds(Control.NOTE_UP), + Control.NOTE_DOWN => getDefaultGamepadBinds(Control.NOTE_DOWN), + Control.NOTE_LEFT => getDefaultGamepadBinds(Control.NOTE_LEFT), + Control.NOTE_RIGHT => getDefaultGamepadBinds(Control.NOTE_RIGHT), + Control.PAUSE => getDefaultGamepadBinds(Control.PAUSE), + Control.RESET => getDefaultGamepadBinds(Control.RESET), + Control.WINDOW_FULLSCREEN => getDefaultGamepadBinds(Control.WINDOW_FULLSCREEN), + #if FEATURE_SCREENSHOTS + Control.WINDOW_SCREENSHOT => getDefaultGamepadBinds(Control.WINDOW_SCREENSHOT), + #end + Control.CUTSCENE_ADVANCE => getDefaultGamepadBinds(Control.CUTSCENE_ADVANCE), + Control.FREEPLAY_FAVORITE => getDefaultGamepadBinds(Control.FREEPLAY_FAVORITE), + Control.FREEPLAY_LEFT => getDefaultGamepadBinds(Control.FREEPLAY_LEFT), + Control.FREEPLAY_RIGHT => getDefaultGamepadBinds(Control.FREEPLAY_RIGHT), + Control.FREEPLAY_CHAR_SELECT => getDefaultGamepadBinds(Control.FREEPLAY_CHAR_SELECT), + Control.FREEPLAY_JUMP_TO_TOP => getDefaultGamepadBinds(Control.FREEPLAY_JUMP_TO_TOP), + Control.FREEPLAY_JUMP_TO_BOTTOM => getDefaultGamepadBinds(Control.FREEPLAY_JUMP_TO_BOTTOM), + Control.VOLUME_UP => getDefaultGamepadBinds(Control.VOLUME_UP), + Control.VOLUME_DOWN => getDefaultGamepadBinds(Control.VOLUME_DOWN), + Control.VOLUME_MUTE => getDefaultGamepadBinds(Control.VOLUME_MUTE), + Control.DEBUG_MENU => getDefaultGamepadBinds(Control.DEBUG_MENU), + #if FEATURE_CHART_EDITOR + Control.DEBUG_CHART => getDefaultGamepadBinds(Control.DEBUG_CHART), + #end + #if FEATURE_STAGE_EDITOR + Control.DEBUG_STAGE => getDefaultGamepadBinds(Control.DEBUG_STAGE), + #end + ]); + } + + function getDefaultGamepadBinds(control:Control):Array + { + switch (control) + { + case Control.ACCEPT: + return [#if switch B #else A #end]; + case Control.BACK: + return [#if switch A #else B #end]; + case Control.UI_UP: + return [DPAD_UP, LEFT_STICK_DIGITAL_UP]; + case Control.UI_DOWN: + return [DPAD_DOWN, LEFT_STICK_DIGITAL_DOWN]; + case Control.UI_LEFT: + return [DPAD_LEFT, LEFT_STICK_DIGITAL_LEFT]; + case Control.UI_RIGHT: + return [DPAD_RIGHT, LEFT_STICK_DIGITAL_RIGHT]; + case Control.NOTE_UP: + return [DPAD_UP, Y, LEFT_STICK_DIGITAL_UP, RIGHT_STICK_DIGITAL_UP]; + case Control.NOTE_DOWN: + return [DPAD_DOWN, A, LEFT_STICK_DIGITAL_DOWN, RIGHT_STICK_DIGITAL_DOWN]; + case Control.NOTE_LEFT: + return [DPAD_LEFT, X, LEFT_STICK_DIGITAL_LEFT, RIGHT_STICK_DIGITAL_LEFT]; + case Control.NOTE_RIGHT: + return [DPAD_RIGHT, B, LEFT_STICK_DIGITAL_RIGHT, RIGHT_STICK_DIGITAL_RIGHT]; + case Control.PAUSE: + return [START]; + case Control.RESET: + return [FlxGamepadInputID.BACK]; // Back (i.e. Select) + case Control.WINDOW_FULLSCREEN: + []; + #if FEATURE_SCREENSHOTS + case Control.WINDOW_SCREENSHOT: + []; + #end + case Control.CUTSCENE_ADVANCE: + return [A]; + case Control.FREEPLAY_FAVORITE: + return [Y]; // Back (i.e. Select) + case Control.FREEPLAY_LEFT: + return [LEFT_SHOULDER]; + case Control.FREEPLAY_RIGHT: + return [RIGHT_SHOULDER]; + case Control.FREEPLAY_CHAR_SELECT: + return [X]; + case Control.FREEPLAY_JUMP_TO_TOP: + return []; + case Control.FREEPLAY_JUMP_TO_BOTTOM: + return []; + case Control.VOLUME_UP: + []; + case Control.VOLUME_DOWN: + []; + case Control.VOLUME_MUTE: + []; + case Control.DEBUG_MENU: + []; + #if FEATURE_CHART_EDITOR + case Control.DEBUG_CHART: + []; + #end + #if FEATURE_STAGE_EDITOR + case Control.DEBUG_STAGE: + []; + #end + default: + // Fallthrough. + } + return []; + } + + /** + * Sets all actions that pertain to the binder to trigger when the supplied keys are used. + * If binder is a literal you can inline this + */ + public function bindButtons(control:Control, id, buttons) + { + forEachBound(control, function(action, state) addButtons(action, buttons, state, id)); + } + + public function touchShit(control:Control, id) + { + forEachBound(control, function(action, state) { + // action + }); + } + + /** + * Sets all actions that pertain to the binder to trigger when the supplied keys are used. + * If binder is a literal you can inline this + */ + public function unbindButtons(control:Control, gamepadID:Int, buttons) + { + forEachBound(control, function(action, _) removeButtons(action, gamepadID, buttons)); + } + + inline static function addButtons(action:FlxActionDigital, buttons:Array, state, id) + { + for (button in buttons) + { + if (button == FlxGamepadInputID.NONE) continue; // Ignore unbound keys. + action.addGamepad(button, state, id); + } + } + + static function removeButtons(action:FlxActionDigital, gamepadID:Int, buttons:Array) + { + var i = action.inputs.length; + while (i-- > 0) + { + var input = action.inputs[i]; + if (isGamepad(input, gamepadID) && buttons.indexOf(cast input.inputID) != -1) action.remove(input); + } + } + + public function getInputsFor(control:Control, device:Device, ?list:Array):Array + { + if (list == null) list = []; + + switch (device) + { + case Keys: + for (input in getActionFromControl(control).inputs) + { + if (input.device == KEYBOARD) list.push(input.inputID); + } + case Gamepad(id): + for (input in getActionFromControl(control).inputs) + { + if (isGamepad(input, id)) list.push(input.inputID); + } + } + return list; + } + + public function removeDevice(device:Device) + { + switch (device) + { + case Keys: + setKeyboardScheme(None); + case Gamepad(id): + removeGamepad(id); + } + } + + /** + * NOTE: When loading controls: + * An EMPTY array means the control is uninitialized and needs to be reset to default. + * An array with a single FlxKey.NONE means the control was intentionally unbound by the user. + */ + public function fromSaveData(data:Dynamic, device:Device):Void + { + for (control in Control.createAll()) + { + var inputs:Array = Reflect.field(data, control.getName()); + inputs = inputs?.distinct(); + if (inputs != null) + { + if (inputs.length == 0) + { + trace('Control ${control} is missing bindings, resetting to default.'); + switch (device) + { + case Keys: + bindKeys(control, getDefaultKeybinds(Solo, control)); + case Gamepad(id): + bindButtons(control, id, getDefaultGamepadBinds(control)); + } + } + else if (inputs == [FlxKey.NONE]) + { + trace('Control ${control} is unbound, leaving it be.'); + } + else + { + switch (device) + { + case Keys: + bindKeys(control, inputs.copy()); + case Gamepad(id): + bindButtons(control, id, inputs.copy()); + } + } + } + else + { + trace('Control ${control} is missing bindings, resetting to default.'); + switch (device) + { + case Keys: + bindKeys(control, getDefaultKeybinds(Solo, control)); + case Gamepad(id): + bindButtons(control, id, getDefaultGamepadBinds(control)); + } + } + } + } + + /** + * NOTE: When saving controls: + * An EMPTY array means the control is uninitialized and needs to be reset to default. + * An array with a single FlxKey.NONE means the control was intentionally unbound by the user. + */ + public function createSaveData(device:Device):Dynamic + { + var isEmpty = true; + var data = {}; + for (control in Control.createAll()) + { + var inputs = getInputsFor(control, device); + isEmpty = isEmpty && inputs.length == 0; + + if (inputs.length == 0) + { + inputs = [FlxKey.NONE]; + } + else + { + inputs = inputs.distinct(); + } + + Reflect.setField(data, control.getName(), inputs); + } + + return isEmpty ? null : data; + } + + static function isDevice(input:FlxActionInput, device:Device) + { + return switch (device) + { + case Keys: input.device == KEYBOARD; + case Gamepad(id): isGamepad(input, id); + } + } + + inline static function isGamepad(input:FlxActionInput, deviceID:Int) + { + return input.device == GAMEPAD && (deviceID == FlxInputDeviceID.ALL || input.deviceID == deviceID); + } +} + +typedef SaveInputLists = +{ + ?keys:Array, + ?pad:Array +}; + +typedef Swipes = +{ + ?initTouchPos:FlxPoint, + ?touchAngle:Float, + ?touchLength:Float, + ?curTouchPos:FlxPoint +}; + +/** + * An FlxActionDigital with additional functionality, including: + * - Combining `pressed` and `released` inputs into one action. + * - Filtering by input method (`KEYBOARD`, `MOUSE`, `GAMEPAD`, etc). + */ +class FunkinAction extends FlxActionDigital +{ + public var namePressed(default, null):Null; + public var nameReleased(default, null):Null; + + var cache:Map = []; + + public function new(?name:String = "", ?namePressed:String, ?nameReleased:String) + { + super(name); + + this.namePressed = namePressed; + this.nameReleased = nameReleased; + } + + /** + * Input checks default to whether the input was just pressed, on any input device. + */ + public override function check():Bool + { + return checkFiltered(JUST_PRESSED); + } + + /** + * Check whether the input is currently being held. + */ + public function checkPressed():Bool + { + return checkFiltered(PRESSED); + } + + /** + * Check whether the input is currently being held, and was not held last frame. + */ + public function checkJustPressed():Bool + { + return checkFiltered(JUST_PRESSED); + } + + /** + * Check whether the input is not currently being held. + */ + public function checkReleased():Bool + { + return checkFiltered(RELEASED); + } + + /** + * Check whether the input is not currently being held, and was held last frame. + */ + public function checkJustReleased():Bool + { + return checkFiltered(JUST_RELEASED); + } + + /** + * Check whether the input is currently being held by a gamepad device. + */ + public function checkPressedGamepad():Bool + { + return checkFiltered(PRESSED, GAMEPAD); + } + + /** + * Check whether the input is currently being held by a gamepad device, and was not held last frame. + */ + public function checkJustPressedGamepad():Bool + { + return checkFiltered(JUST_PRESSED, GAMEPAD); + } + + /** + * Check whether the input is not currently being held by a gamepad device. + */ + public function checkReleasedGamepad():Bool + { + return checkFiltered(RELEASED, GAMEPAD); + } + + /** + * Check whether the input is not currently being held by a gamepad device, and was held last frame. + */ + public function checkJustReleasedGamepad():Bool + { + return checkFiltered(JUST_RELEASED, GAMEPAD); + } + + public function checkMultiFiltered(?filterTriggers:Array, ?filterDevices:Array):Bool + { + if (filterTriggers == null) + { + filterTriggers = [PRESSED, JUST_PRESSED]; + } + if (filterDevices == null) + { + filterDevices = []; + } + + // Perform checkFiltered for each combination. + for (i in filterTriggers) + { + if (filterDevices.length == 0) + { + if (checkFiltered(i)) + { + return true; + } + } + else + { + for (j in filterDevices) + { + if (checkFiltered(i, j)) + { + return true; + } + } + } + } + return false; + } + + /** + * Performs the functionality of `FlxActionDigital.check()`, but with optional filters. + * @param action The action to check for. + * @param filterTrigger Optionally filter by trigger condition (`JUST_PRESSED`, `PRESSED`, `JUST_RELEASED`, `RELEASED`). + * @param filterDevice Optionally filter by device (`KEYBOARD`, `MOUSE`, `GAMEPAD`, `OTHER`). + */ + public function checkFiltered(?filterTrigger:FlxInputState, ?filterDevice:FlxInputDevice):Bool + { + // The normal + + // Make sure we only update the inputs once per frame. + var key = '${filterTrigger}:${filterDevice}'; + var cacheEntry = cache.get(key); + + if (cacheEntry != null && cacheEntry.timestamp == FlxG.game.ticks) + { + return cacheEntry.value; + } + // Use a for loop instead so we can remove inputs while iterating. + + // We don't return early because we need to call check() on ALL inputs. + var result = false; + var len = inputs != null ? inputs.length : 0; + for (i in 0...len) + { + var j = len - i - 1; + var input = inputs[j]; + + // Filter out dead inputs. + if (input.destroyed) + { + inputs.splice(j, 1); + continue; + } + + // Update the input. + input.update(); + + // Check whether the input is the right trigger. + if (filterTrigger != null && input.trigger != filterTrigger) + { + continue; + } + + // Check whether the input is the right device. + if (filterDevice != null && input.device != filterDevice) + { + continue; + } + + // Check whether the input has triggered. + if (input.check(this)) + { + result = true; + } + } + + // We need to cache this result. + cache.set(key, {timestamp: FlxG.game.ticks, value: result}); + + return result; + } +} + +class FlxActionInputDigitalMobileSwipeGameplay extends FlxActionInputDigital +{ + var touchMap:Map = new Map(); + + var vibrationSteps:Int = 5; + var curStep:Int = 5; + var activateLength:Float = 90; + var hapticPressure:Int = 100; + + public function new(swipeDir:FlxDirectionFlags = FlxDirectionFlags.ANY, Trigger:FlxInputState, ?swipeLength:Float = 90) + { + super(OTHER, swipeDir.toInt(), Trigger); + + activateLength = swipeLength; + } + + // fix right swipe + // make so cant double swipe during gameplay + // hold notes? + + override function update():Void + { + super.update(); + + #if FLX_TOUCH + for (touch in FlxG.touches.list) + { + if (touch.justPressed) + { + var pos:FlxPoint = new FlxPoint(touch.screenX, touch.screenY); + var pos2:FlxPoint = new FlxPoint(touch.screenX, touch.screenY); + + var swp:Swipes = + { + initTouchPos: pos, + curTouchPos: pos2, + touchAngle: 0, + touchLength: 0 + }; + touchMap[touch.touchPointID] = swp; + + curStep = 1; + Haptic.vibrate(40, 70); + } + if (touch.pressed) + { + var daSwipe = touchMap[touch.touchPointID]; + + daSwipe.curTouchPos.set(touch.screenX, touch.screenY); + + var dx = daSwipe.initTouchPos.x - touch.screenX; + var dy = daSwipe.initTouchPos.y - touch.screenY; + + daSwipe.touchAngle = Math.atan2(dy, dx); + daSwipe.touchLength = Math.sqrt(dx * dx + dy * dy); + + FlxG.watch.addQuick("LENGTH", daSwipe.touchLength); + FlxG.watch.addQuick("ANGLE", FlxAngle.asDegrees(daSwipe.touchAngle)); + + if (daSwipe.touchLength >= (activateLength / vibrationSteps) * curStep) + { + curStep += 1; + // Haptic.vibrate(Std.int(hapticPressure / (curStep * 1.5)), 50); + } + } + + if (touch.justReleased) + { + touchMap.remove(touch.touchPointID); + } + + /* switch (inputID) + { + case FlxDirectionFlags.UP: + return + case FlxDirectionFlags.DOWN: + } + */ + } + #end + } + + override public function check(Action:FlxAction):Bool + { + for (swp in touchMap) + { + var degAngle = FlxAngle.asDegrees(swp.touchAngle); + + switch (trigger) + { + case JUST_PRESSED: + if (swp.touchLength >= activateLength) + { + if (inputID == FlxDirectionFlags.UP.toInt()) + { + if (degAngle >= 45 && degAngle <= 90 + 45) return properTouch(swp); + } + else if (inputID == FlxDirectionFlags.DOWN.toInt()) + { + if (-degAngle >= 45 && -degAngle <= 90 + 45) return properTouch(swp); + } + else if (inputID == FlxDirectionFlags.LEFT.toInt()) + { + if (degAngle <= 45 && -degAngle <= 45) return properTouch(swp); + } + else if (inputID == FlxDirectionFlags.RIGHT.toInt()) + { + if (degAngle >= 90 + 45 && degAngle <= -90 + -45) return properTouch(swp); + } + } + default: + } + } + + return false; + } + + function properTouch(swipe:Swipes):Bool + { + curStep = 1; + Haptic.vibrate(100, 30); + swipe.initTouchPos.set(swipe.curTouchPos.x, swipe.curTouchPos.y); + return true; + } +} + +// Maybe this can be committed to main HaxeFlixel repo? +#if android +class FlxActionInputDigitalAndroid extends FlxActionInputDigital +{ + /** + * Android buttons action input + * @param androidKeyID Key identifier (FlxAndroidKey.BACK, FlxAndroidKey.MENU... those are the only 2 android specific ones) + * @param Trigger What state triggers this action (PRESSED, JUST_PRESSED, RELEASED, JUST_RELEASED) + */ + public function new(androidKeyID:FlxAndroidKey, Trigger:FlxInputState) + { + super(FlxInputDevice.OTHER, androidKeyID, Trigger); + } + + override public function check(Action:FlxAction):Bool + { + return switch (trigger) + { + #if android + case PRESSED: FlxG.android.checkStatus(inputID, PRESSED) || FlxG.android.checkStatus(inputID, PRESSED); + case RELEASED: FlxG.android.checkStatus(inputID, RELEASED) || FlxG.android.checkStatus(inputID, JUST_RELEASED); + case JUST_PRESSED: FlxG.android.checkStatus(inputID, JUST_PRESSED); + case JUST_RELEASED: FlxG.android.checkStatus(inputID, JUST_RELEASED); + #end + + default: false; + } + } +} +#end + +/** + * Since, in many cases multiple actions should use similar keys, we don't want the + * rebinding UI to list every action. ActionBinders are what the user percieves as + * an input so, for instance, they can't set jump-press and jump-release to different keys. + */ +enum Control +{ + // NOTE + NOTE_LEFT; + NOTE_DOWN; + NOTE_UP; + NOTE_RIGHT; + // UI + UI_LEFT; + UI_DOWN; + UI_UP; + UI_RIGHT; + RESET; + ACCEPT; + BACK; + PAUSE; + // CUTSCENE + CUTSCENE_ADVANCE; + // FREEPLAY + FREEPLAY_FAVORITE; + FREEPLAY_LEFT; + FREEPLAY_RIGHT; + FREEPLAY_CHAR_SELECT; + FREEPLAY_JUMP_TO_TOP; + FREEPLAY_JUMP_TO_BOTTOM; + // WINDOW + #if FEATURE_SCREENSHOTS WINDOW_SCREENSHOT; #end + WINDOW_FULLSCREEN; + // VOLUME + VOLUME_UP; + VOLUME_DOWN; + VOLUME_MUTE; + // DEBUG + DEBUG_MENU; + #if FEATURE_CHART_EDITOR DEBUG_CHART; #end + #if FEATURE_STAGE_EDITOR DEBUG_STAGE; #end +} + +enum abstract Action(String) to String from String +{ + // NOTE + var NOTE_UP = "note_up"; + var NOTE_LEFT = "note_left"; + var NOTE_RIGHT = "note_right"; + var NOTE_DOWN = "note_down"; + var NOTE_UP_P = "note_up-press"; + var NOTE_LEFT_P = "note_left-press"; + var NOTE_RIGHT_P = "note_right-press"; + var NOTE_DOWN_P = "note_down-press"; + var NOTE_UP_R = "note_up-release"; + var NOTE_LEFT_R = "note_left-release"; + var NOTE_RIGHT_R = "note_right-release"; + var NOTE_DOWN_R = "note_down-release"; + // UI + var UI_UP = "ui_up"; + var UI_LEFT = "ui_left"; + var UI_RIGHT = "ui_right"; + var UI_DOWN = "ui_down"; + var UI_UP_P = "ui_up-press"; + var UI_LEFT_P = "ui_left-press"; + var UI_RIGHT_P = "ui_right-press"; + var UI_DOWN_P = "ui_down-press"; + var UI_UP_R = "ui_up-release"; + var UI_LEFT_R = "ui_left-release"; + var UI_RIGHT_R = "ui_right-release"; + var UI_DOWN_R = "ui_down-release"; + var ACCEPT = "accept"; + var BACK = "back"; + var PAUSE = "pause"; + var RESET = "reset"; + // WINDOW + var WINDOW_FULLSCREEN = "window_fullscreen"; + #if FEATURE_SCREENSHOTS + var WINDOW_SCREENSHOT = "window_screenshot"; + #end + // CUTSCENE + var CUTSCENE_ADVANCE = "cutscene_advance"; + // FREEPLAY + var FREEPLAY_FAVORITE = "freeplay_favorite"; + var FREEPLAY_LEFT = "freeplay_left"; + var FREEPLAY_RIGHT = "freeplay_right"; + var FREEPLAY_CHAR_SELECT = "freeplay_char_select"; + var FREEPLAY_JUMP_TO_TOP = "freeplay_jump_to_top"; + var FREEPLAY_JUMP_TO_BOTTOM = "freeplay_jump_to_bottom"; + // VOLUME + var VOLUME_UP = "volume_up"; + var VOLUME_DOWN = "volume_down"; + var VOLUME_MUTE = "volume_mute"; + // DEBUG + var DEBUG_MENU = "debug_menu"; + #if FEATURE_CHART_EDITOR + var DEBUG_CHART = "debug_chart"; + #end + #if FEATURE_STAGE_EDITOR + var DEBUG_STAGE = "debug_stage"; + #end +} + +enum Device +{ + Keys; + Gamepad(id:Int); +} + +enum KeyboardScheme +{ + Solo; + Duo(first:Bool); + None; + Custom; +} diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx new file mode 100644 index 00000000..554a6b2c --- /dev/null +++ b/source/funkin/input/Cursor.hx @@ -0,0 +1,595 @@ +package funkin.input; + +import haxe.ui.backend.flixel.CursorHelper; +import lime.app.Future; +import openfl.display.BitmapData; + +@:nullSafety +class Cursor +{ + /** + * The current cursor mode. + * Set this value to change the cursor graphic. + */ + public static var cursorMode(default, set):Null = null; + + /** + * Show the cursor. + */ + public static inline function show():Void + { + FlxG.mouse.visible = true; + // Reset the cursor mode. + Cursor.cursorMode = Default; + } + + /** + * Hide the cursor. + */ + public static inline function hide():Void + { + FlxG.mouse.visible = false; + // Reset the cursor mode. + Cursor.cursorMode = null; + } + + public static inline function toggle():Void + { + if (FlxG.mouse.visible) + { + hide(); + } + else + { + show(); + } + } + + public static final CURSOR_DEFAULT_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-default.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorDefault:Null = null; + + public static final CURSOR_CROSS_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-cross.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorCross:Null = null; + + public static final CURSOR_ERASER_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-eraser.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorEraser:Null = null; + + public static final CURSOR_GRABBING_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-grabbing.png", + scale: 1.0, + offsetX: -8, + offsetY: 0, + }; + static var assetCursorGrabbing:Null = null; + + public static final CURSOR_HOURGLASS_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-hourglass.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorHourglass:Null = null; + + public static final CURSOR_POINTER_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-pointer.png", + scale: 1.0, + offsetX: -8, + offsetY: 0, + }; + static var assetCursorPointer:Null = null; + + public static final CURSOR_TEXT_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-text.png", + scale: 0.2, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorText:Null = null; + + public static final CURSOR_TEXT_VERTICAL_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-text-vertical.png", + scale: 0.2, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorTextVertical:Null = null; + + public static final CURSOR_ZOOM_IN_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-zoom-in.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorZoomIn:Null = null; + + public static final CURSOR_ZOOM_OUT_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-zoom-out.png", + scale: 1.0, + offsetX: 0, + offsetY: 0, + }; + static var assetCursorZoomOut:Null = null; + + public static final CURSOR_CROSSHAIR_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-crosshair.png", + scale: 1.0, + offsetX: -16, + offsetY: -16, + }; + static var assetCursorCrosshair:Null = null; + + public static final CURSOR_CELL_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-cell.png", + scale: 1.0, + offsetX: -16, + offsetY: -16, + }; + static var assetCursorCell:Null = null; + + public static final CURSOR_SCROLL_PARAMS:CursorParams = + { + graphic: "assets/images/cursor/cursor-scroll.png", + scale: 0.2, + offsetX: -15, + offsetY: -15, + }; + static var assetCursorScroll:Null = null; + + static function set_cursorMode(value:Null):Null + { + if (value != null && cursorMode != value) + { + cursorMode = value; + setCursorGraphic(cursorMode); + } + return cursorMode; + } + + /** + * Synchronous. + */ + static function setCursorGraphic(?value:CursorMode = null):Void + { + if (value == null) + { + FlxG.mouse.unload(); + return; + } + + switch (value) + { + case Default: + if (assetCursorDefault == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_DEFAULT_PARAMS.graphic); + assetCursorDefault = bitmapData; + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + } + else + { + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + } + + case Cross: + if (assetCursorCross == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSS_PARAMS.graphic); + assetCursorCross = bitmapData; + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + } + else + { + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + } + + case Eraser: + if (assetCursorEraser == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ERASER_PARAMS.graphic); + assetCursorEraser = bitmapData; + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + } + else + { + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + } + + case Grabbing: + if (assetCursorGrabbing == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_GRABBING_PARAMS.graphic); + assetCursorGrabbing = bitmapData; + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + } + else + { + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + } + + case Hourglass: + if (assetCursorHourglass == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_HOURGLASS_PARAMS.graphic); + assetCursorHourglass = bitmapData; + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + } + else + { + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + } + + case Pointer: + if (assetCursorPointer == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_POINTER_PARAMS.graphic); + assetCursorPointer = bitmapData; + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + } + else + { + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + } + + case Text: + if (assetCursorText == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_TEXT_PARAMS.graphic); + assetCursorText = bitmapData; + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + } + else + { + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + } + + case ZoomIn: + if (assetCursorZoomIn == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic); + assetCursorZoomIn = bitmapData; + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + } + else + { + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + } + + case ZoomOut: + if (assetCursorZoomOut == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic); + assetCursorZoomOut = bitmapData; + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + } + else + { + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + } + + case Crosshair: + if (assetCursorCrosshair == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic); + assetCursorCrosshair = bitmapData; + applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS); + } + else + { + applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS); + } + + case Cell: + if (assetCursorCell == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_CELL_PARAMS.graphic); + assetCursorCell = bitmapData; + applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS); + } + else + { + applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS); + } + + case Scroll: + if (assetCursorScroll == null) + { + var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_SCROLL_PARAMS.graphic); + assetCursorScroll = bitmapData; + applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS); + } + else + { + applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS); + } + + default: + setCursorGraphic(null); + } + } + + /** + * Asynchronous. + */ + static function loadCursorGraphic(?value:CursorMode = null):Void + { + if (value == null) + { + FlxG.mouse.unload(); + return; + } + + switch (value) + { + case Default: + if (assetCursorDefault == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_DEFAULT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorDefault = bitmapData; + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + }); + future.onError(onCursorError.bind(Default)); + } + else + { + applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS); + } + + case Cross: + if (assetCursorCross == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_CROSS_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorCross = bitmapData; + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + }); + future.onError(onCursorError.bind(Cross)); + } + else + { + applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS); + } + + case Eraser: + if (assetCursorEraser == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ERASER_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorEraser = bitmapData; + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + }); + future.onError(onCursorError.bind(Eraser)); + } + else + { + applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS); + } + + case Grabbing: + if (assetCursorGrabbing == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_GRABBING_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorGrabbing = bitmapData; + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + }); + future.onError(onCursorError.bind(Grabbing)); + } + else + { + applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS); + } + + case Hourglass: + if (assetCursorHourglass == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_HOURGLASS_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorHourglass = bitmapData; + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + }); + future.onError(onCursorError.bind(Hourglass)); + } + else + { + applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS); + } + + case Pointer: + if (assetCursorPointer == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_POINTER_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorPointer = bitmapData; + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + }); + future.onError(onCursorError.bind(Pointer)); + } + else + { + applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS); + } + + case Text: + if (assetCursorText == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_TEXT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorText = bitmapData; + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + }); + future.onError(onCursorError.bind(Text)); + } + else + { + applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS); + } + + case ZoomIn: + if (assetCursorZoomIn == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorZoomIn = bitmapData; + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + }); + future.onError(onCursorError.bind(ZoomIn)); + } + else + { + applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS); + } + + case ZoomOut: + if (assetCursorZoomOut == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorZoomOut = bitmapData; + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + }); + future.onError(onCursorError.bind(ZoomOut)); + } + else + { + applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS); + } + + case Crosshair: + if (assetCursorCrosshair == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_CROSSHAIR_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorCrosshair = bitmapData; + applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS); + }); + future.onError(onCursorError.bind(Crosshair)); + } + else + { + applyCursorParams(assetCursorCrosshair, CURSOR_CROSSHAIR_PARAMS); + } + + case Cell: + if (assetCursorCell == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_CELL_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorCell = bitmapData; + applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS); + }); + future.onError(onCursorError.bind(Cell)); + } + else + { + applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS); + } + + case Scroll: + if (assetCursorScroll == null) + { + var future:Future = Assets.loadBitmapData(CURSOR_SCROLL_PARAMS.graphic); + future.onComplete(function(bitmapData:BitmapData) { + assetCursorScroll = bitmapData; + applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS); + }); + future.onError(onCursorError.bind(Scroll)); + } + else + { + applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS); + } + + default: + loadCursorGraphic(null); + } + } + + static inline function applyCursorParams(graphic:BitmapData, params:CursorParams):Void + { + FlxG.mouse.load(graphic, params.scale, params.offsetX, params.offsetY); + } + + static function onCursorError(cursorMode:CursorMode, error:String):Void + { + trace("Failed to load cursor graphic for cursor mode " + cursorMode + ": " + error); + } + + public static function registerHaxeUICursors():Void + { + CursorHelper.useCustomCursors = true; + registerHaxeUICursor('default', CURSOR_DEFAULT_PARAMS); + registerHaxeUICursor('cross', CURSOR_CROSS_PARAMS); + registerHaxeUICursor('eraser', CURSOR_ERASER_PARAMS); + registerHaxeUICursor('grabbing', CURSOR_GRABBING_PARAMS); + registerHaxeUICursor('hourglass', CURSOR_HOURGLASS_PARAMS); + registerHaxeUICursor('pointer', CURSOR_POINTER_PARAMS); + registerHaxeUICursor('text', CURSOR_TEXT_PARAMS); + registerHaxeUICursor('text-vertical', CURSOR_TEXT_VERTICAL_PARAMS); + registerHaxeUICursor('zoom-in', CURSOR_ZOOM_IN_PARAMS); + registerHaxeUICursor('zoom-out', CURSOR_ZOOM_OUT_PARAMS); + registerHaxeUICursor('crosshair', CURSOR_CROSSHAIR_PARAMS); + registerHaxeUICursor('cell', CURSOR_CELL_PARAMS); + registerHaxeUICursor('scroll', CURSOR_SCROLL_PARAMS); + } + + public static function registerHaxeUICursor(id:String, params:CursorParams):Void + { + CursorHelper.registerCursor(id, params.graphic, params.scale, params.offsetX, params.offsetY); + } +} + +// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor +enum CursorMode +{ + Default; + Cross; + Eraser; + Grabbing; + Hourglass; + Pointer; + Text; + ZoomIn; + ZoomOut; + Crosshair; + Cell; + Scroll; +} + +/** + * Static data describing how a cursor should be rendered. + */ +typedef CursorParams = +{ + graphic:String, + scale:Float, + offsetX:Int, + offsetY:Int, +} diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx new file mode 100644 index 00000000..ade45b6f --- /dev/null +++ b/source/funkin/input/PreciseInputManager.hx @@ -0,0 +1,494 @@ +package funkin.input; + +import flixel.FlxG; +import flixel.input.FlxInput; +import flixel.input.FlxInput.FlxInputState; +import flixel.input.FlxKeyManager; +import flixel.input.gamepad.FlxGamepad; +import flixel.input.gamepad.FlxGamepadInputID; +import flixel.input.keyboard.FlxKey; +import flixel.input.keyboard.FlxKeyboard.FlxKeyInput; +import flixel.input.keyboard.FlxKeyList; +import flixel.util.FlxSignal.FlxTypedSignal; +import funkin.play.notes.NoteDirection; +import funkin.util.FlxGamepadUtil; +import haxe.Int64; +import lime.ui.Gamepad as LimeGamepad; +import lime.ui.GamepadAxis as LimeGamepadAxis; +import lime.ui.GamepadButton as LimeGamepadButton; +import lime.ui.KeyCode; +import lime.ui.KeyModifier; +import openfl.events.KeyboardEvent; +import openfl.ui.Keyboard; + +/** + * A precise input manager that: + * - Records the exact timestamp of when a key was pressed or released + * - Only records key presses for keys bound to game inputs (up/down/left/right) + */ +class PreciseInputManager extends FlxKeyManager +{ + public static var instance(get, null):PreciseInputManager; + + static function get_instance():PreciseInputManager + { + return instance ?? (instance = new PreciseInputManager()); + } + + static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; + + public var onInputPressed:FlxTypedSignalVoid>; + public var onInputReleased:FlxTypedSignalVoid>; + + /** + * The list of keys that are bound to game inputs (up/down/left/right). + */ + var _keyList:Array; + + /** + * The direction that a given key is bound to. + */ + var _keyListDir:Map; + + /** + * A FlxGamepadID->Array, with FlxGamepadInputID being the counterpart to FlxKey. + */ + var _buttonList:Map>; + + var _buttonListArray:Array>; + + var _buttonListMap:Map>>; + + /** + * A FlxGamepadID->FlxGamepadInputID->NoteDirection, with FlxGamepadInputID being the counterpart to FlxKey. + */ + var _buttonListDir:Map>; + + /** + * The timestamp at which a given note direction was last pressed. + */ + var _dirPressTimestamps:Map; + + /** + * The timestamp at which a given note direction was last released. + */ + var _dirReleaseTimestamps:Map; + + var _deviceBinds:MapInt64->Void, + onButtonUp:LimeGamepadButton->Int64->Void + }>; + + public function new() + { + super(PreciseInputList.new); + + _deviceBinds = []; + + _keyList = []; + // _keyListMap + // _keyListArray + _keyListDir = new Map(); + + _buttonList = []; + _buttonListMap = []; + _buttonListArray = []; + _buttonListDir = new Map>(); + + _dirPressTimestamps = new Map(); + _dirReleaseTimestamps = new Map(); + + // Keyboard + FlxG.stage.removeEventListener(KeyboardEvent.KEY_DOWN, onKeyDown); + FlxG.stage.removeEventListener(KeyboardEvent.KEY_UP, onKeyUp); + FlxG.stage.application.window.onKeyDownPrecise.add(handleKeyDown); + FlxG.stage.application.window.onKeyUpPrecise.add(handleKeyUp); + + preventDefaultKeys = getPreventDefaultKeys(); + + onInputPressed = new FlxTypedSignalVoid>(); + onInputReleased = new FlxTypedSignalVoid>(); + } + + public static function getKeysForDirection(controls:Controls, noteDirection:NoteDirection) + { + return switch (noteDirection) + { + case NoteDirection.LEFT: controls.getKeysForAction(NOTE_LEFT); + case NoteDirection.DOWN: controls.getKeysForAction(NOTE_DOWN); + case NoteDirection.UP: controls.getKeysForAction(NOTE_UP); + case NoteDirection.RIGHT: controls.getKeysForAction(NOTE_RIGHT); + }; + } + + public static function getButtonsForDirection(controls:Controls, noteDirection:NoteDirection) + { + return switch (noteDirection) + { + case NoteDirection.LEFT: controls.getButtonsForAction(NOTE_LEFT); + case NoteDirection.DOWN: controls.getButtonsForAction(NOTE_DOWN); + case NoteDirection.UP: controls.getButtonsForAction(NOTE_UP); + case NoteDirection.RIGHT: controls.getButtonsForAction(NOTE_RIGHT); + }; + } + + /** + * Convert from int to Int64. + */ + static final NS_PER_MS:Int64 = Constants.NS_PER_MS; + + /** + * Returns a precise timestamp, measured in nanoseconds. + * Timestamp is only useful for comparing against other timestamps. + * + * @return Int64 + */ + @:access(lime._internal.backend.native.NativeCFFI) + public static function getCurrentTimestamp():Int64 + { + #if html5 + // NOTE: This timestamp isn't that precise on standard HTML5 builds. + // This is because of browser safeguards against timing attacks. + // See https://web.dev/coop-coep to enable headers which allow for more precise timestamps. + return haxe.Int64.fromFloat(js.Browser.window.performance.now()) * NS_PER_MS; + #elseif cpp + // NOTE: If the game hard crashes on this line, rebuild Lime! + // `lime rebuild windows -clean` + return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * NS_PER_MS; + #else + throw "Eric didn't implement precise timestamps on this platform!"; + #end + } + + static function getPreventDefaultKeys():Array + { + return FlxG.keys.preventDefaultKeys; + } + + /** + * Call this whenever the user's inputs change. + */ + public function initializeKeys(controls:Controls):Void + { + clearKeys(); + + for (noteDirection in DIRECTIONS) + { + var keys = getKeysForDirection(controls, noteDirection); + for (key in keys) + { + var input = new FlxKeyInput(key); + _keyList.push(key); + _keyListArray.push(input); + _keyListMap.set(key, input); + _keyListDir.set(key, noteDirection); + } + } + } + + public function initializeButtons(controls:Controls, gamepad:FlxGamepad):Void + { + clearButtons(); + + var limeGamepad = FlxGamepadUtil.getLimeGamepad(gamepad); + var callbacks = + { + onButtonDown: handleButtonDown.bind(gamepad), + onButtonUp: handleButtonUp.bind(gamepad) + }; + limeGamepad.onButtonDownPrecise.add(callbacks.onButtonDown); + limeGamepad.onButtonUpPrecise.add(callbacks.onButtonUp); + + for (noteDirection in DIRECTIONS) + { + var buttons = getButtonsForDirection(controls, noteDirection); + for (button in buttons) + { + var input = new FlxInput(button); + + var buttonListEntry = _buttonList.get(gamepad.id); + if (buttonListEntry == null) _buttonList.set(gamepad.id, buttonListEntry = []); + buttonListEntry.push(button); + + _buttonListArray.push(input); + + var buttonListMapEntry = _buttonListMap.get(gamepad.id); + if (buttonListMapEntry == null) _buttonListMap.set(gamepad.id, buttonListMapEntry = new Map>()); + buttonListMapEntry.set(button, input); + + var buttonListDirEntry = _buttonListDir.get(gamepad.id); + if (buttonListDirEntry == null) _buttonListDir.set(gamepad.id, buttonListDirEntry = new Map()); + buttonListDirEntry.set(button, noteDirection); + } + } + } + + /** + * Get the time, in nanoseconds, since the given note direction was last pressed. + * @param noteDirection The note direction to check. + * @return An Int64 representing the time since the given note direction was last pressed. + */ + public function getTimeSincePressed(noteDirection:NoteDirection):Int64 + { + return getCurrentTimestamp() - _dirPressTimestamps.get(noteDirection); + } + + /** + * Get the time, in nanoseconds, since the given note direction was last released. + * @param noteDirection The note direction to check. + * @return An Int64 representing the time since the given note direction was last released. + */ + public function getTimeSinceReleased(noteDirection:NoteDirection):Int64 + { + return getCurrentTimestamp() - _dirReleaseTimestamps.get(noteDirection); + } + + // TODO: Why doesn't this work? + // @:allow(funkin.input.PreciseInputManager.PreciseInputList) + public function getInputByKey(key:FlxKey):FlxKeyInput + { + return _keyListMap.get(key); + } + + public function getInputByButton(gamepad:FlxGamepad, button:FlxGamepadInputID):FlxInput + { + return _buttonListMap.get(gamepad.id).get(button); + } + + public function getDirectionForKey(key:FlxKey):NoteDirection + { + return _keyListDir.get(key); + } + + public function getDirectionForButton(gamepad:FlxGamepad, button:FlxGamepadInputID):NoteDirection + { + return _buttonListDir.get(gamepad.id).get(button); + } + + function getButton(gamepad:FlxGamepad, button:FlxGamepadInputID):FlxInput + { + return _buttonListMap.get(gamepad.id).get(button); + } + + function updateButtonStates(gamepad:FlxGamepad, button:FlxGamepadInputID, down:Bool):Void + { + var input = getButton(gamepad, button); + if (input == null) return; + + if (down) + { + input.press(); + } + else + { + input.release(); + } + } + + function handleKeyDown(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void + { + var key:FlxKey = convertKeyCode(keyCode); + if (_keyList.indexOf(key) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds. + timestamp *= Constants.NS_PER_MS; // 18126000000 38367000000 + timestamp -= Conductor.instance.inputOffset * Constants.NS_PER_MS; + // trace(timestamp); + updateKeyStates(key, true); + + if (getInputByKey(key)?.justPressed ?? false) + { + onInputPressed.dispatch( + { + noteDirection: getDirectionForKey(key), + timestamp: timestamp + }); + _dirPressTimestamps.set(getDirectionForKey(key), timestamp); + } + } + + function handleKeyUp(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void + { + var key:FlxKey = convertKeyCode(keyCode); + if (_keyList.indexOf(key) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds. + timestamp *= Constants.NS_PER_MS; + + updateKeyStates(key, false); + + if (getInputByKey(key)?.justReleased ?? false) + { + onInputReleased.dispatch( + { + noteDirection: getDirectionForKey(key), + timestamp: timestamp + }); + _dirReleaseTimestamps.set(getDirectionForKey(key), timestamp); + } + } + + function handleButtonDown(gamepad:FlxGamepad, button:LimeGamepadButton, timestamp:Int64):Void + { + var buttonId:FlxGamepadInputID = FlxGamepadUtil.getInputID(gamepad, button); + + var buttonListEntry = _buttonList.get(gamepad.id); + if (buttonListEntry == null || buttonListEntry.indexOf(buttonId) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds. + timestamp *= Constants.NS_PER_MS; + + updateButtonStates(gamepad, buttonId, true); + + if (getInputByButton(gamepad, buttonId)?.justPressed ?? false) + { + onInputPressed.dispatch( + { + noteDirection: getDirectionForButton(gamepad, buttonId), + timestamp: timestamp + }); + _dirPressTimestamps.set(getDirectionForButton(gamepad, buttonId), timestamp); + } + } + + function handleButtonUp(gamepad:FlxGamepad, button:LimeGamepadButton, timestamp:Int64):Void + { + var buttonId:FlxGamepadInputID = FlxGamepadUtil.getInputID(gamepad, button); + + var buttonListEntry = _buttonList.get(gamepad.id); + if (buttonListEntry == null || buttonListEntry.indexOf(buttonId) == -1) return; + + // TODO: Remove this line with SDL3 when timestamps change meaning. + // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds. + timestamp *= Constants.NS_PER_MS; + + updateButtonStates(gamepad, buttonId, false); + + if (getInputByButton(gamepad, buttonId)?.justReleased ?? false) + { + onInputReleased.dispatch( + { + noteDirection: getDirectionForButton(gamepad, buttonId), + timestamp: timestamp + }); + _dirReleaseTimestamps.set(getDirectionForButton(gamepad, buttonId), timestamp); + } + } + + static function convertKeyCode(input:KeyCode):FlxKey + { + @:privateAccess + { + return Keyboard.__convertKeyCode(input); + } + } + + function clearKeys():Void + { + _keyListArray = []; + _keyListMap.clear(); + _keyListDir.clear(); + } + + function clearButtons():Void + { + _buttonListArray = []; + _buttonListDir.clear(); + + for (gamepad in _deviceBinds.keys()) + { + var callbacks = _deviceBinds.get(gamepad); + var limeGamepad = FlxGamepadUtil.getLimeGamepad(gamepad); + limeGamepad.onButtonDownPrecise.remove(callbacks.onButtonDown); + limeGamepad.onButtonUpPrecise.remove(callbacks.onButtonUp); + } + _deviceBinds.clear(); + } + + public override function destroy():Void + { + // Keyboard + FlxG.stage.application.window.onKeyDownPrecise.remove(handleKeyDown); + FlxG.stage.application.window.onKeyUpPrecise.remove(handleKeyUp); + + clearKeys(); + clearButtons(); + } +} + +class PreciseInputList extends FlxKeyList +{ + var _preciseInputManager:PreciseInputManager; + + public function new(state:FlxInputState, preciseInputManager:FlxKeyManager) + { + super(state, preciseInputManager); + + _preciseInputManager = cast preciseInputManager; + } + + static function getKeysForDir(noteDir:NoteDirection):Array + { + return PreciseInputManager.getKeysForDirection(PlayerSettings.player1.controls, noteDir); + } + + function isKeyValid(key:FlxKey):Bool + { + @:privateAccess + { + return _preciseInputManager._keyListMap.exists(key); + } + } + + public function checkFlxKey(key:FlxKey):Bool + { + if (isKeyValid(key)) return check(cast key); + return false; + } + + public function checkDir(noteDir:NoteDirection):Bool + { + for (key in getKeysForDir(noteDir)) + { + if (check(_preciseInputManager.getInputByKey(key)?.ID)) return true; + } + return false; + } + + public var NOTE_LEFT(get, never):Bool; + + function get_NOTE_LEFT():Bool + return checkDir(NoteDirection.LEFT); + + public var NOTE_DOWN(get, never):Bool; + + function get_NOTE_DOWN():Bool + return checkDir(NoteDirection.DOWN); + + public var NOTE_UP(get, never):Bool; + + function get_NOTE_UP():Bool + return checkDir(NoteDirection.UP); + + public var NOTE_RIGHT(get, never):Bool; + + function get_NOTE_RIGHT():Bool + return checkDir(NoteDirection.RIGHT); +} + +typedef PreciseInputEvent = +{ + /** + * The direction of the input. + */ + noteDirection:NoteDirection, + + /** + * The timestamp of the input. Measured in nanoseconds. + */ + timestamp:Int64, +}; diff --git a/source/funkin/input/TurboActionHandler.hx b/source/funkin/input/TurboActionHandler.hx new file mode 100644 index 00000000..9425db8c --- /dev/null +++ b/source/funkin/input/TurboActionHandler.hx @@ -0,0 +1,111 @@ +package funkin.input; + +import flixel.input.keyboard.FlxKey; +import flixel.FlxBasic; +import funkin.input.Controls; +import funkin.input.Controls.Action; + +/** + * Handles repeating behavior when holding down a control action. + * + * When the `action` is pressed, `activated` will be true for the first frame, + * then wait `delay` seconds before becoming true for one frame every `interval` seconds. + * + * Example: Pressing Ctrl+Z will undo, while holding Ctrl+Z will start to undo repeatedly. + */ +class TurboActionHandler extends FlxBasic +{ + /** + * Default delay before repeating. + */ + static inline final DEFAULT_DELAY:Float = 0.4; + + /** + * Default interval between repeats. + */ + static inline final DEFAULT_INTERVAL:Float = 0.1; + + /** + * Whether the action for this handler is pressed. + */ + public var pressed(get, never):Bool; + + /** + * Whether the action for this handler is pressed, + * and the handler is ready to repeat. + */ + public var activated(default, null):Bool = false; + + /** + * The Funkin Controls handler. + */ + var controls(get, never):Controls; + + function get_controls():Controls + { + return PlayerSettings.player1.controls; + } + + var action:Action; + + var delay:Float; + var interval:Float; + var gamepadOnly:Bool; + + var pressedTime:Float = 0; + + function new(action:Action, delay:Float = DEFAULT_DELAY, interval:Float = DEFAULT_INTERVAL, gamepadOnly:Bool = false) + { + super(); + this.action = action; + this.delay = delay; + this.interval = interval; + this.gamepadOnly = gamepadOnly; + } + + function get_pressed():Bool + { + return controls.check(action, PRESSED, gamepadOnly); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (pressed) + { + if (pressedTime == 0) + { + activated = true; + } + else if (pressedTime >= (delay + interval)) + { + activated = true; + pressedTime -= interval; + } + else + { + activated = false; + } + pressedTime += elapsed; + } + else + { + pressedTime = 0; + activated = false; + } + } + + /** + * Builds a TurboActionHandler that monitors from a single key. + * @param inputKey The key to monitor. + * @param delay How long to wait before repeating. + * @param repeatDelay How long to wait between repeats. + * @return A TurboActionHandler + */ + public static overload inline extern function build(action:Action, ?delay:Float = DEFAULT_DELAY, ?interval:Float = DEFAULT_INTERVAL, + ?gamepadOnly:Bool = false):TurboActionHandler + { + return new TurboActionHandler(action, delay, interval); + } +} diff --git a/source/funkin/input/TurboButtonHandler.hx b/source/funkin/input/TurboButtonHandler.hx new file mode 100644 index 00000000..63c2a294 --- /dev/null +++ b/source/funkin/input/TurboButtonHandler.hx @@ -0,0 +1,127 @@ +package funkin.input; + +import flixel.input.gamepad.FlxGamepadInputID; +import flixel.input.gamepad.FlxGamepad; +import flixel.FlxBasic; + +/** + * Handles repeating behavior when holding down a gamepad button or button combination. + * + * When the `inputs` are pressed, `activated` will be true for the first frame, + * then wait `delay` seconds before becoming true for one frame every `interval` seconds. + * + * Example: Pressing Ctrl+Z will undo, while holding Ctrl+Z will start to undo repeatedly. + */ +class TurboButtonHandler extends FlxBasic +{ + /** + * Default delay before repeating. + */ + static inline final DEFAULT_DELAY:Float = 0.4; + + /** + * Default interval between repeats. + */ + static inline final DEFAULT_INTERVAL:Float = 0.1; + + /** + * Whether all of the keys for this handler are pressed. + */ + public var allPressed(get, never):Bool; + + /** + * Whether all of the keys for this handler are activated, + * and the handler is ready to repeat. + */ + public var activated(default, null):Bool = false; + + var inputs:Array; + var delay:Float; + var interval:Float; + var targetGamepad:FlxGamepad; + + var allPressedTime:Float = 0; + + function new(inputs:Array, delay:Float = DEFAULT_DELAY, interval:Float = DEFAULT_INTERVAL, ?targetGamepad:FlxGamepad) + { + super(); + this.inputs = inputs; + this.delay = delay; + this.interval = interval; + this.targetGamepad = targetGamepad ?? FlxG.gamepads.firstActive; + } + + function get_allPressed():Bool + { + if (targetGamepad == null) return false; + if (inputs == null || inputs.length == 0) return false; + if (inputs.length == 1) return targetGamepad.anyPressed(inputs); + + // Check if ANY keys are unpressed + for (input in inputs) + { + if (!targetGamepad.anyPressed([input])) return false; + } + return true; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Try to find a gamepad if we don't have one + if (targetGamepad == null) + { + targetGamepad = FlxG.gamepads.firstActive; + } + + if (allPressed) + { + if (allPressedTime == 0) + { + activated = true; + } + else if (allPressedTime >= (delay + interval)) + { + activated = true; + allPressedTime -= interval; + } + else + { + activated = false; + } + allPressedTime += elapsed; + } + else + { + allPressedTime = 0; + activated = false; + } + } + + /** + * Builds a TurboButtonHandler that monitors from a single input. + * @param input The input to monitor. + * @param delay How long to wait before repeating. + * @param repeatDelay How long to wait between repeats. + * @return A TurboKeyHandler + */ + public static overload inline extern function build(input:FlxGamepadInputID, ?delay:Float = DEFAULT_DELAY, + ?interval:Float = DEFAULT_INTERVAL):TurboButtonHandler + { + return new TurboButtonHandler([input], delay, interval); + } + + /** + * Builds a TurboKeyHandler that monitors a key combination. + * @param inputs The combination of inputs to monitor. + * @param delay How long to wait before repeating. + * @param repeatDelay How long to wait between repeats. + * @return A TurboKeyHandler + */ + public static overload inline extern function build(inputs:Array, ?delay:Float = DEFAULT_DELAY, + ?interval:Float = DEFAULT_INTERVAL):TurboButtonHandler + { + return new TurboButtonHandler(inputs, delay, interval); + } +} diff --git a/source/funkin/input/TurboKeyHandler.hx b/source/funkin/input/TurboKeyHandler.hx new file mode 100644 index 00000000..36b8e208 --- /dev/null +++ b/source/funkin/input/TurboKeyHandler.hx @@ -0,0 +1,115 @@ +package funkin.input; + +import flixel.input.keyboard.FlxKey; +import flixel.FlxBasic; + +/** + * Handles repeating behavior when holding down a key or key combination. + * + * When the `keys` are pressed, `activated` will be true for the first frame, + * then wait `delay` seconds before becoming true for one frame every `interval` seconds. + * + * Example: Pressing Ctrl+Z will undo, while holding Ctrl+Z will start to undo repeatedly. + */ +class TurboKeyHandler extends FlxBasic +{ + /** + * Default delay before repeating. + */ + static inline final DEFAULT_DELAY:Float = 0.4; + + /** + * Default interval between repeats. + */ + static inline final DEFAULT_INTERVAL:Float = 0.1; + + /** + * Whether all of the keys for this handler are pressed. + */ + public var allPressed(get, never):Bool; + + /** + * Whether all of the keys for this handler are activated, + * and the handler is ready to repeat. + */ + public var activated(default, null):Bool = false; + + var keys:Array; + var delay:Float; + var interval:Float; + + var allPressedTime:Float = 0; + + function new(keys:Array, delay:Float = DEFAULT_DELAY, interval:Float = DEFAULT_INTERVAL) + { + super(); + this.keys = keys; + this.delay = delay; + this.interval = interval; + } + + function get_allPressed():Bool + { + if (keys == null || keys.length == 0) return false; + if (keys.length == 1) return FlxG.keys.anyPressed(keys); + + // Check if ANY keys are unpressed + for (key in keys) + { + if (!FlxG.keys.anyPressed([key])) return false; + } + return true; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (allPressed) + { + if (allPressedTime == 0) + { + activated = true; + } + else if (allPressedTime >= (delay + interval)) + { + activated = true; + allPressedTime -= interval; + } + else + { + activated = false; + } + allPressedTime += elapsed; + } + else + { + allPressedTime = 0; + activated = false; + } + } + + /** + * Builds a TurboKeyHandler that monitors from a single key. + * @param inputKey The key to monitor. + * @param delay How long to wait before repeating. + * @param repeatDelay How long to wait between repeats. + * @return A TurboKeyHandler + */ + public static overload inline extern function build(inputKey:FlxKey, ?delay:Float = DEFAULT_DELAY, ?interval:Float = DEFAULT_INTERVAL):TurboKeyHandler + { + return new TurboKeyHandler([inputKey], delay, interval); + } + + /** + * Builds a TurboKeyHandler that monitors a key combination. + * @param inputKeys The combination of keys to monitor. + * @param delay How long to wait before repeating. + * @param repeatDelay How long to wait between repeats. + * @return A TurboKeyHandler + */ + public static overload inline extern function build(inputKeys:Array, ?delay:Float = DEFAULT_DELAY, ?interval:Float = DEFAULT_INTERVAL):TurboKeyHandler + { + return new TurboKeyHandler(inputKeys, delay, interval); + } +} diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx new file mode 100644 index 00000000..f739f59b --- /dev/null +++ b/source/funkin/modding/IScriptedClass.hx @@ -0,0 +1,193 @@ +package funkin.modding; + +import funkin.modding.events.ScriptEvent; + +/** + * Defines a set of callbacks available to all scripted classes. + * + * Includes events handling basic life cycle relevant to all scripted classes. + */ +interface IScriptedClass +{ + public function onScriptEvent(event:ScriptEvent):Void; + + public function onCreate(event:ScriptEvent):Void; + public function onDestroy(event:ScriptEvent):Void; + public function onUpdate(event:UpdateScriptEvent):Void; +} + +/** + * Defines an element which can receive script events. + * For example, the PlayState dispatches the event to all its child elements. + */ +interface IEventHandler +{ + public function dispatchEvent(event:ScriptEvent):Void; +} + +/** + * Defines a set of callbacks available to scripted classes which can follow the game between states. + */ +interface IStateChangingScriptedClass extends IScriptedClass +{ + public function onStateChangeBegin(event:StateChangeScriptEvent):Void; + public function onStateChangeEnd(event:StateChangeScriptEvent):Void; + + public function onSubStateOpenBegin(event:SubStateScriptEvent):Void; + public function onSubStateOpenEnd(event:SubStateScriptEvent):Void; + public function onSubStateCloseBegin(event:SubStateScriptEvent):Void; + public function onSubStateCloseEnd(event:SubStateScriptEvent):Void; + + public function onFocusLost(event:FocusScriptEvent):Void; + public function onFocusGained(event:FocusScriptEvent):Void; +} + +/** + * Defines a set of callbacks available to scripted classes which can be added to the current state. + * Generally requires the class to be an instance of FlxBasic. + */ +interface IStateStageProp extends IScriptedClass +{ + /** + * Called when the relevant element is added to the game state. + */ + public function onAdd(event:ScriptEvent):Void; +} + +/** + * Defines a set of callbacks available to scripted classes which represent notes. + */ +interface INoteScriptedClass extends IScriptedClass +{ + /** + * Called when a note enters the field of view and approaches the strumline. + */ + public function onNoteIncoming(event:NoteScriptEvent):Void; + + /** + * Called when EITHER player hits a note. + * Query the note attached to the event to determine if it was hit by the player or CPU. + */ + public function onNoteHit(event:HitNoteScriptEvent):Void; + + /** + * Called when EITHER player (usually the player) misses a note. + */ + public function onNoteMiss(event:NoteScriptEvent):Void; + + /** + * Called when EITHER player (usually the player) drops a hold note. + */ + public function onNoteHoldDrop(event:HoldNoteScriptEvent):Void; +} + +/** + * Defines a set of callbacks available to scripted classes which represent sprites synced with the BPM. + */ +interface IBPMSyncedScriptedClass extends IScriptedClass +{ + /** + * Called once every step of the song. + */ + public function onStepHit(event:SongTimeScriptEvent):Void; + + /** + * Called once every beat of the song. + */ + public function onBeatHit(event:SongTimeScriptEvent):Void; +} + +/** + * Developer note: + * + * I previously considered adding events for onKeyDown, onKeyUp, mouse events, etc. + * However, I realized that you can simply call something like the following within a module: + * `FlxG.state.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);` + * This is more efficient than adding an entire event handler for every key press. + * + * -Eric + */ +/** + * Defines a set of callbacks available to scripted classes that involve the lifecycle of the Play State. + */ +interface IPlayStateScriptedClass extends INoteScriptedClass extends IBPMSyncedScriptedClass +{ + /** + * Called when the game is paused. + * Has properties to set whether the pause easter egg will happen, + * and can be cancelled by scripts. + */ + public function onPause(event:PauseScriptEvent):Void; + + /** + * Called when the game is unpaused. + */ + public function onResume(event:ScriptEvent):Void; + + /** + * Called when the song has been parsed, before notes have been placed. + * Use this to mutate the chart. + */ + public function onSongLoaded(event:SongLoadScriptEvent):Void; + + /** + * Called when the song starts (conductor time is 0 seconds). + */ + public function onSongStart(event:ScriptEvent):Void; + + /** + * Called when the song ends and the song is about to be unloaded. + */ + public function onSongEnd(event:ScriptEvent):Void; + + /** + * Called as the player runs out of health just before the game over substate is entered. + */ + public function onGameOver(event:ScriptEvent):Void; + + /** + * Called when the player restarts the song, either via pause menu or restarting after a game over. + */ + public function onSongRetry(event:SongRetryEvent):Void; + + /** + * Called when the player presses a key when no note is on the strumline. + */ + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void; + + /** + * Called when the song reaches an event. + */ + public function onSongEvent(event:SongEventScriptEvent):Void; + + /** + * Called when the countdown of the song starts. + */ + public function onCountdownStart(event:CountdownScriptEvent):Void; + + /** + * Called when the a part of the countdown happens. + */ + public function onCountdownStep(event:CountdownScriptEvent):Void; + + /** + * Called when the countdown of the song ends. + */ + public function onCountdownEnd(event:CountdownScriptEvent):Void; +} + +/** + * Defines a set of callbacks activated during a dialogue conversation. + */ +interface IDialogueScriptedClass extends IScriptedClass +{ + /** + * Called as the dialogue starts, and before the first dialogue text is displayed. + */ + public function onDialogueStart(event:DialogueScriptEvent):Void; + + public function onDialogueCompleteLine(event:DialogueScriptEvent):Void; + public function onDialogueLine(event:DialogueScriptEvent):Void; + public function onDialogueSkip(event:DialogueScriptEvent):Void; + public function onDialogueEnd(event:DialogueScriptEvent):Void; +} diff --git a/source/funkin/modding/PolymodErrorHandler.hx b/source/funkin/modding/PolymodErrorHandler.hx new file mode 100644 index 00000000..dc387900 --- /dev/null +++ b/source/funkin/modding/PolymodErrorHandler.hx @@ -0,0 +1,91 @@ +package funkin.modding; + +import polymod.Polymod; + +class PolymodErrorHandler +{ + /** + * Show a popup with the given text. + * This displays a system popup, it WILL interrupt the game. + * Make sure to only use this when it's important, like when there's a script error. + * + * @param name The name at the top of the popup. + * @param desc The body text of the popup. + */ + public static function showAlert(name:String, desc:String):Void + { + lime.app.Application.current.window.alert(desc, name); + } + + public static function onPolymodError(error:PolymodError):Void + { + // Perform an action based on the error code. + switch (error.code) + { + case FRAMEWORK_INIT, FRAMEWORK_AUTODETECT, SCRIPT_PARSING: + // Unimportant. + return; + + case MOD_LOAD_PREPARE, MOD_LOAD_DONE: + logInfo('LOADING MOD - ${error.message}'); + + case MISSING_ICON: + logWarn('A mod is missing an icon. Please add one.'); + + case SCRIPT_PARSE_ERROR: + // A syntax error when parsing a script. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Parsing Error', error.message); + case SCRIPT_RUNTIME_EXCEPTION: + // A runtime error when running a script. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Exception', error.message); + case SCRIPT_CLASS_MODULE_NOT_FOUND: + // A scripted class tried to reference an unknown class or module. + logError(error.message); + + // Last word is the class name. + var className:String = error.message.split(' ').pop(); + var msg:String = 'Import error in ${error.origin}'; + msg += '\nCould not import unknown class ${className}'; + msg += '\nCheck to ensure the class exists and is spelled correctly.'; + + // Notify the user via popup. + showAlert('Polymod Script Import Error', msg); + case SCRIPT_CLASS_MODULE_BLACKLISTED: + // A scripted class tried to reference a blacklisted class or module. + logError(error.message); + // Notify the user via popup. + showAlert('Polymod Script Blacklist Violation', error.message); + + default: + // Log the message based on its severity. + switch (error.severity) + { + case NOTICE: + logInfo(error.message); + case WARNING: + logWarn(error.message); + case ERROR: + logError(error.message); + } + } + } + + static function logInfo(message:String):Void + { + trace('[INFO-] ${message}'); + } + + static function logError(message:String):Void + { + trace('[ERROR] ${message}'); + } + + static function logWarn(message:String):Void + { + trace('[WARN-] ${message}'); + } +} diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx new file mode 100644 index 00000000..4ae1d46b --- /dev/null +++ b/source/funkin/modding/PolymodHandler.hx @@ -0,0 +1,497 @@ +package funkin.modding; + +import polymod.fs.ZipFileSystem; +import funkin.data.dialogue.conversation.ConversationRegistry; +import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry; +import funkin.data.dialogue.speaker.SpeakerRegistry; +import funkin.data.event.SongEventRegistry; +import funkin.data.story.level.LevelRegistry; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notekind.NoteKindManager; +import funkin.data.song.SongRegistry; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.stage.StageRegistry; +import funkin.data.stickers.StickerRegistry; +import funkin.data.freeplay.album.AlbumRegistry; +import funkin.modding.module.ModuleHandler; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.save.Save; +import funkin.util.FileUtil; +import funkin.util.macro.ClassMacro; +import polymod.backends.PolymodAssets.PolymodAssetType; +import polymod.format.ParseRules.TextFileFormat; +import polymod.Polymod; + +/** + * A class for interacting with Polymod, the atomic modding framework for Haxe. + */ +class PolymodHandler +{ + /** + * The API version for the current version of the game. Since 0.5.0, we've just made this the game version! + * Minor updates rarely impact mods but major versions often do. + */ + // static final API_VERSION:String = Constants.VERSION; + + /** + * The Semantic Versioning rule + * Indicates which mods are compatible with this version of the game. + * Using more complex rules allows mods from older compatible versions to stay functioning, + * while preventing mods made for future versions from being installed. + */ + static final API_VERSION_RULE:String = ">=0.6.3 <0.7.0"; + + /** + * Where relative to the executable that mods are located. + */ + static final MOD_FOLDER:String = + #if (REDIRECT_ASSETS_FOLDER && macos) + '../../../../../../../example_mods' + #elseif REDIRECT_ASSETS_FOLDER + '../../../../example_mods' + #else + 'mods' + #end; + + static final CORE_FOLDER:Null = + #if (REDIRECT_ASSETS_FOLDER && macos) + '../../../../../../../assets' + #elseif REDIRECT_ASSETS_FOLDER + '../../../../assets' + #else + null + #end; + + public static var loadedModIds:Array = []; + + // Use SysZipFileSystem on desktop and MemoryZipFilesystem on web. + static var modFileSystem:Null = null; + + /** + * If the mods folder doesn't exist, create it. + */ + public static function createModRoot():Void + { + FileUtil.createDirIfNotExists(MOD_FOLDER); + } + + /** + * Loads the game with ALL mods enabled with Polymod. + */ + public static function loadAllMods():Void + { + #if sys + // Create the mod root if it doesn't exist. + createModRoot(); + #end + trace('Initializing Polymod (using all mods)...'); + loadModsById(getAllModIds()); + } + + /** + * Loads the game with configured mods enabled with Polymod. + */ + public static function loadEnabledMods():Void + { + #if sys + // Create the mod root if it doesn't exist. + createModRoot(); + #end + trace('Initializing Polymod (using configured mods)...'); + loadModsById(Save.instance.enabledModIds); + } + + /** + * Loads the game without any mods enabled with Polymod. + */ + public static function loadNoMods():Void + { + #if sys + // Create the mod root if it doesn't exist. + createModRoot(); + #end + // We still need to configure the debug print calls etc. + trace('Initializing Polymod (using no mods)...'); + loadModsById([]); + } + + /** + * Load all the mods with the given ids. + * @param ids The ORDERED list of mod ids to load. + */ + public static function loadModsById(ids:Array):Void + { + if (ids.length == 0) + { + trace('You attempted to load zero mods.'); + } + else + { + trace('Attempting to load ${ids.length} mods...'); + } + + buildImports(); + + if (modFileSystem == null) modFileSystem = buildFileSystem(); + + var loadedModList:Array = polymod.Polymod.init( + { + // Root directory for all mods. + modRoot: MOD_FOLDER, + // The directories for one or more mods to load. + dirs: ids, + // Framework being used to load assets. + framework: OPENFL, + // The current version of our API. + apiVersionRule: API_VERSION_RULE, + // Call this function any time an error occurs. + errorCallback: PolymodErrorHandler.onPolymodError, + // Enforce semantic version patterns for each mod. + // modVersions: null, + // A map telling Polymod what the asset type is for unfamiliar file extensions. + // extensionMap: [], + + customFilesystem: modFileSystem, + + frameworkParams: buildFrameworkParams(), + + // List of filenames to ignore in mods. Use the default list to ignore the metadata file, etc. + ignoredFiles: buildIgnoreList(), + + // Parsing rules for various data formats. + parseRules: buildParseRules(), + + skipDependencyErrors: true, + + // Parse hxc files and register the scripted classes in them. + useScriptedClasses: true, + loadScriptsAsync: #if html5 true #else false #end, + }); + + if (loadedModList == null) + { + trace('An error occurred! Failed when loading mods!'); + } + else + { + if (loadedModList.length == 0) + { + trace('Mod loading complete. We loaded no mods / ${ids.length} mods.'); + } + else + { + trace('Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.'); + } + } + + loadedModIds = []; + for (mod in loadedModList) + { + trace(' * ${mod.title} v${mod.modVersion} [${mod.id}]'); + loadedModIds.push(mod.id); + } + + #if FEATURE_DEBUG_FUNCTIONS + var fileList:Array = Polymod.listModFiles(PolymodAssetType.IMAGE); + trace('Installed mods have replaced ${fileList.length} images.'); + for (item in fileList) + { + trace(' * $item'); + } + + fileList = Polymod.listModFiles(PolymodAssetType.TEXT); + trace('Installed mods have added/replaced ${fileList.length} text files.'); + for (item in fileList) + { + trace(' * $item'); + } + + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC); + trace('Installed mods have replaced ${fileList.length} music files.'); + for (item in fileList) + { + trace(' * $item'); + } + + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND); + trace('Installed mods have replaced ${fileList.length} sound files.'); + for (item in fileList) + { + trace(' * $item'); + } + + fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC); + trace('Installed mods have replaced ${fileList.length} generic audio files.'); + for (item in fileList) + { + trace(' * $item'); + } + #end + } + + static function buildFileSystem():polymod.fs.ZipFileSystem + { + polymod.Polymod.onError = PolymodErrorHandler.onPolymodError; + return new ZipFileSystem( + { + modRoot: MOD_FOLDER, + autoScan: true + }); + } + + static function buildImports():Void + { + // Add default imports for common classes. + Polymod.addDefaultImport(funkin.Assets); + Polymod.addDefaultImport(funkin.Paths); + + // Add import aliases for certain classes. + // NOTE: Scripted classes are automatically aliased to their parent class. + Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint); + + Polymod.addImportAlias('funkin.data.event.SongEventSchema', funkin.data.event.SongEventSchema.SongEventSchemaRaw); + + // `lime.utils.Assets` literally just has a private `resolveClass` function for some reason? so we replace it with our own. + 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` + // Sys.command() can run malicious processes + Polymod.blacklistImport('Sys'); + + // `Reflect` + // Reflect.callMethod() can access blacklisted packages, but some functions are whitelisted + Polymod.addImportAlias('Reflect', funkin.util.ReflectUtil); + + // `Type` + // Type.createInstance(Type.resolveClass()) can access blacklisted packages, but some functions are whitelisted + Polymod.addImportAlias('Type', funkin.util.ReflectUtil); + + // `cpp.Lib` + // Lib.load() can load malicious DLLs + Polymod.blacklistImport('cpp.Lib'); + + // `Unserializer` + // Unserializer.DEFAULT_RESOLVER.resolveClass() can access blacklisted packages + Polymod.blacklistImport('Unserializer'); + + // `lime.system.CFFI` + // Can load and execute compiled binaries. + Polymod.blacklistImport('lime.system.CFFI'); + + // `lime.system.JNI` + // Can load and execute compiled binaries. + Polymod.blacklistImport('lime.system.JNI'); + + // `lime.system.System` + // System.load() can load malicious DLLs + Polymod.blacklistImport('lime.system.System'); + + // `lime.utils.Assets` + // Literally just has a private `resolveClass` function for some reason? + Polymod.blacklistImport('lime.utils.Assets'); + Polymod.blacklistImport('openfl.utils.Assets'); + Polymod.blacklistImport('openfl.Lib'); + Polymod.blacklistImport('openfl.system.ApplicationDomain'); + Polymod.blacklistImport('openfl.net.SharedObject'); + + // `openfl.desktop.NativeProcess` + // Can load native processes on the host operating system. + Polymod.blacklistImport('openfl.desktop.NativeProcess'); + + // `funkin.api.*` + // Contains functions which may allow for cheating and such. + for (cls in ClassMacro.listClassesInPackage('funkin.api')) + { + if (cls == null) continue; + var className:String = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + + // `polymod.*` + // Contains functions which may allow for un-blacklisting other modules. + for (cls in ClassMacro.listClassesInPackage('polymod')) + { + if (cls == null) continue; + var className:String = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + + // `funkin.api.newgrounds.*` + // Contains functions which allow for cheating medals and leaderboards. + for (cls in ClassMacro.listClassesInPackage('funkin.api.newgrounds')) + { + if (cls == null) continue; + var className:String = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + + // `io.newgrounds.*` + // Contains functions which allow for cheating medals and leaderboards. + for (cls in ClassMacro.listClassesInPackage('io.newgrounds')) + { + if (cls == null) continue; + var className:String = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + + // `sys.*` + // Access to system utilities such as the file system. + for (cls in ClassMacro.listClassesInPackage('sys')) + { + if (cls == null) continue; + var className:String = Type.getClassName(cls); + Polymod.blacklistImport(className); + } + } + + /** + * Build a list of file paths that will be ignored in mods. + */ + static function buildIgnoreList():Array + { + var result = Polymod.getDefaultIgnoreList(); + + result.push('.git'); + result.push('.gitignore'); + result.push('.gitattributes'); + result.push('README.md'); + + return result; + } + + static function buildParseRules():polymod.format.ParseRules + { + var output:polymod.format.ParseRules = polymod.format.ParseRules.getDefault(); + // Ensure TXT files have merge support. + output.addType('txt', TextFileFormat.LINES); + // Ensure script files have merge support. + output.addType('hscript', TextFileFormat.PLAINTEXT); + output.addType('hxs', TextFileFormat.PLAINTEXT); + output.addType('hxc', TextFileFormat.PLAINTEXT); + output.addType('hx', TextFileFormat.PLAINTEXT); + + // You can specify the format of a specific file, with file extension. + // output.addFile("data/introText.txt", TextFileFormat.LINES) + return output; + } + + static inline function buildFrameworkParams():polymod.Polymod.FrameworkParams + { + return { + assetLibraryPaths: [ + 'default' => 'preload', + 'shared' => 'shared', + 'songs' => 'songs', + 'videos' => 'videos', + 'tutorial' => 'tutorial', + 'week1' => 'week1', + 'week2' => 'week2', + 'week3' => 'week3', + 'week4' => 'week4', + 'week5' => 'week5', + 'week6' => 'week6', + 'week7' => 'week7', + 'weekend1' => 'weekend1', + ], + coreAssetRedirect: CORE_FOLDER, + } + } + + /** + * Retrieve a list of metadata for ALL installed mods, including disabled mods. + * @return An array of mod metadata + */ + public static function getAllMods():Array + { + trace('Scanning the mods folder...'); + + if (modFileSystem == null) modFileSystem = buildFileSystem(); + + var modMetadata:Array = Polymod.scan( + { + modRoot: MOD_FOLDER, + apiVersionRule: API_VERSION_RULE, + fileSystem: modFileSystem, + errorCallback: PolymodErrorHandler.onPolymodError + }); + trace('Found ${modMetadata.length} mods when scanning.'); + return modMetadata; + } + + /** + * Retrieve a list of ALL mod IDs, including disabled mods. + * @return An array of mod IDs + */ + public static function getAllModIds():Array + { + var modIds:Array = [for (i in getAllMods()) i.id]; + return modIds; + } + + /** + * Retrieve a list of metadata for all enabled mods. + * @return An array of mod metadata + */ + public static function getEnabledMods():Array + { + var modIds:Array = Save.instance.enabledModIds; + var modMetadata:Array = getAllMods(); + var enabledMods:Array = []; + for (item in modMetadata) + { + if (modIds.indexOf(item.id) != -1) + { + enabledMods.push(item); + } + } + return enabledMods; + } + + /** + * Clear and reload from disk all data assets. + * Useful for "hot reloading" for fast iteration! + */ + public static function forceReloadAssets():Void + { + // Forcibly clear scripts so that scripts can be edited. + ModuleHandler.clearModuleCache(); + Polymod.clearScripts(); + + // Forcibly reload Polymod so it finds any new files. + // TODO: Replace this with loadEnabledMods(). + funkin.modding.PolymodHandler.loadAllMods(); + + // Reload scripted classes so stages and modules will update. + Polymod.registerAllScriptClasses(); + + // Reload everything that is cached. + // Currently this freezes the game for a second but I guess that's tolerable? + + // TODO: Reload event callbacks + + // These MUST be imported at the top of the file and not referred to by fully qualified name, + // to ensure build macros work properly. + SongEventRegistry.loadEventCache(); + + SongRegistry.instance.loadEntries(); + LevelRegistry.instance.loadEntries(); + NoteStyleRegistry.instance.loadEntries(); + PlayerRegistry.instance.loadEntries(); + ConversationRegistry.instance.loadEntries(); + DialogueBoxRegistry.instance.loadEntries(); + SpeakerRegistry.instance.loadEntries(); + AlbumRegistry.instance.loadEntries(); + StageRegistry.instance.loadEntries(); + StickerRegistry.instance.loadEntries(); + + CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry. + NoteKindManager.loadScripts(); + ModuleHandler.loadModuleCache(); + } +} diff --git a/source/funkin/modding/base/README.md b/source/funkin/modding/base/README.md new file mode 100644 index 00000000..ebcea06f --- /dev/null +++ b/source/funkin/modding/base/README.md @@ -0,0 +1,5 @@ +# modding.base + +This package is used to allow modders to create scripted classes which extend these base classes. +For example, one script can extend FlxSprite and another can call `ScriptedFlxSprite.init('ClassName')`. +Most of these scripted class stubs are not used by the game itself, so this package has been explicitly marked to be ignored by DCE. diff --git a/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx b/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx new file mode 100644 index 00000000..e9ffd450 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxAtlasSprite.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxAtlasSprite + * Create a scripted class that extends FlxAtlasSprite to use this. + */ +@:hscriptClass +class ScriptedFlxAtlasSprite extends funkin.graphics.adobeanimate.FlxAtlasSprite implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx b/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx new file mode 100644 index 00000000..0ef058d8 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxRuntimeShader.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxRuntimeShader. + * Create a scripted class that extends FlxRuntimeShader to use this. + */ +@:hscriptClass +class ScriptedFlxRuntimeShader extends flixel.addons.display.FlxRuntimeShader implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxSprite.hx b/source/funkin/modding/base/ScriptedFlxSprite.hx new file mode 100644 index 00000000..0bd2e9da --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxSprite.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxSprite. + * Create a scripted class that extends FlxSprite to use this. + */ +@:hscriptClass +class ScriptedFlxSprite extends flixel.FlxSprite implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx new file mode 100644 index 00000000..1391cb44 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxSpriteGroup. + * Create a scripted class that extends FlxSpriteGroup to use this. + */ +@:hscriptClass +class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxState.hx b/source/funkin/modding/base/ScriptedFlxState.hx new file mode 100644 index 00000000..8b659328 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxState.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxState. + * Create a scripted class that extends FlxState to use this. + */ +@:hscriptClass +class ScriptedFlxState extends flixel.FlxState implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxSubState.hx b/source/funkin/modding/base/ScriptedFlxSubState.hx new file mode 100644 index 00000000..a9812c10 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxSubState.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxSubState. + * Create a scripted class that extends FlxSubState to use this. + */ +@:hscriptClass +class ScriptedFlxSubState extends flixel.FlxSubState implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFlxTransitionableState.hx b/source/funkin/modding/base/ScriptedFlxTransitionableState.hx new file mode 100644 index 00000000..2452b93a --- /dev/null +++ b/source/funkin/modding/base/ScriptedFlxTransitionableState.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to an FlxTransitionableState. + * Create a scripted class that extends FlxTransitionableState to use this. + */ +@:hscriptClass +class ScriptedFlxTransitionableState extends flixel.addons.transition.FlxTransitionableState implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedFunkinSprite.hx b/source/funkin/modding/base/ScriptedFunkinSprite.hx new file mode 100644 index 00000000..2ce84db3 --- /dev/null +++ b/source/funkin/modding/base/ScriptedFunkinSprite.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to a FunkinSprite. + * Create a scripted class that extends FunkinSprite to use this. + */ +@:hscriptClass +class ScriptedFunkinSprite extends funkin.graphics.FunkinSprite implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedMusicBeatState.hx b/source/funkin/modding/base/ScriptedMusicBeatState.hx new file mode 100644 index 00000000..6dc6826c --- /dev/null +++ b/source/funkin/modding/base/ScriptedMusicBeatState.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to a MusicBeatState. + * Create a scripted class that extends MusicBeatState to use this. + */ +@:hscriptClass +class ScriptedMusicBeatState extends funkin.ui.MusicBeatState implements HScriptedClass {} diff --git a/source/funkin/modding/base/ScriptedMusicBeatSubState.hx b/source/funkin/modding/base/ScriptedMusicBeatSubState.hx new file mode 100644 index 00000000..79c98ea3 --- /dev/null +++ b/source/funkin/modding/base/ScriptedMusicBeatSubState.hx @@ -0,0 +1,8 @@ +package funkin.modding.base; + +/** + * A script that can be tied to a MusicBeatSubState. + * Create a scripted class that extends MusicBeatSubState to use this. + */ +@:hscriptClass +class ScriptedMusicBeatSubState extends funkin.ui.MusicBeatSubState implements HScriptedClass {} diff --git a/source/funkin/modding/base/import.hx b/source/funkin/modding/base/import.hx new file mode 100644 index 00000000..957fb894 --- /dev/null +++ b/source/funkin/modding/base/import.hx @@ -0,0 +1 @@ +import polymod.hscript.HScriptedClass; diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx new file mode 100644 index 00000000..6f4742a2 --- /dev/null +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -0,0 +1,556 @@ +package funkin.modding.events; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import flixel.FlxState; +import flixel.FlxSubState; +import funkin.play.notes.NoteSprite; +import funkin.play.notes.SustainTrail; +import funkin.play.cutscene.dialogue.Conversation; +import funkin.play.Countdown.CountdownStep; +import funkin.play.notes.NoteDirection; +import openfl.events.KeyboardEvent; + +/** + * This is a base class for all events that are issued to scripted classes. + * It can be used to identify the type of event called, store data, and cancel event propagation. + */ +class ScriptEvent +{ + /** + * If true, the behavior associated with this event can be prevented. + * For example, cancelling COUNTDOWN_START should prevent the countdown from starting, + * until another script restarts it, or cancelling NOTE_HIT should cause the note to be missed. + */ + public var cancelable(default, null):Bool; + + /** + * The type associated with the event. + */ + public var type(default, null):ScriptEventType; + + /** + * Whether the event should continue to be triggered on additional targets. + */ + public var shouldPropagate(default, null):Bool; + + /** + * Whether the event has been canceled by one of the scripts that received it. + */ + public var eventCanceled(default, null):Bool; + + public function new(type:ScriptEventType, cancelable:Bool = false):Void + { + this.type = type; + this.cancelable = cancelable; + this.eventCanceled = false; + this.shouldPropagate = true; + } + + /** + * Call this function on a cancelable event to cancel the associated behavior. + * For example, cancelling COUNTDOWN_START will prevent the countdown from starting. + */ + public function cancelEvent():Void + { + if (cancelable) + { + eventCanceled = true; + } + } + + /** + * Cancel this event. + * This is an alias for cancelEvent() but I make this typo all the time. + */ + public function cancel():Void + { + cancelEvent(); + } + + /** + * Call this function to stop any other Scripteds from receiving the event. + */ + public function stopPropagation():Void + { + shouldPropagate = false; + } + + public function toString():String + { + return 'ScriptEvent(type=$type, cancelable=$cancelable)'; + } +} + +/** + * SPECIFIC EVENTS + */ +/** + * An event that is fired associated with a specific note. + */ +class NoteScriptEvent extends ScriptEvent +{ + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var note(default, null):NoteSprite; + + /** + * The combo count as it is with this event. + * Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled). + */ + public var comboCount(default, null):Int; + + /** + * Whether to play the record scratch sound (if this event type is `NOTE_MISS`). + */ + public var playSound(default, default):Bool; + + /** + * The health gained or lost from this note. + * This affects both hits and misses. Remember that max health is 2.00. + */ + public var healthChange:Float; + + public function new(type:ScriptEventType, note:NoteSprite, healthChange:Float, comboCount:Int = 0, cancelable:Bool = false):Void + { + super(type, cancelable); + this.note = note; + this.comboCount = comboCount; + this.playSound = true; + this.healthChange = healthChange; + } + + public override function toString():String + { + return 'NoteScriptEvent(type=' + type + ', cancelable=' + cancelable + ', note=' + note + ', comboCount=' + comboCount + ')'; + } +} + +class HitNoteScriptEvent extends NoteScriptEvent +{ + /** + * The judgement the player received for hitting the note. + */ + public var judgement:String; + + /** + * The score the player received for hitting the note. + */ + public var score:Int; + + /** + * If the hit causes a combo break. + */ + public var isComboBreak:Bool = false; + + /** + * The time difference when the player hit the note + */ + public var hitDiff:Float = 0; + + /** + * Whether this note hit causes a note splash to display. + * Defaults to true only on "sick" notes. + */ + public var doesNotesplash:Bool = false; + + public function new(note:NoteSprite, healthChange:Float, score:Int, judgement:String, isComboBreak:Bool, comboCount:Int = 0, hitDiff:Float = 0, + doesNotesplash:Bool = false):Void + { + super(NOTE_HIT, note, healthChange, comboCount, true); + this.score = score; + this.judgement = judgement; + this.isComboBreak = isComboBreak; + this.doesNotesplash = doesNotesplash; + this.hitDiff = hitDiff; + } + + public override function toString():String + { + return 'HitNoteScriptEvent(note=' + note + ', comboCount=' + comboCount + ', judgement=' + judgement + ', score=' + score + ', isComboBreak=' + + isComboBreak + ', hitDiff=' + hitDiff + ', doesNotesplash=' + doesNotesplash + ')'; + } +} + +/** + * An event that is fired when you press a key with no note present. + */ +class GhostMissNoteScriptEvent extends ScriptEvent +{ + /** + * The direction that was mistakenly pressed. + */ + public var dir(default, null):NoteDirection; + + /** + * Whether there was a note within judgement range when this ghost note was pressed. + */ + public var hasPossibleNotes(default, null):Bool; + + /** + * How much health should be lost when this ghost note is pressed. + * Remember that max health is 2.00. + */ + public var healthChange(default, default):Float; + + /** + * How much score should be lost when this ghost note is pressed. + */ + public var scoreChange(default, default):Int; + + /** + * Whether to play the record scratch sound. + */ + public var playSound(default, default):Bool; + + /** + * Whether to play the miss animation on the player. + */ + public var playAnim(default, default):Bool; + + public function new(dir:NoteDirection, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void + { + super(NOTE_GHOST_MISS, true); + this.dir = dir; + this.hasPossibleNotes = hasPossibleNotes; + this.healthChange = healthChange; + this.scoreChange = scoreChange; + this.playSound = true; + this.playAnim = true; + } + + public override function toString():String + { + return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')'; + } +} + +class HoldNoteScriptEvent extends NoteScriptEvent +{ + /** + * The hold note that was hit (or dropped). + */ + public var holdNote:SustainTrail; + + /** + * The score the player received for hitting the note. + */ + public var score:Int; + + /** + * If the hit causes a combo break. + */ + public var isComboBreak:Bool = false; + + /** + * The time difference when the player hit the note + */ + public var hitDiff:Float = 0; + + /** + * Whether this note hit causes a note splash to display. + * Defaults to true only on "sick" notes. + */ + public var doesNotesplash:Bool = false; + + public function new(type:ScriptEventType, holdNote:SustainTrail, healthChange:Float, score:Int, isComboBreak:Bool, cancelable:Bool = false):Void + { + super(type, null, healthChange, comboCount, true); + this.holdNote = holdNote; + this.score = score; + this.isComboBreak = isComboBreak; + } + + public override function toString():String + { + return 'HoldNoteScriptEvent(type=$type, holdNote=$holdNote, healthChange=$healthChange, score=$score, isComboBreak=$isComboBreak, cancelable=$cancelable)'; + } +} + +/** + * An event that is fired when the song reaches an event. + */ +class SongEventScriptEvent extends ScriptEvent +{ + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var eventData(default, null):funkin.data.song.SongData.SongEventData; + + public function new(eventData:funkin.data.song.SongData.SongEventData):Void + { + super(SONG_EVENT, true); + this.eventData = eventData; + } + + public override function toString():String + { + return 'SongEventScriptEvent(event=' + eventData + ')'; + } +} + +/** + * An event that is fired during the update loop. + */ +class UpdateScriptEvent extends ScriptEvent +{ + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var elapsed(default, null):Float; + + public function new(elapsed:Float):Void + { + super(UPDATE, false); + this.elapsed = elapsed; + } + + public override function toString():String + { + return 'UpdateScriptEvent(elapsed=$elapsed)'; + } +} + +/** + * An event that is fired regularly during the song. + * May be on beat or on step. + */ +class SongTimeScriptEvent extends ScriptEvent +{ + /** + * The current beat of the song. + */ + public var beat(default, null):Int; + + /** + * The current step of the song. + */ + public var step(default, null):Int; + + public function new(type:ScriptEventType, beat:Int, step:Int):Void + { + super(type, true); + this.beat = beat; + this.step = step; + } + + public override function toString():String + { + return 'SongTimeScriptEvent(type=' + type + ', beat=' + beat + ', step=' + step + ')'; + } +} + +/** + * An event that is fired regularly during the song. + * May be on beat or on step. + */ +class CountdownScriptEvent extends ScriptEvent +{ + /** + * The current step of the countdown. + */ + public var step(default, null):CountdownStep; + + public function new(type:ScriptEventType, step:CountdownStep, cancelable:Bool = true):Void + { + super(type, cancelable); + this.step = step; + } + + public override function toString():String + { + return 'CountdownScriptEvent(type=' + type + ', step=' + step + ')'; + } +} + +/** + * An event that is fired during a dialogue. + */ +class DialogueScriptEvent extends ScriptEvent +{ + /** + * The dialogue being referenced by the event. + */ + public var conversation(default, null):Conversation; + + public function new(type:ScriptEventType, conversation:Conversation, cancelable:Bool = true):Void + { + super(type, cancelable); + this.conversation = conversation; + } + + public override function toString():String + { + return 'DialogueScriptEvent(type=$type, conversation=$conversation)'; + } +} + +/** + * An event that is fired when the player presses a key. + */ +class KeyboardInputScriptEvent extends ScriptEvent +{ + /** + * The associated keyboard event. + */ + public var event(default, null):KeyboardEvent; + + public function new(type:ScriptEventType, event:KeyboardEvent):Void + { + super(type, false); + this.event = event; + } + + public override function toString():String + { + return 'KeyboardInputScriptEvent(type=' + type + ', event=' + event + ')'; + } +} + +/** + * An event that is fired once the song's chart has been parsed. + */ +class SongLoadScriptEvent extends ScriptEvent +{ + /** + * The note associated with this event. + * You cannot replace it, but you can edit it. + */ + public var notes(default, set):Array; + + public var events(default, set):Array; + + public var id(default, null):String; + + public var difficulty(default, null):String; + + function set_notes(notes:Array):Array + { + this.notes = notes; + return this.notes; + } + + function set_events(events:Array):Array + { + this.events = events; + return this.events; + } + + public function new(id:String, difficulty:String, notes:Array, events:Array):Void + { + super(SONG_LOADED, false); + this.id = id; + this.difficulty = difficulty; + this.notes = notes; + this.events = events; + } + + public override function toString():String + { + var noteStr = notes == null ? 'null' : 'Array(' + notes.length + ')'; + return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)'; + } +} + +/** + * AAn event that is fired when the player retries the song. + */ +class SongRetryEvent extends ScriptEvent +{ + /** + * The new difficulty of the song. + */ + public var difficulty(default, null):String; + + public function new(difficulty:String):Void + { + super(SONG_RETRY, false); + this.difficulty = difficulty; + } + + public override function toString():String + { + return 'SongRetryEvent(difficulty=$difficulty)'; + } +} + +/** + * An event that is fired when moving out of or into an FlxState. + */ +class StateChangeScriptEvent extends ScriptEvent +{ + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxState; + + public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void + { + super(type, cancelable); + this.targetState = targetState; + } + + public override function toString():String + { + return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')'; + } +} + +/** + * An event that is fired when the game loses or gains focus. + */ +class FocusScriptEvent extends ScriptEvent +{ + public function new(type:ScriptEventType):Void + { + super(type, false); + } + + public override function toString():String + { + return 'FocusScriptEvent(type=' + type + ')'; + } +} + +/** + * An event that is fired when moving out of or into an FlxSubState. + */ +class SubStateScriptEvent extends ScriptEvent +{ + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxSubState; + + public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void + { + super(type, cancelable); + this.targetState = targetState; + } + + public override function toString():String + { + return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')'; + } +} + +/** + * An event which is called when the player attempts to pause the game. + */ +class PauseScriptEvent extends ScriptEvent +{ + /** + * Whether to use the Gitaroo Man pause. + */ + public var gitaroo(default, default):Bool; + + public function new(gitaroo:Bool):Void + { + super(PAUSE, true); + this.gitaroo = gitaroo; + } +} diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx new file mode 100644 index 00000000..7d6e3992 --- /dev/null +++ b/source/funkin/modding/events/ScriptEventDispatcher.hx @@ -0,0 +1,226 @@ +package funkin.modding.events; + +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.modding.IScriptedClass; + +/** + * Utility functions to assist with handling scripted classes. + */ +class ScriptEventDispatcher +{ + /** + * Invoke the given event hook on the given scripted class. + * @param target The target class to call script hooks on. + * @param event The event, which determines the script hook to call and provides parameters for it. + */ + public static function callEvent(target:Null, event:ScriptEvent):Void + { + if (target == null || event == null) return; + + target.onScriptEvent(event); + + // If one target says to stop propagation, stop. + if (!event.shouldPropagate) + { + return; + } + + // IScriptedClass + switch (event.type) + { + case CREATE: + target.onCreate(event); + return; + case DESTROY: + target.onDestroy(event); + return; + case UPDATE: + target.onUpdate(cast event); + return; + default: // Continue; + } + + if (Std.isOfType(target, IStateStageProp)) + { + var t:IStateStageProp = cast(target, IStateStageProp); + switch (event.type) + { + case ADDED: + t.onAdd(cast event); + return; + default: // Continue; + } + } + + if (Std.isOfType(target, IDialogueScriptedClass)) + { + var t:IDialogueScriptedClass = cast(target, IDialogueScriptedClass); + switch (event.type) + { + case DIALOGUE_START: + t.onDialogueStart(cast event); + return; + case DIALOGUE_LINE: + t.onDialogueLine(cast event); + return; + case DIALOGUE_COMPLETE_LINE: + t.onDialogueCompleteLine(cast event); + return; + case DIALOGUE_SKIP: + t.onDialogueSkip(cast event); + return; + case DIALOGUE_END: + t.onDialogueEnd(cast event); + return; + default: // Continue; + } + } + + if (Std.isOfType(target, INoteScriptedClass)) + { + var t:INoteScriptedClass = cast(target, INoteScriptedClass); + switch (event.type) + { + case NOTE_INCOMING: + t.onNoteIncoming(cast event); + return; + case NOTE_HIT: + t.onNoteHit(cast event); + return; + case NOTE_MISS: + t.onNoteMiss(cast event); + return; + case NOTE_HOLD_DROP: + t.onNoteHoldDrop(cast event); + return; + default: // Continue; + } + } + + if (Std.isOfType(target, IBPMSyncedScriptedClass)) + { + var t:IBPMSyncedScriptedClass = cast(target, IBPMSyncedScriptedClass); + switch (event.type) + { + case SONG_BEAT_HIT: + t.onBeatHit(cast event); + return; + case SONG_STEP_HIT: + t.onStepHit(cast event); + return; + default: // Continue; + } + } + + if (Std.isOfType(target, IPlayStateScriptedClass)) + { + var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass); + switch (event.type) + { + case NOTE_GHOST_MISS: + t.onNoteGhostMiss(cast event); + return; + case SONG_START: + t.onSongStart(event); + return; + case SONG_END: + t.onSongEnd(event); + return; + case SONG_RETRY: + t.onSongRetry(cast event); + return; + case GAME_OVER: + t.onGameOver(event); + return; + case PAUSE: + t.onPause(cast event); + return; + case RESUME: + t.onResume(event); + return; + case SONG_EVENT: + t.onSongEvent(cast event); + return; + case COUNTDOWN_START: + t.onCountdownStart(cast event); + return; + case COUNTDOWN_STEP: + t.onCountdownStep(cast event); + return; + case COUNTDOWN_END: + t.onCountdownEnd(cast event); + return; + case SONG_LOADED: + t.onSongLoaded(cast event); + return; + default: // Continue; + } + } + + if (Std.isOfType(target, IStateChangingScriptedClass)) + { + var t = cast(target, IStateChangingScriptedClass); + switch (event.type) + { + case STATE_CHANGE_BEGIN: + t.onStateChangeBegin(cast event); + return; + case STATE_CHANGE_END: + t.onStateChangeEnd(cast event); + return; + case SUBSTATE_OPEN_BEGIN: + t.onSubStateOpenBegin(cast event); + return; + case SUBSTATE_OPEN_END: + t.onSubStateOpenEnd(cast event); + return; + case SUBSTATE_CLOSE_BEGIN: + t.onSubStateCloseBegin(cast event); + return; + case SUBSTATE_CLOSE_END: + t.onSubStateCloseEnd(cast event); + return; + case FOCUS_LOST: + t.onFocusLost(cast event); + return; + case FOCUS_GAINED: + t.onFocusGained(cast event); + return; + default: // Continue; + } + } + else + { + // Prevent "NO HELPER error." + return; + } + + // If you get a crash on this line, that means ERIC FUCKED UP! + // throw 'No function called for event type: ${event.type}'; + } + + public static function callEventOnAllTargets(targets:Iterator, event:ScriptEvent):Void + { + if (targets == null || event == null) return; + + if (Std.isOfType(targets, Array)) + { + var t = cast(targets, Array); + if (t.length == 0) return; + } + + for (target in targets) + { + var t:IScriptedClass = cast target; + if (t == null) continue; + + callEvent(t, event); + + // If one target says to stop propagation, stop. + if (!event.shouldPropagate) + { + return; + } + } + } +} diff --git a/source/funkin/modding/events/ScriptEventType.hx b/source/funkin/modding/events/ScriptEventType.hx new file mode 100644 index 00000000..02de0dc1 --- /dev/null +++ b/source/funkin/modding/events/ScriptEventType.hx @@ -0,0 +1,300 @@ +package funkin.modding.events; + +enum abstract ScriptEventType(String) from String to String +{ + /** + * Called when the relevant object is created. + * Keep in mind that the constructor may be called before the object is needed, + * for the purposes of caching data or otherwise. + * + * This event is not cancelable. + */ + var CREATE = 'CREATE'; + + /** + * Called when the relevant object is destroyed. + * This should perform relevant cleanup to ensure good performance. + * + * This event is not cancelable. + */ + var DESTROY = 'DESTROY'; + + /** + * Called when the relevant object is added to the game state. + * This assumes all data is loaded and ready to go. + * + * This event is not cancelable. + */ + var ADDED = 'ADDED'; + + /** + * Called during the update function. + * This is called every frame, so be careful! + * + * This event is not cancelable. + */ + var UPDATE = 'UPDATE'; + + /** + * Called when the player moves to pause the game. + * + * This event IS cancelable! Canceling the event will prevent the game from pausing. + */ + var PAUSE = 'PAUSE'; + + /** + * Called when the player moves to unpause the game while paused. + * + * This event IS cancelable! Canceling the event will prevent the game from resuming. + */ + var RESUME = 'RESUME'; + + /** + * Called once per step in the song. This happens 4 times per measure. + * + * This event is not cancelable. + */ + var SONG_BEAT_HIT = 'BEAT_HIT'; + + /** + * Called once per step in the song. This happens 16 times per measure. + * + * This event is not cancelable. + */ + var SONG_STEP_HIT = 'STEP_HIT'; + + /** + * Called when a note comes on screen and starts approaching the strumline. + * + * This event is not cancelable. + */ + var NOTE_INCOMING = 'NOTE_INCOMING'; + + /** + * Called when a character hits a note. + * Important information such as judgement/timing, note data, player/opponent, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being hit, + * and will likely result in a miss later. + */ + var NOTE_HIT = 'NOTE_HIT'; + + /** + * Called when a character misses a note. + * Important information such as note data, player/opponent, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being considered missed, + * avoiding a combo break and lost health. + */ + var NOTE_MISS = 'NOTE_MISS'; + + /** + * Called when a character lets go of a hold note. + * Important information such as note data, player/opponent, etc. are all provided. + * + * This event is not cancelable. + */ + var NOTE_HOLD_DROP = 'NOTE_HOLD_DROP'; + + /** + * Called when a character presses a note when there was none there, causing them to lose health. + * Important information such as direction pressed, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being considered missed, + * avoiding lost health/score and preventing the miss animation. + */ + var NOTE_GHOST_MISS = 'NOTE_GHOST_MISS'; + + /** + * Called when a song event is reached in the chart. + * + * This event IS cancelable! Cancelling this event prevents the event from being triggered, + * thus blocking its normal functionality. + */ + var SONG_EVENT = 'SONG_EVENT'; + + /** + * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin. + * + * This event is not cancelable. + */ + var SONG_START = 'SONG_START'; + + /** + * Called when the song ends. This happens as the instrumental and vocals end. + * + * This event is not cancelable. + */ + var SONG_END = 'SONG_END'; + + /** + * Called when the countdown begins. This occurs before the song starts. + * + * This event IS cancelable! Canceling this event will prevent the countdown from starting. + * - The song will not start until you call Countdown.performCountdown() later. + * - Note that calling performCountdown() will trigger this event again, so be sure to add logic to ignore it. + */ + var COUNTDOWN_START = 'COUNTDOWN_START'; + + /** + * Called when a step of the countdown happens. + * Includes information about what step of the countdown was hit. + * + * This event IS cancelable! Canceling this event will pause the countdown. + * - The countdown will not resume until you call PlayState.resumeCountdown(). + */ + var COUNTDOWN_STEP = 'COUNTDOWN_STEP'; + + /** + * Called when the countdown is done but just before the song starts. + * + * This event is not cancelable. + */ + var COUNTDOWN_END = 'COUNTDOWN_END'; + + /** + * Called before the game over screen triggers and the death animation plays. + * + * This event is not cancelable. + */ + var GAME_OVER = 'GAME_OVER'; + + /** + * Called after the player presses a key to restart the game. + * This can happen from the pause menu or the game over screen. + * + * This event IS cancelable! Canceling this event will prevent the game from restarting. + */ + var SONG_RETRY = 'SONG_RETRY'; + + /** + * Called when the player pushes down any key on the keyboard. + * + * This event is not cancelable. + */ + var KEY_DOWN = 'KEY_DOWN'; + + /** + * Called when the player releases a key on the keyboard. + * + * This event is not cancelable. + */ + var KEY_UP = 'KEY_UP'; + + /** + * Called when the game has finished loading the notes from JSON. + * This allows modders to mutate the notes before they are used in the song. + * + * This event is not cancelable. + */ + var SONG_LOADED = 'SONG_LOADED'; + + /** + * Called when the game is about to switch the current FlxState. + * + * This event is not cancelable. + */ + var STATE_CHANGE_BEGIN = 'STATE_CHANGE_BEGIN'; + + /** + * Called when the game has finished switching the current FlxState. + * + * This event is not cancelable. + */ + var STATE_CHANGE_END = 'STATE_CHANGE_END'; + + /** + * Called when the game is about to open a new FlxSubState. + * + * This event is not cancelable. + */ + var SUBSTATE_OPEN_BEGIN = 'SUBSTATE_OPEN_BEGIN'; + + /** + * Called when the game has finished opening a new FlxSubState. + * + * This event is not cancelable. + */ + var SUBSTATE_OPEN_END = 'SUBSTATE_OPEN_END'; + + /** + * Called when the game is about to close the current FlxSubState. + * + * This event is not cancelable. + */ + var SUBSTATE_CLOSE_BEGIN = 'SUBSTATE_CLOSE_BEGIN'; + + /** + * Called when the game has finished closing the current FlxSubState. + * + * This event is not cancelable. + */ + var SUBSTATE_CLOSE_END = 'SUBSTATE_CLOSE_END'; + + /** + * Called when the game regains focus. + * + * This event is not cancelable. + */ + var FOCUS_GAINED = 'FOCUS_GAINED'; + + /** + * Called when the game loses focus. + * + * This event is not cancelable. + */ + var FOCUS_LOST = 'FOCUS_LOST'; + + /** + * Called when the game starts a conversation. + * + * This event is not cancelable. + */ + var DIALOGUE_START = 'DIALOGUE_START'; + + /** + * Called to display the next line of conversation. + * + * This event IS cancelable! Canceling this event will prevent the conversation from moving to the next line. + * - This event is called when the conversation starts, or when the user presses ACCEPT to advance the conversation. + */ + var DIALOGUE_LINE = 'DIALOGUE_LINE'; + + /** + * Called to skip scrolling the current line of conversation. + * + * This event IS cancelable! Canceling this event will prevent the conversation from skipping to the next line. + * - This event is called when the user presses ACCEPT to advance the conversation while it is already advancing. + */ + var DIALOGUE_COMPLETE_LINE = 'DIALOGUE_COMPLETE_LINE'; + + /** + * Called to skip the conversation. + * + * This event IS cancelable! Canceling this event will prevent the conversation from skipping. + */ + var DIALOGUE_SKIP = 'DIALOGUE_SKIP'; + + /** + * Called when the game ends a conversation. + * + * This event is not cancelable. + */ + var DIALOGUE_END = 'DIALOGUE_END'; + + /** + * Allow for comparing `ScriptEventType` to `String`. + */ + @:op(A == B) private static inline function equals(a:ScriptEventType, b:String):Bool + { + return (a : String) == b; + } + + /** + * Allow for comparing `ScriptEventType` to `String`. + */ + @:op(A != B) private static inline function notEquals(a:ScriptEventType, b:String):Bool + { + return (a : String) != b; + } +} diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx new file mode 100644 index 00000000..d53639f0 --- /dev/null +++ b/source/funkin/modding/module/Module.hx @@ -0,0 +1,213 @@ +package funkin.modding.module; + +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.modding.IScriptedClass.IStateChangingScriptedClass; +import funkin.modding.events.ScriptEvent; + +/** + * A module is a scripted class which receives all events without requiring a specific context. + * You may have the module active at all times, or only when another script enables it. + */ +class Module implements IPlayStateScriptedClass implements IStateChangingScriptedClass +{ + /** + * Whether the module is currently active. + */ + public var active(default, set):Bool = true; + + function set_active(value:Bool):Bool + { + return this.active = value; + } + + public var moduleId(default, null):String = 'UNKNOWN'; + + /** + * Determines the order in which modules receive events. + * You can modify this to change the order in which a given module receives events. + * + * Priority 1 is processed before Priority 1000, etc. + */ + public var priority(default, set):Int; + + function set_priority(value:Int):Int + { + this.priority = value; + @:privateAccess + ModuleHandler.reorderModuleCache(); + return value; + } + + /** + * Called when the module is initialized. + * It may not be safe to reference other modules here since they may not be loaded yet. + * + * NOTE: To make the module start inactive, call `this.active = false` in the constructor. + */ + public function new(moduleId:String, priority:Int = 1000):Void + { + this.moduleId = moduleId; + this.priority = priority; + } + + public function toString() + { + return 'Module(' + this.moduleId + ')'; + } + + // TODO: Half of these aren't actually being called!!!!!!! + + /** + * Called when ANY script event is dispatched. + */ + public function onScriptEvent(event:ScriptEvent) {} + + /** + * Called when the module is first created. + * This happens before the title screen appears! + */ + public function onCreate(event:ScriptEvent) {} + + /** + * Called when a module is destroyed. + * This currently only happens when reloading modules with F5. + */ + public function onDestroy(event:ScriptEvent) {} + + /** + * Called every frame. + */ + public function onUpdate(event:UpdateScriptEvent) {} + + /** + * Called when the game is paused. + */ + public function onPause(event:PauseScriptEvent) {} + + /** + * Called when the game is resumed. + */ + public function onResume(event:ScriptEvent) {} + + /** + * Called when the song begins. + */ + public function onSongStart(event:ScriptEvent) {} + + /** + * Called when the song ends. + */ + public function onSongEnd(event:ScriptEvent) {} + + /** + * Called when the player dies. + */ + public function onGameOver(event:ScriptEvent) {} + + /** + * Called when a note on the strumline has been rendered and is now onscreen. + * This gets dispatched for both the player and opponent strumlines. + */ + public function onNoteIncoming(event:NoteScriptEvent) {} + + /** + * Called when a note has been hit. + * This gets dispatched for both the player and opponent strumlines. + */ + public function onNoteHit(event:HitNoteScriptEvent) {} + + /** + * Called when a note has been missed. + * This gets dispatched for both the player and opponent strumlines. + */ + public function onNoteMiss(event:NoteScriptEvent) {} + + public function onNoteHoldDrop(event:HoldNoteScriptEvent) {} + + /** + * Called when the player presses a key without any notes present. + */ + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + + /** + * Called when a step is hit in the song. + */ + public function onStepHit(event:SongTimeScriptEvent) {} + + /** + * Called when a beat is hit in the song. + */ + public function onBeatHit(event:SongTimeScriptEvent) {} + + /** + * Called when a song event is triggered. + */ + public function onSongEvent(event:SongEventScriptEvent) {} + + /** + * Called when the countdown begins. + */ + public function onCountdownStart(event:CountdownScriptEvent) {} + + /** + * Called for every step in the countdown. + */ + public function onCountdownStep(event:CountdownScriptEvent) {} + + /** + * Called when the countdown ends, but BEFORE the song starts. + */ + public function onCountdownEnd(event:CountdownScriptEvent) {} + + /** + * Called when the song's chart has been parsed and loaded. + */ + public function onSongLoaded(event:SongLoadScriptEvent) {} + + /** + * Called when the game is about to switch to a new state. + */ + public function onStateChangeBegin(event:StateChangeScriptEvent) {} + + /** + * Called after the game has switched to a new state. + */ + public function onStateChangeEnd(event:StateChangeScriptEvent) {} + + /** + * Called when the game regains focus. + * This does not get called if "Auto Pause" is disabled. + */ + public function onFocusGained(event:FocusScriptEvent) {} + + /** + * Called when the game loses focus. + * This does not get called if "Auto Pause" is disabled. + */ + public function onFocusLost(event:FocusScriptEvent) {} + + /** + * Called when the game is about to open a substate. + */ + public function onSubStateOpenBegin(event:SubStateScriptEvent) {} + + /** + * Called when a substate has been opened. + */ + public function onSubStateOpenEnd(event:SubStateScriptEvent) {} + + /** + * Called when the game is about to close a substate. + */ + public function onSubStateCloseBegin(event:SubStateScriptEvent) {} + + /** + * Called when a substate has been closed. + */ + public function onSubStateCloseEnd(event:SubStateScriptEvent) {} + + /** + * Called when the song has been restarted. + */ + public function onSongRetry(event:SongRetryEvent) {} +} diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx new file mode 100644 index 00000000..6c60b0f8 --- /dev/null +++ b/source/funkin/modding/module/ModuleHandler.hx @@ -0,0 +1,152 @@ +package funkin.modding.module; + +import funkin.util.SortUtil; +import funkin.modding.events.ScriptEvent.UpdateScriptEvent; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.modding.module.Module; +import funkin.modding.module.ScriptedModule; + +/** + * Utility functions for loading and manipulating active modules. + */ +class ModuleHandler +{ + static final moduleCache:Map = new Map(); + static var modulePriorityOrder:Array = []; + + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadModuleCache():Void + { + // Clear any stages that are cached if there were any. + clearModuleCache(); + trace("[MODULEHANDLER] Loading module cache..."); + + var scriptedModuleClassNames:Array = ScriptedModule.listScriptClasses(); + trace(' Instantiating ${scriptedModuleClassNames.length} modules...'); + for (moduleCls in scriptedModuleClassNames) + { + var module:Module = ScriptedModule.init(moduleCls, moduleCls); + if (module != null) + { + trace(' Loaded module: ${moduleCls}'); + + // Then store it. + addToModuleCache(module); + } + else + { + trace(' Failed to instantiate module: ${moduleCls}'); + } + } + reorderModuleCache(); + + trace("[MODULEHANDLER] Module cache loaded."); + } + + public static function buildModuleCallbacks():Void + { + FlxG.signals.postStateSwitch.add(onStateSwitchComplete); + } + + static function onStateSwitchComplete():Void + { + callEvent(new StateChangeScriptEvent(STATE_CHANGE_END, FlxG.state, true)); + } + + static function addToModuleCache(module:Module):Void + { + moduleCache.set(module.moduleId, module); + } + + static function reorderModuleCache():Void + { + modulePriorityOrder = moduleCache.keys().array(); + + modulePriorityOrder.sort(sortByPriority); + } + + /** + * Given two module IDs, sort them by priority. + * @return 1 or -1 depending on which module has a higher priority. + */ + static function sortByPriority(a:String, b:String) + { + var aModule:Module = moduleCache.get(a); + var bModule:Module = moduleCache.get(b); + + if (aModule.priority != bModule.priority) + { + return aModule.priority - bModule.priority; + } + else + { + return SortUtil.alphabetically(a, b); + } + } + + public static function getModule(moduleId:String):Module + { + return moduleCache.get(moduleId); + } + + public static function activateModule(moduleId:String):Void + { + var module:Module = getModule(moduleId); + if (module != null) + { + module.active = true; + } + } + + public static function deactivateModule(moduleId:String):Void + { + var module:Module = getModule(moduleId); + if (module != null) + { + module.active = false; + } + } + + /** + * Clear the module cache, forcing all modules to call shutdown events. + */ + public static function clearModuleCache():Void + { + if (moduleCache != null) + { + var event = new ScriptEvent(DESTROY, false); + + // Note: Ignore stopPropagation() + for (key => value in moduleCache) + { + ScriptEventDispatcher.callEvent(value, event); + } + + moduleCache.clear(); + modulePriorityOrder = []; + } + } + + public static function callEvent(event:ScriptEvent):Void + { + for (moduleId in modulePriorityOrder) + { + var module:Module = moduleCache.get(moduleId); + // The module needs to be active to receive events. + if (module != null && module.active) + { + ScriptEventDispatcher.callEvent(module, event); + } + } + } + + public static inline function callOnCreate():Void + { + callEvent(new ScriptEvent(CREATE, false)); + } +} diff --git a/source/funkin/modding/module/ScriptedModule.hx b/source/funkin/modding/module/ScriptedModule.hx new file mode 100644 index 00000000..3517bb9a --- /dev/null +++ b/source/funkin/modding/module/ScriptedModule.hx @@ -0,0 +1,10 @@ +package funkin.modding.module; + +import polymod.hscript.HScriptedClass; + +/** + * A script that can be tied to a Module, which persists across states. + * Create a scripted class that extends Module to use this. + */ +@:hscriptClass +class ScriptedModule extends Module implements HScriptedClass {} diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx new file mode 100644 index 00000000..c034ba76 --- /dev/null +++ b/source/funkin/play/Countdown.hx @@ -0,0 +1,294 @@ +package funkin.play; + +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEvent.CountdownScriptEvent; +import flixel.util.FlxTimer; +import funkin.util.EaseUtil; +import funkin.audio.FunkinSound; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; + +class Countdown +{ + /** + * The current step of the countdown. + */ + public static var countdownStep(default, null):CountdownStep = BEFORE; + + /** + * Which alternate graphic/sound on countdown to use. + * This is set via the current notestyle. + * For example, in Week 6 it is `pixel`. + */ + public static var soundSuffix:String = ''; + + /** + * Which alternate graphic on countdown to use. + * You can set this via script. + * For example, in Week 6 it is `-pixel`. + */ + public static var graphicSuffix:String = ''; + + static var noteStyle:NoteStyle; + + static var fallbackNoteStyle:Null; + + /** + * The currently running countdown. This will be null if there is no countdown running. + */ + static var countdownTimer:FlxTimer = null; + + /** + * Performs the countdown. + * Pauses the song, plays the countdown graphics/sound, and then starts the song. + * This will automatically stop and restart the countdown if it is already running. + * @returns `false` if the countdown was cancelled by a script. + */ + public static function performCountdown():Bool + { + countdownStep = BEFORE; + var cancelled:Bool = propagateCountdownEvent(countdownStep); + if (cancelled) + { + return false; + } + + // Stop any existing countdown. + stopCountdown(); + + PlayState.instance.isInCountdown = true; + Conductor.instance.update(PlayState.instance.startTimestamp + Conductor.instance.beatLengthMs * -5); + // Handle onBeatHit events manually + // @:privateAccess + // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0)); + + // The timer function gets called based on the beat of the song. + countdownTimer = new FlxTimer(); + + countdownTimer.start(Conductor.instance.beatLengthMs / 1000, function(tmr:FlxTimer) { + if (PlayState.instance == null) + { + tmr.cancel(); + return; + } + + countdownStep = decrement(countdownStep); + + // onBeatHit events are now properly dispatched by the Conductor even at negative timestamps, + // so calling this is no longer necessary. + // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0)); + + // Countdown graphic. + showCountdownGraphic(countdownStep); + + // Countdown sound. + playCountdownSound(countdownStep); + + // Event handling bullshit. + var cancelled:Bool = propagateCountdownEvent(countdownStep); + + if (cancelled) + { + pauseCountdown(); + } + + if (countdownStep == AFTER) + { + stopCountdown(); + } + }, 5); // Before, 3, 2, 1, GO!, After + + return true; + } + + /** + * @return TRUE if the event was cancelled. + */ + static function propagateCountdownEvent(index:CountdownStep):Bool + { + var event:ScriptEvent; + + switch (index) + { + case BEFORE: + event = new CountdownScriptEvent(COUNTDOWN_START, index); + case THREE | TWO | ONE | GO: // I didn't know you could use `|` in a switch/case block! + event = new CountdownScriptEvent(COUNTDOWN_STEP, index); + case AFTER: + event = new CountdownScriptEvent(COUNTDOWN_END, index, false); + default: + return true; + } + + // Modules, stages, characters. + @:privateAccess + PlayState.instance.dispatchEvent(event); + + return event.eventCanceled; + } + + /** + * Pauses the countdown at the current step. You can start it up again later by calling resumeCountdown(). + * + * If you want to call this from a module, it's better to use the event system and cancel the onCountdownStep event. + */ + public static function pauseCountdown():Void + { + if (countdownTimer != null && !countdownTimer.finished) + { + countdownTimer.active = false; + } + } + + /** + * Resumes the countdown at the current step. Only makes sense if you called pauseCountdown() first. + * + * If you want to call this from a module, it's better to use the event system and cancel the onCountdownStep event. + */ + public static function resumeCountdown():Void + { + if (countdownTimer != null && !countdownTimer.finished) + { + countdownTimer.active = true; + } + } + + /** + * Stops the countdown at the current step. You will have to restart it again later. + * + * If you want to call this from a module, it's better to use the event system and cancel the onCountdownStart event. + */ + public static function stopCountdown():Void + { + if (countdownTimer != null) + { + countdownTimer.cancel(); + countdownTimer.destroy(); + countdownTimer = null; + } + } + + /** + * Stops the current countdown, then starts the song for you. + */ + public static function skipCountdown():Void + { + stopCountdown(); + // This will trigger PlayState.startSong() + Conductor.instance.update(0); + // PlayState.isInCountdown = false; + } + + /** + * Resets the countdown. Only works if it's already running. + */ + public static function resetCountdown() + { + if (countdownTimer != null) + { + countdownTimer.reset(); + } + } + + /** + * Reset the countdown configuration to the default. + */ + public static function reset() + { + noteStyle = null; + } + + /** + * Retrieve the note style data (if we haven't already) + * @param noteStyleId The id of the note style to fetch. Defaults to the one used by the current PlayState. + * @param force Fetch the note style from the registry even if we've already fetched it. + */ + static function fetchNoteStyle(?noteStyleId:String, force:Bool = false):Void + { + if (noteStyle != null && !force) return; + + if (noteStyleId == null) noteStyleId = PlayState.instance?.currentChart?.noteStyle; + + noteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault(); + } + + /** + * Retrieves the graphic to use for this step of the countdown. + */ + public static function showCountdownGraphic(index:CountdownStep):Void + { + fetchNoteStyle(); + + var countdownSprite = noteStyle.buildCountdownSprite(index); + if (countdownSprite == null) return; + + var fadeEase = FlxEase.cubeInOut; + if (noteStyle.isCountdownSpritePixel(index)) fadeEase = EaseUtil.stepped(8); + + // Fade sprite in, then out, then destroy it. + FlxTween.tween(countdownSprite, {alpha: 0}, Conductor.instance.beatLengthMs / 1000, + { + ease: fadeEase, + onComplete: function(twn:FlxTween) { + countdownSprite.destroy(); + } + }); + + countdownSprite.cameras = [PlayState.instance.camHUD]; + PlayState.instance.add(countdownSprite); + countdownSprite.screenCenter(); + + var offsets = noteStyle.getCountdownSpriteOffsets(index); + countdownSprite.x += offsets[0]; + countdownSprite.y += offsets[1]; + } + + /** + * Retrieves the sound file to use for this step of the countdown. + */ + public static function playCountdownSound(step:CountdownStep):FunkinSound + { + fetchNoteStyle(); + var path = noteStyle.getCountdownSoundPath(step); + if (path == null) return null; + + return FunkinSound.playOnce(path, Constants.COUNTDOWN_VOLUME, null, null, true); + } + + public static function decrement(step:CountdownStep):CountdownStep + { + switch (step) + { + case BEFORE: + return THREE; + case THREE: + return TWO; + case TWO: + return ONE; + case ONE: + return GO; + case GO: + return AFTER; + + default: + return AFTER; + } + } +} + +/** + * The countdown step. + * This can't be an enum abstract because scripts may need it. + */ +enum CountdownStep +{ + BEFORE; + THREE; + TWO; + ONE; + GO; + AFTER; +} diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx new file mode 100644 index 00000000..c84a47f5 --- /dev/null +++ b/source/funkin/play/GameOverSubState.hx @@ -0,0 +1,579 @@ +package funkin.play; + +import flixel.FlxState; +import funkin.data.freeplay.player.PlayerRegistry; +import flixel.FlxG; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.input.touch.FlxTouch; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.graphics.FunkinSprite; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.BaseCharacter; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.story.StoryMenuState; +import funkin.util.MathUtil; +import funkin.effects.RetroCameraFade; +import flixel.math.FlxPoint; + +/** + * A substate which renders over the PlayState when the player dies. + * Displays the player death animation, plays the music, and handles restarting the song. + * + * The newest implementation uses a substate, which prevents having to reload the song and stage each reset. + */ +@:nullSafety +class GameOverSubState extends MusicBeatSubState +{ + /** + * The currently active GameOverSubState. + * There should be only one GameOverSubState in existance at a time, we can use a singleton. + */ + public static var instance:Null = null; + + /** + * Which alternate animation on the character to use. + * You can set this via script. + * For example, playing a different animation when BF dies in Week 4 + * or Pico dies in Weekend 1. + */ + public static var animationSuffix:String = ''; + + /** + * Which alternate game over music to use. + * You can set this via script. + * For example, the bf-pixel script sets this to `-pixel` + * and the pico-playable script sets this to `Pico`. + */ + public static var musicSuffix:String = ''; + + /** + * Which alternate "blue ball" sound effect to use. + */ + public static var blueBallSuffix:String = ''; + + static var blueballed:Bool = false; + + /** + * The boyfriend character. + */ + var boyfriend:Null = null; + + /** + * The invisible object in the scene which the camera focuses on. + */ + var cameraFollowPoint:FlxObject; + + /** + * The music playing in the background of the state. + */ + var gameOverMusic:Null = null; + + /** + * Whether the player has confirmed and prepared to restart the level or to go back to the freeplay menu. + * This means the animation and transition have already started. + */ + var isEnding:Bool = false; + + /** + * Whether the death music is on its first loop. + */ + var isStarting:Bool = true; + + var isChartingMode:Bool = false; + + var mustNotExit:Bool = false; + + var transparent:Bool; + + static final CAMERA_ZOOM_DURATION:Float = 0.5; + + var targetCameraZoom:Float = 1.0; + + public function new(params:GameOverParams) + { + super(); + + this.isChartingMode = params?.isChartingMode ?? false; + transparent = params.transparent; + + cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1); + } + + /** + * Reset the game over configuration to the default. + */ + public static function reset():Void + { + animationSuffix = ''; + musicSuffix = ''; + blueBallSuffix = ''; + blueballed = false; + } + + public override function create():Void + { + if (instance != null) + { + // TODO: Do something in this case? IDK. + FlxG.log.warn('WARNING: GameOverSubState instance already exists. This should not happen.'); + } + instance = this; + + super.create(); + + // + // Set up the visuals + // + + var playState = PlayState.instance; + + // Add a black background to the screen. + var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); + // We make this transparent so that we can see the stage underneath during debugging, + // but it's normally opaque. + bg.alpha = transparent ? 0.25 : 1.0; + bg.scrollFactor.set(); + bg.screenCenter(); + add(bg); + + // Pluck Boyfriend from the PlayState and place him (in the same position) in the GameOverSubState. + // We can then play the character's `firstDeath` animation. + if (PlayState.instance.isMinimalMode) {} + else + { + boyfriend = PlayState.instance.currentStage.getBoyfriend(true); + if (boyfriend != null) + { + boyfriend.canPlayOtherAnims = true; + boyfriend.isDead = true; + add(boyfriend); + boyfriend.resetCharacter(); + } + } + + setCameraTarget(); + + // + // Set up the audio + // + + // The conductor now represents the BPM of the game over music. + Conductor.instance.update(0); + } + + @:nullSafety(Off) + function setCameraTarget():Void + { + if (PlayState.instance.isMinimalMode || boyfriend == null) return; + + // Assign a camera follow point to the boyfriend's position. + cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1); + cameraFollowPoint.x = getMidPointOld(boyfriend).x; + cameraFollowPoint.y = getMidPointOld(boyfriend).y; + var offsets:Array = boyfriend.getDeathCameraOffsets(); + cameraFollowPoint.x += offsets[0]; + cameraFollowPoint.y += offsets[1]; + add(cameraFollowPoint); + + FlxG.camera.target = null; + FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE / 2); + targetCameraZoom = (PlayState?.instance?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom(); + } + + /** + * FlxSprite.getMidpoint(); calculations changed in this git commit + * https://github.com/HaxeFlixel/flixel/commit/1553b5af0871462fcefedc091b7885437d6c36d2 + * https://github.com/HaxeFlixel/flixel/pull/3125 + * + * So we use this to do the old math that gets the midpoint of our graphics + * Luckily, we don't use getGraphicMidpoint() much in the code, so it's fine being in GameoverSubState here. + * @return FlxPoint + */ + function getMidPointOld(spr:FlxSprite, ?point:FlxPoint):FlxPoint + { + if (point == null) point = FlxPoint.get(); + return point.set(spr.x + spr.frameWidth * 0.5 * spr.scale.x, spr.y + spr.frameHeight * 0.5 * spr.scale.y); + } + + /** + * Forcibly reset the camera zoom level to that of the current stage. + * This prevents camera zoom events from adversely affecting the game over state. + */ + public function resetCameraZoom():Void + { + // Apply camera zoom level from stage data. + FlxG.camera.zoom = PlayState?.instance?.currentStage?.camZoom ?? 1.0; + } + + var hasStartedAnimation:Bool = false; + + override function update(elapsed:Float):Void + { + if (!hasStartedAnimation) + { + hasStartedAnimation = true; + + if (boyfriend == null || PlayState.instance.isMinimalMode) + { + // Play the "blue balled" sound. May play a variant if one has been assigned. + playBlueBalledSFX(); + } + else + { + if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100)) + { + boyfriend.playAnimation('fakeoutDeath', true, false); + } + else + { + boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over! + // Play the "blue balled" sound. May play a variant if one has been assigned. + playBlueBalledSFX(); + } + } + } + + // Smoothly lerp the camera + FlxG.camera.zoom = MathUtil.smoothLerp(FlxG.camera.zoom, targetCameraZoom, elapsed, CAMERA_ZOOM_DURATION); + + // + // Handle user inputs. + // + + // MOBILE ONLY: Restart the level when tapping Boyfriend. + if (FlxG.onMobile) + { + var touch:FlxTouch = FlxG.touches.getFirst(); + if (touch != null) + { + if (boyfriend == null || touch.overlaps(boyfriend)) + { + confirmDeath(); + } + } + } + + // KEYBOARD ONLY: Restart the level when pressing the assigned key. + if (controls.ACCEPT && blueballed && !mustNotExit) + { + blueballed = false; + confirmDeath(); + } + + // KEYBOARD ONLY: Return to the menu when pressing the assigned key. + if (controls.BACK && !mustNotExit && !isEnding) + { + isEnding = true; + blueballed = false; + PlayState.instance.deathCounter = 0; + // PlayState.seenCutscene = false; // old thing... + if (gameOverMusic != null) gameOverMusic.stop(); + + // Stop death quotes immediately. + hasPlayedDeathQuote = true; + if (deathQuoteSound != null) + { + deathQuoteSound.stop(); + deathQuoteSound = null; + } + + if (isChartingMode) + { + this.close(); + if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! + PlayState.instance.close(); // This only works because PlayState is a substate! + return; + } + else + { + var targetState:funkin.ui.transition.stickers.StickerSubState->FlxState = (PlayStatePlaylist.isStoryMode) ? (sticker) -> + new StoryMenuState(sticker) : (sticker) -> FreeplayState.build(sticker); + + if (PlayStatePlaylist.isStoryMode) + { + PlayStatePlaylist.reset(); + } + + var stickerPackId:Null = PlayState.instance.currentChart.stickerPack; + + if (stickerPackId == null) + { + var playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(PlayState.instance.currentChart.characters.player); + var playerCharacter = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? Constants.DEFAULT_CHARACTER); + + if (playerCharacter != null) + { + stickerPackId = playerCharacter.getStickerPackID(); + } + } + + openSubState(new funkin.ui.transition.stickers.StickerSubState({targetState: targetState, stickerPack: stickerPackId})); + } + } + + if (gameOverMusic != null && gameOverMusic.playing) + { + // Match the conductor to the music. + // This enables the stepHit and beatHit events. + Conductor.instance.update(gameOverMusic.time); + } + else if (boyfriend != null) + { + if (PlayState.instance.isMinimalMode) + { + // startDeathMusic(1.0, false); + } + else + { + // Music hasn't started yet. + + if (boyfriend.getDeathQuote() != null) + { + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !hasPlayedDeathQuote) + { + hasPlayedDeathQuote = true; + playDeathQuote(); + } + } + else + { + // Start music at normal volume once the initial death animation finishes. + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) + { + startDeathMusic(1.0, false); + boyfriend.playAnimation('deathLoop' + animationSuffix); + } + } + } + } + + // Start death music before firstDeath gets replaced + super.update(elapsed); + } + + var deathQuoteSound:Null = null; + + function playDeathQuote():Void + { + if (boyfriend == null) return; + + var deathQuote = boyfriend.getDeathQuote(); + if (deathQuote == null) return; + + if (deathQuoteSound != null) + { + deathQuoteSound.stop(); + deathQuoteSound = null; + } + + // Start music at lower volume + startDeathMusic(0.2, false); + boyfriend.playAnimation('deathLoop' + animationSuffix); + deathQuoteSound = FunkinSound.playOnce(deathQuote, function() { + // Once the quote ends, fade in the game over music. + if (!isEnding && gameOverMusic != null) + { + gameOverMusic.fadeIn(4, 0.2, 1); + } + }); + } + + /** + * Do behavior which occurs when you confirm and move to restart the level. + */ + function confirmDeath():Void + { + if (!isEnding) + { + isEnding = true; + startDeathMusic(1.0, true); // isEnding changes this function's behavior. + + // Stop death quotes immediately. + hasPlayedDeathQuote = true; + if (deathQuoteSound != null) + { + deathQuoteSound.stop(); + deathQuoteSound = null; + } + + if (PlayState.instance.isMinimalMode || boyfriend == null) {} + else + { + boyfriend.playAnimation('deathConfirm' + animationSuffix, true); + } + + // After the animation finishes... + new FlxTimer().start(0.7, function(tmr:FlxTimer) { + // ...fade out the graphics. Then after that happens... + + var resetPlaying = function(pixel:Bool = false) { + // ...close the GameOverSubState. + if (pixel) RetroCameraFade.fadeBlack(FlxG.camera, 10, 1); + else + FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true); + PlayState.instance.needsReset = true; + + if (PlayState.instance.isMinimalMode || boyfriend == null) {} + else + { + // Readd Boyfriend to the stage. + boyfriend.isDead = false; + remove(boyfriend); + PlayState.instance.currentStage.addCharacter(boyfriend, BF); + } + + // Snap reset the camera which may have changed because of the player character data. + resetCameraZoom(); + + // Close the substate. + close(); + }; + + if (musicSuffix == '-pixel') + { + RetroCameraFade.fadeToBlack(FlxG.camera, 10, 2); + new FlxTimer().start(2, _ -> { + FlxG.camera.filters = []; + resetPlaying(true); + }); + } + else + { + FlxG.camera.fade(FlxColor.BLACK, 2, false, function() { + resetPlaying(); + }); + } + }); + } + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + super.dispatchEvent(event); + + ScriptEventDispatcher.callEvent(boyfriend, event); + } + + /** + * Rather than hardcoding stuff, we look for the presence of a music file + * with the given suffix, and strip it down until we find one that's valid. + */ + function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null + { + var basePath:String = 'gameplay/gameover/gameOver'; + if (ending) basePath += 'End'; + else if (starting) basePath += 'Start'; + + var musicPath:String = Paths.music(basePath + suffix); + while (!Assets.exists(musicPath) && suffix.length > 0) + { + suffix = suffix.split('-').slice(0, -1).join('-'); + musicPath = Paths.music(basePath + suffix); + } + if (!Assets.exists(musicPath)) return null; + trace('Resolved music path: ' + musicPath); + return musicPath; + } + + /** + * Starts the death music at the appropriate volume. + * @param startingVolume The initial volume for the music. + * @param force Whether or not to force the music to restart. + */ + public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void + { + var musicPath:Null = resolveMusicPath(musicSuffix, isStarting, isEnding); + var onComplete:() -> Void = () -> {}; + + if (isStarting) + { + if (musicPath == null) + { + // Looked for starting music and didn't find it. Use middle music instead. + isStarting = false; + musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding); + } + else + { + onComplete = function() { + isStarting = true; + // We need to force to ensure that the non-starting music plays. + startDeathMusic(1.0, true); + }; + } + } + + if (musicPath == null) + { + FlxG.log.warn('[GAMEOVER] Could not find game over music at path ($musicPath)!'); + return; + } + else if (gameOverMusic == null || !gameOverMusic.playing || force) + { + if (gameOverMusic != null) gameOverMusic.stop(); + + gameOverMusic = FunkinSound.load(musicPath); + if (gameOverMusic == null) return; + + gameOverMusic.volume = startingVolume; + gameOverMusic.looped = !(isEnding || isStarting); + gameOverMusic.onComplete = onComplete; + gameOverMusic.play(); + } + else + { + @:privateAccess + trace('Music already playing! ${gameOverMusic?._label}'); + } + } + + /** + * Play the sound effect that occurs when + * boyfriend's testicles get utterly annihilated. + */ + public static function playBlueBalledSFX():Void + { + blueballed = true; + if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix))) + { + FunkinSound.playOnce(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); + } + else + { + FlxG.log.error('Missing blue ball sound effect: ' + Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)); + } + } + + var hasPlayedDeathQuote:Bool = false; + + public override function destroy():Void + { + super.destroy(); + if (gameOverMusic != null) + { + gameOverMusic.stop(); + gameOverMusic = null; + } + blueballed = false; + instance = null; + } + + public override function toString():String + { + return 'GameOverSubState'; + } +} + +/** + * Parameters used to instantiate a GameOverSubState. + */ +typedef GameOverParams = +{ + var isChartingMode:Bool; + var transparent:Bool; +} diff --git a/source/funkin/play/GitarooPause.hx b/source/funkin/play/GitarooPause.hx new file mode 100644 index 00000000..5c5c2aea --- /dev/null +++ b/source/funkin/play/GitarooPause.hx @@ -0,0 +1,96 @@ +package funkin.play; + +import flixel.FlxSprite; +import funkin.play.PlayState.PlayStateParams; +import funkin.graphics.FunkinSprite; +import funkin.ui.MusicBeatState; +import flixel.addons.transition.FlxTransitionableState; +import funkin.ui.mainmenu.MainMenuState; + +class GitarooPause extends MusicBeatState +{ + var replayButton:FlxSprite; + var cancelButton:FlxSprite; + + var replaySelect:Bool = false; + + var previousParams:PlayStateParams; + + public function new(previousParams:PlayStateParams):Void + { + super(); + + this.previousParams = previousParams; + } + + override function create():Void + { + if (FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + var bg:FunkinSprite = FunkinSprite.create('pauseAlt/pauseBG'); + add(bg); + + var bf:FunkinSprite = FunkinSprite.createSparrow(0, 30, 'pauseAlt/bfLol'); + bf.animation.addByPrefix('lol', "funnyThing", 13); + bf.animation.play('lol'); + add(bf); + bf.screenCenter(X); + + replayButton = FunkinSprite.createSparrow(FlxG.width * 0.28, FlxG.height * 0.7, 'pauseAlt/pauseUI'); + replayButton.animation.addByPrefix('selected', 'bluereplay', 0, false); + replayButton.animation.appendByPrefix('selected', 'yellowreplay'); + replayButton.animation.play('selected'); + add(replayButton); + + cancelButton = FunkinSprite.createSparrow(FlxG.width * 0.58, replayButton.y, 'pauseAlt/pauseUI'); + cancelButton.animation.addByPrefix('selected', 'bluecancel', 0, false); + cancelButton.animation.appendByPrefix('selected', 'cancelyellow'); + cancelButton.animation.play('selected'); + add(cancelButton); + + changeThing(); + + super.create(); + } + + override function update(elapsed:Float):Void + { + if (controls.UI_LEFT_P || controls.UI_RIGHT_P) changeThing(); + + if (controls.ACCEPT) + { + if (replaySelect) + { + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; + FlxG.switchState(() -> new PlayState(previousParams)); + } + else + { + FlxG.switchState(() -> new MainMenuState()); + } + } + + super.update(elapsed); + } + + function changeThing():Void + { + replaySelect = !replaySelect; + + if (replaySelect) + { + cancelButton.animation.curAnim.curFrame = 0; + replayButton.animation.curAnim.curFrame = 1; + } + else + { + cancelButton.animation.curAnim.curFrame = 1; + replayButton.animation.curAnim.curFrame = 0; + } + } +} diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx new file mode 100644 index 00000000..977a7808 --- /dev/null +++ b/source/funkin/play/PauseSubState.hx @@ -0,0 +1,835 @@ +package funkin.play; + +import flixel.FlxState; +import funkin.ui.story.StoryMenuState; +import funkin.data.freeplay.player.PlayerRegistry; +import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxG; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxMath; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import funkin.data.song.SongRegistry; +import funkin.ui.freeplay.FreeplayState; +import funkin.graphics.FunkinSprite; +import funkin.play.cutscene.VideoCutscene; +import funkin.ui.AtlasText; +import funkin.ui.MusicBeatSubState; + +/** + * Parameters for initializing the PauseSubState. + */ +typedef PauseSubStateParams = +{ + /** + * Which mode to start in. Dictates what entries are displayed. + */ + ?mode:PauseMode, +}; + +/** + * The menu displayed when the Play State is paused. + */ +class PauseSubState extends MusicBeatSubState +{ + // =============== + // Constants + // =============== + + /** + * Pause menu entries for when the game is paused during a song. + */ + static final PAUSE_MENU_ENTRIES_STANDARD:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Change Difficulty', callback: switchMode.bind(_, Difficulty)}, + {text: 'Enable Practice Mode', callback: enablePracticeMode, filter: () -> !(PlayState.instance?.isPracticeMode ?? false)}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; + + /** + * Pause menu entries for when the game is paused in the Chart Editor preview. + */ + static final PAUSE_MENU_ENTRIES_CHARTING:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Return to Chart Editor', callback: quitToChartEditor}, + ]; + + /** + * Pause menu entries for when the user selects "Change Difficulty". + */ + static final PAUSE_MENU_ENTRIES_DIFFICULTY:Array = [ + {text: 'Back', callback: switchMode.bind(_, Standard)} + // Other entries are added dynamically. + ]; + + /** + * Pause menu entries for when the game is paused during a video cutscene. + */ + static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Skip Cutscene', callback: skipVideoCutscene}, + {text: 'Restart Cutscene', callback: restartVideoCutscene}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; + + /** + * Pause menu entries for when the game is paused during a conversation. + */ + static final PAUSE_MENU_ENTRIES_CONVERSATION:Array = [ + {text: 'Resume', callback: resume}, + {text: 'Skip Dialogue', callback: skipConversation}, + {text: 'Restart Dialogue', callback: restartConversation}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; + + /** + * Duration for the music to fade in when the pause menu is opened. + */ + static final MUSIC_FADE_IN_TIME:Float = 5; + + /** + * The final volume for the music when the pause menu is opened. + */ + static final MUSIC_FINAL_VOLUME:Float = 0.75; + + static final CHARTER_FADE_DELAY:Float = 15.0; + + static final CHARTER_FADE_DURATION:Float = 0.75; + + /** + * Defines which pause music to use. + */ + public static var musicSuffix:String = ''; + + /** + * Reset the pause configuration to the default. + */ + public static function reset():Void + { + musicSuffix = ''; + } + + // =============== + // Status Variables + // =============== + + /** + * Disallow input until transitions are complete! + * This prevents the pause menu from immediately closing when opened, among other things. + */ + public var allowInput:Bool = true; + + // If this is true, it means we are frame 1 of our substate. + var justOpened:Bool = true; + + /** + * The entries currently displayed in the pause menu. + */ + var currentMenuEntries:Array; + + /** + * The index of `currentMenuEntries` that is currently selected. + */ + var currentEntry:Int = 0; + + /** + * The mode that the pause menu is currently in. + */ + var currentMode:PauseMode; + + // =============== + // Graphics Variables + // =============== + + /** + * The semi-transparent black background that appears when the game is paused. + */ + var background:FunkinSprite; + + /** + * The metadata displayed in the top right. + */ + var metadata:FlxTypedSpriteGroup; + + /** + * A text object that displays the current practice mode status. + */ + var metadataPractice:FlxText; + + /** + * A text object that displays the current death count. + */ + var metadataDeaths:FlxText; + + /** + * A text object which displays the current song's artist. + * Fades to the charter after a period before fading back. + */ + var metadataArtist:FlxText; + + /** + * The actual text objects for the menu entries. + */ + var menuEntryText:FlxTypedSpriteGroup; + + // =============== + // Audio Variables + // =============== + var pauseMusic:FunkinSound; + + // =============== + // Constructor + // =============== + + public function new(?params:PauseSubStateParams) + { + super(); + this.currentMode = params?.mode ?? Standard; + } + + // =============== + // Lifecycle Functions + // =============== + + /** + * Called when the state is first loaded. + */ + public override function create():Void + { + super.create(); + + startPauseMusic(); + + buildBackground(); + + buildMetadata(); + + regenerateMenu(); + + transitionIn(); + + startCharterTimer(); + } + + /** + * Called every frame. + * @param elapsed The time elapsed since the last frame, in seconds. + */ + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + handleInputs(); + } + + /** + * Called when the state is closed. + */ + public override function destroy():Void + { + super.destroy(); + charterFadeTween.cancel(); + charterFadeTween = null; + pauseMusic.stop(); + } + + // =============== + // Initialization Functions + // =============== + + /** + * Play the pause music. + */ + function startPauseMusic():Void + { + var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix'); + pauseMusic = FunkinSound.load(pauseMusicPath, true, true); + + if (pauseMusic == null) + { + FlxG.log.warn('Could not play pause music: ${pauseMusicPath} does not exist!'); + } + + // Start playing at a random point in the song. + pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2))); + pauseMusic.fadeIn(MUSIC_FADE_IN_TIME, 0, MUSIC_FINAL_VOLUME); + } + + /** + * Render the semi-transparent black background. + */ + function buildBackground():Void + { + // Using state.bgColor causes bugs! + background = new FunkinSprite(0, 0); + background.makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK); + background.alpha = 0.0; + background.scrollFactor.set(0, 0); + background.updateHitbox(); + add(background); + } + + /** + * Render the metadata in the top right. + */ + function buildMetadata():Void + { + metadata = new FlxTypedSpriteGroup(); + metadata.scrollFactor.set(0, 0); + add(metadata); + + var metadataSong:FlxText = new FlxText(20, 15, FlxG.width - 40, 'Song Name'); + metadataSong.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentChart != null) + { + metadataSong.text = '${PlayState.instance.currentChart.songName}'; + } + metadataSong.scrollFactor.set(0, 0); + metadata.add(metadataSong); + + metadataArtist = new FlxText(20, metadataSong.y + 32, FlxG.width - 40, 'Artist: ${Constants.DEFAULT_ARTIST}'); + metadataArtist.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentChart != null) + { + metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}'; + } + metadataArtist.scrollFactor.set(0, 0); + metadata.add(metadataArtist); + + var metadataDifficulty:FlxText = new FlxText(20, metadataArtist.y + 32, FlxG.width - 40, 'Difficulty: '); + metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentDifficulty != null) + { + metadataDifficulty.text += PlayState.instance.currentDifficulty.replace('-', ' ').toTitleCase(); + } + metadataDifficulty.scrollFactor.set(0, 0); + metadata.add(metadataDifficulty); + + metadataDeaths = new FlxText(20, metadataDifficulty.y + 32, FlxG.width - 40, '${PlayState.instance?.deathCounter} Blue Balls'); + metadataDeaths.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataDeaths.scrollFactor.set(0, 0); + metadata.add(metadataDeaths); + + metadataPractice = new FlxText(20, metadataDeaths.y + 32, FlxG.width - 40, 'PRACTICE MODE'); + metadataPractice.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + metadataPractice.scrollFactor.set(0, 0); + metadata.add(metadataPractice); + + updateMetadataText(); + } + + var charterFadeTween:Null = null; + + function startCharterTimer():Void + { + charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION, + { + startDelay: CHARTER_FADE_DELAY, + ease: FlxEase.quartOut, + onComplete: (_) -> { + if (PlayState.instance?.currentChart != null) + { + metadataArtist.text = 'Charter: ${PlayState.instance.currentChart.charter ?? 'Unknown'}'; + } + else + { + metadataArtist.text = 'Charter: ${Constants.DEFAULT_CHARTER}'; + } + + FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION, + { + ease: FlxEase.quartOut, + onComplete: (_) -> { + startArtistTimer(); + } + }); + } + }); + } + + function startArtistTimer():Void + { + charterFadeTween = FlxTween.tween(metadataArtist, {alpha: 0.0}, CHARTER_FADE_DURATION, + { + startDelay: CHARTER_FADE_DELAY, + ease: FlxEase.quartOut, + onComplete: (_) -> { + if (PlayState.instance?.currentChart != null) + { + metadataArtist.text = 'Artist: ${PlayState.instance.currentChart.songArtist}'; + } + else + { + metadataArtist.text = 'Artist: ${Constants.DEFAULT_ARTIST}'; + } + + FlxTween.tween(metadataArtist, {alpha: 1.0}, CHARTER_FADE_DURATION, + { + ease: FlxEase.quartOut, + onComplete: (_) -> { + startCharterTimer(); + } + }); + } + }); + } + + /** + * Perform additional animations to transition the pause menu in when it is first displayed. + */ + function transitionIn():Void + { + FlxTween.tween(background, {alpha: 0.6}, 0.8, {ease: FlxEase.quartOut}); + + // Animate each element a little bit downwards. + var delay:Float = 0.1; + for (child in metadata.members) + { + FlxTween.tween(child, {alpha: 1, y: child.y + 5}, 1.8, {ease: FlxEase.quartOut, startDelay: delay}); + delay += 0.1; + } + } + + // =============== + // Input Handling + // =============== + + /** + * Process user inputs every frame. + */ + function handleInputs():Void + { + if (!allowInput) return; + + if (controls.UI_UP_P) + { + changeSelection(-1); + } + if (controls.UI_DOWN_P) + { + changeSelection(1); + } + + if (controls.ACCEPT && !justOpened) + { + currentMenuEntries[currentEntry].callback(this); + } + else if (controls.PAUSE && !justOpened) + { + resume(this); + } + + // we only want justOpened to be true for 1 single frame, when we first get into the pause menu substate + justOpened = false; + + #if FEATURE_DEBUG_FUNCTIONS + // to pause the game and get screenshots easy, press H on pause menu! + if (FlxG.keys.justPressed.H) + { + var visible = !metadata.visible; + + metadata.visible = visible; + menuEntryText.visible = visible; + background.visible = visible; + this.bgColor = visible ? 0x99000000 : 0x00000000; // 60% or fully transparent black + } + #end + } + + /** + * Move the current selection up or down. + * @param change The amount to change the selection by, with sign indicating direction. + */ + function changeSelection(change:Int = 0):Void + { + var prevEntry:Int = currentEntry; + currentEntry += change; + + if (currentEntry < 0) currentEntry = currentMenuEntries.length - 1; + if (currentEntry >= currentMenuEntries.length) currentEntry = 0; + + if (currentEntry != prevEntry) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + + for (entryIndex in 0...currentMenuEntries.length) + { + var isCurrent:Bool = entryIndex == currentEntry; + + var entry:PauseMenuEntry = currentMenuEntries[entryIndex]; + var text:AtlasText = entry.sprite; + + // Set the transparency. + text.alpha = isCurrent ? 1.0 : 0.6; + + // Set the position. + var targetX = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 20 + 90; + var targetY = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 120 + (FlxG.height * 0.48); + FlxTween.globalManager.cancelTweensOf(text); + FlxTween.tween(text, {x: targetX, y: targetY}, 0.33, {ease: FlxEase.quartOut}); + } + } + + // =============== + // Menu Functions + // =============== + + /** + * Clear the current menu entries and regenerate them based on the current mode. + * @param targetMode Optionally specify a mode to switch to before regenerating the menu. + */ + function regenerateMenu(?targetMode:PauseMode):Void + { + // If targetMode is null, keep the current mode. + if (targetMode == null) targetMode = this.currentMode; + + var previousMode:PauseMode = this.currentMode; + this.currentMode = targetMode; + + resetSelection(); + chooseMenuEntries(); + clearAndAddMenuEntries(); + updateMetadataText(); + changeSelection(); + } + + /** + * Reset the current selection to the first entry. + */ + function resetSelection():Void + { + this.currentEntry = 0; + } + + /** + * Select which menu entries to display based on the current mode. + */ + function chooseMenuEntries():Void + { + // Choose the correct menu entries. + // NOTE: We clone the arrays to prevent modifications to the arrays from affecting the original. + switch (this.currentMode) + { + case PauseMode.Standard: + currentMenuEntries = PAUSE_MENU_ENTRIES_STANDARD.clone(); + case PauseMode.Charting: + currentMenuEntries = PAUSE_MENU_ENTRIES_CHARTING.clone(); + case PauseMode.Difficulty: + // Prepend the difficulties. + var entries:Array = []; + if (PlayState.instance.currentChart != null) + { + var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation, true); + trace('DIFFICULTIES: ${difficultiesInVariation}'); + for (difficulty in difficultiesInVariation) + { + entries.push({text: difficulty.toTitleCase(), callback: (state) -> changeDifficulty(state, difficulty)}); + } + } + + // Add the back button. + currentMenuEntries = entries.concat(PAUSE_MENU_ENTRIES_DIFFICULTY.clone()); + case PauseMode.Conversation: + currentMenuEntries = PAUSE_MENU_ENTRIES_CONVERSATION.clone(); + case PauseMode.Cutscene: + currentMenuEntries = PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE.clone(); + } + } + + /** + * Clear the `menuEntryText` group and render the current menu entries to it. + * We first create the `menuEntryText` group if it doesn't already exist. + */ + function clearAndAddMenuEntries():Void + { + if (menuEntryText == null) + { + menuEntryText = new FlxTypedSpriteGroup(); + menuEntryText.scrollFactor.set(0, 0); + add(menuEntryText); + } + menuEntryText.clear(); + + // Render out the entries depending on the mode. + var entryIndex:Int = 0; + var toRemove = []; + for (entry in currentMenuEntries) + { + if (entry == null || (entry.filter != null && !entry.filter())) + { + // Remove entries that should be hidden. + toRemove.push(entry); + } + else + { + // Handle visible entries. + var yPos:Float = 70 * entryIndex + 30; + var text:AtlasText = new AtlasText(0, yPos, entry.text, AtlasFont.BOLD); + text.scrollFactor.set(0, 0); + text.alpha = 0; + menuEntryText.add(text); + + entry.sprite = text; + + entryIndex++; + } + } + for (entry in toRemove) + { + currentMenuEntries.remove(entry); + } + } + + // =============== + // Metadata Functions + // =============== + + /** + * Update the values for the metadata text in the top right. + */ + function updateMetadataText():Void + { + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + + switch (this.currentMode) + { + case Standard | Difficulty: + metadataDeaths.text = '${PlayState.instance?.deathCounter} Blue Balls'; + case Charting: + metadataDeaths.text = 'Chart Editor Preview'; + case Conversation: + metadataDeaths.text = 'Dialogue Paused'; + case Cutscene: + metadataDeaths.text = 'Video Paused'; + } + } + + // =============== + // Menu Callbacks + // =============== + + /** + * Close the pause menu and resume the game. + * @param state The current PauseSubState. + */ + static function resume(state:PauseSubState):Void + { + // Resume a paused video if it exists. + VideoCutscene.resumeVideo(); + + state.close(); + } + + /** + * Switch the pause menu to the indicated mode. + * Create a callback from this using `.bind(_, targetMode)`. + * @param state The current PauseSubState. + * @param targetMode The mode to switch to. + */ + static function switchMode(state:PauseSubState, targetMode:PauseMode):Void + { + state.regenerateMenu(targetMode); + } + + /** + * Switch the game's difficulty to the indicated difficulty, then resume the game. + * @param state The current PauseSubState. + * @param difficulty The difficulty to switch to. + */ + static function changeDifficulty(state:PauseSubState, difficulty:String):Void + { + PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); + + // Reset campaign score when changing difficulty + // So if you switch difficulty on the last song of a week you get a really low overall score. + PlayStatePlaylist.campaignScore = 0; + PlayStatePlaylist.campaignDifficulty = difficulty; + PlayState.instance.previousDifficulty = PlayState.instance.currentDifficulty; + PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; + + FreeplayState.rememberedDifficulty = difficulty; + + PlayState.instance.needsReset = true; + + state.close(); + } + + /** + * Restart the current level, then resume the game. + * @param state The current PauseSubState. + */ + static function restartPlayState(state:PauseSubState):Void + { + PlayState.instance.needsReset = true; + state.close(); + } + + /** + * Force the game into practice mode, then update the pause menu. + * @param state The current PauseSubState. + */ + static function enablePracticeMode(state:PauseSubState):Void + { + if (PlayState.instance == null) return; + + PlayState.instance.isPracticeMode = true; + state.regenerateMenu(); + } + + /** + * Restart the paused video cutscene, then resume the game. + * @param state The current PauseSubState. + */ + static function restartVideoCutscene(state:PauseSubState):Void + { + VideoCutscene.restartVideo(); + state.close(); + } + + /** + * Skip the paused video cutscene, then resume the game. + * @param state The current PauseSubState. + */ + static function skipVideoCutscene(state:PauseSubState):Void + { + VideoCutscene.finishVideo(); + state.close(); + } + + /** + * Restart the paused conversation, then resume the game. + * @param state The current PauseSubState. + */ + static function restartConversation(state:PauseSubState):Void + { + if (PlayState.instance?.currentConversation == null) return; + + PlayState.instance.currentConversation.resetConversation(); + state.close(); + } + + /** + * Skip the paused conversation, then resume the game. + * @param state The current PauseSubState. + */ + static function skipConversation(state:PauseSubState):Void + { + if (PlayState.instance?.currentConversation == null) return; + + PlayState.instance.currentConversation.skipConversation(); + state.close(); + } + + /** + * Quit the game and return to the main menu. + * @param state The current PauseSubState. + */ + static function quitToMenu(state:PauseSubState):Void + { + state.allowInput = false; + + PlayState.instance.deathCounter = 0; + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + var targetState:funkin.ui.transition.stickers.StickerSubState->FlxState = (PlayStatePlaylist.isStoryMode) ? (sticker) -> + new StoryMenuState(sticker) : (sticker) -> FreeplayState.build(sticker); + + // Do this AFTER because this resets the value of isStoryMode! + if (PlayStatePlaylist.isStoryMode) + { + PlayStatePlaylist.reset(); + } + + var stickerPackId:Null = PlayState.instance.currentChart.stickerPack; + + if (stickerPackId == null) + { + var playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(PlayState.instance.currentChart.characters.player); + var playerCharacter = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? Constants.DEFAULT_CHARACTER); + + if (playerCharacter != null) + { + stickerPackId = playerCharacter.getStickerPackID(); + } + } + + state.openSubState(new funkin.ui.transition.stickers.StickerSubState({targetState: targetState, stickerPack: stickerPackId})); + } + + /** + * Quit the game and return to the chart editor. + * @param state The current PauseSubState. + */ + static function quitToChartEditor(state:PauseSubState):Void + { + state.close(); + if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! + PlayState.instance.close(); // This only works because PlayState is a substate! + } +} + +/** + * Which set of options the pause menu should display. + */ +enum PauseMode +{ + /** + * The menu displayed when the player pauses the game during a song. + */ + Standard; + + /** + * The menu displayed when the player pauses the game during a song while in charting mode. + */ + Charting; + + /** + * The menu displayed when the player moves to change the game's difficulty. + */ + Difficulty; + + /** + * The menu displayed when the player pauses the game during a conversation. + */ + Conversation; + + /** + * The menu displayed when the player pauses the game during a video cutscene. + */ + Cutscene; +} + +/** + * Represents a single entry in the pause menu. + */ +typedef PauseMenuEntry = +{ + /** + * The text to display for this entry. + * TODO: Implement localization. + */ + var text:String; + + /** + * The callback to execute when the user selects this entry. + */ + var callback:PauseSubState->Void; + + /** + * If this returns true, the entry will be displayed. If it returns false, the entry will be hidden. + */ + var ?filter:Void->Bool; + + // Instance-specific properties + + /** + * The text object currently displaying this entry. + */ + var ?sprite:AtlasText; +}; diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx new file mode 100644 index 00000000..472a7f5e --- /dev/null +++ b/source/funkin/play/PlayState.hx @@ -0,0 +1,3516 @@ +package funkin.play; + +import flixel.addons.transition.FlxTransitionableState; +import flixel.addons.transition.Transition; +import flixel.FlxCamera; +import flixel.FlxObject; +import flixel.FlxSubState; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxTween; +import flixel.ui.FlxBar; +import flixel.util.FlxColor; +import flixel.util.FlxStringUtil; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.audio.VoicesGroup; +import funkin.data.dialogue.conversation.ConversationRegistry; +import funkin.data.event.SongEventRegistry; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongRegistry; +import funkin.data.stage.StageRegistry; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.Highscore.Tallies; +import funkin.input.PreciseInputManager; +import funkin.modding.events.ScriptEvent; +import funkin.api.newgrounds.Events; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.components.HealthIcon; +import funkin.play.components.PopUpStuff; +import funkin.play.cutscene.dialogue.Conversation; +import funkin.play.cutscene.VanillaCutscenes; +import funkin.play.cutscene.VideoCutscene; +import funkin.play.notes.NoteDirection; +import funkin.play.notes.notekind.NoteKindManager; +import funkin.play.notes.NoteSprite; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.notes.Strumline; +import funkin.play.notes.SustainTrail; +import funkin.play.scoring.Scoring; +import funkin.play.song.Song; +import funkin.play.stage.Stage; +import funkin.save.Save; +import funkin.ui.debug.charting.ChartEditorState; +import funkin.ui.debug.stage.StageOffsetSubState; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.transition.LoadingState; +import funkin.util.SerializerUtil; +import haxe.Int64; +#if FEATURE_DISCORD_RPC +import funkin.api.discord.DiscordClient; +#end +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.Medals; +import funkin.api.newgrounds.Leaderboards; +#end + +/** + * Parameters used to initialize the PlayState. + */ +typedef PlayStateParams = +{ + /** + * The song to play. + */ + targetSong:Song, + + /** + * The difficulty to play the song on. + * @default `Constants.DEFAULT_DIFFICULTY` + */ + ?targetDifficulty:String, + /** + * The variation to play on. + * @default `Constants.DEFAULT_VARIATION` + */ + ?targetVariation:String, + /** + * The instrumental to play with. + * Significant if the `targetSong` supports alternate instrumentals. + * @default `null` + */ + ?targetInstrumental:String, + /** + * Whether the song should start in Practice Mode. + * @default `false` + */ + ?practiceMode:Bool, + /** + * Whether the song should start in Bot Play Mode. + * @default `false` + */ + ?botPlayMode:Bool, + /** + * Whether the song should be in minimal mode. + * @default `false` + */ + ?minimalMode:Bool, + /** + * If specified, the game will jump to the specified timestamp after the countdown ends. + * @default `0.0` + */ + ?startTimestamp:Float, + /** + * If specified, the game will play the song with the given speed. + * @default `1.0` for 100% speed. + */ + ?playbackRate:Float, + /** + * If specified, the game will not load the instrumental or vocal tracks, + * and must be loaded externally. + */ + ?overrideMusic:Bool, + /** + * The initial camera follow point. + * Used to persist the position of the `cameraFollowPosition` between levels. + */ + ?cameraFollowPoint:FlxPoint, +} + +/** + * The gameplay state, where all the rhythm gaming happens. + * SubState so it can be loaded as a child of the chart editor. + */ +class PlayState extends MusicBeatSubState +{ + /** + * STATIC VARIABLES + * Static variables should be used for information that must be persisted between states or between resets, + * such as the active song or song playlist. + */ + /** + * The currently active PlayState. + * There should be only one PlayState in existance at a time, we can use a singleton. + */ + public static var instance:PlayState = null; + + /** + * This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments. + * @see https://github.com/HaxeFlixel/flixel/issues/2541 + */ + static var lastParams:PlayStateParams = null; + + /** + * PUBLIC INSTANCE VARIABLES + * Public instance variables should be used for information that must be reset or dereferenced + * every time the state is changed, but may need to be accessed externally. + */ + /** + * The currently selected stage. + */ + public var currentSong:Song = null; + + /** + * The currently selected difficulty. + */ + public var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * The currently selected variation. + */ + public var currentVariation:String = Constants.DEFAULT_VARIATION; + + /** + * The currently selected instrumental ID. + * @default `''` + */ + public var currentInstrumental:String = ''; + + /** + * The currently active Stage. This is the object containing all the props. + */ + public var currentStage:Stage = null; + + /** + * Gets set to true when the PlayState needs to reset (player opted to restart or died). + * Gets disabled once resetting happens. + */ + public var needsReset:Bool = false; + + /** + * The current 'Blueball Counter' to display in the pause menu. + * Resets when you beat a song or go back to the main menu. + */ + public var deathCounter:Int = 0; + + /** + * The player's current health. + */ + public var health:Float = Constants.HEALTH_STARTING; + + /** + * The player's current score. + * TODO: Move this to its own class. + */ + public var songScore:Int = 0; + + /** + * Start at this point in the song once the countdown is done. + * For example, if `startTimestamp` is `30000`, the song will start at the 30 second mark. + * Used for chart playtesting or practice. + */ + public var startTimestamp:Float = 0.0; + + /** + * Play back the song at this speed. + * @default `1.0` for normal speed. + */ + public var playbackRate:Float = 1.0; + + /** + * An empty FlxObject contained in the scene. + * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly. + * + * It needs to be an object in the scene for the camera to be configured to follow it. + * We optionally make this a sprite so we can draw a debug graphic with it. + */ + public var cameraFollowPoint:FlxObject; + + /** + * An FlxTween that tweens the camera to the follow point. + * Only used when tweening the camera manually, rather than tweening via follow. + */ + public var cameraFollowTween:FlxTween; + + /** + * An FlxTween that zooms the camera to the desired amount. + */ + public var cameraZoomTween:FlxTween; + + /** + * An FlxTween that changes the additive speed to the desired amount. + */ + public var scrollSpeedTweens:Array = []; + + /** + * The camera follow point from the last stage. + * Used to persist the position of the `cameraFollowPosition` between levels. + */ + public var previousCameraFollowPoint:FlxPoint = null; + + /** + * The current camera zoom level without any modifiers applied. + */ + public var currentCameraZoom:Float = FlxCamera.defaultZoom; + + /** + * Multiplier for currentCameraZoom for camera bops. + * Lerped back to 1.0x every frame. + */ + public var cameraBopMultiplier:Float = 1.0; + + /** + * Default camera zoom for the current stage. + * If we aren't in a stage, just use the default zoom (1.05x). + */ + public var stageZoom(get, never):Float; + + function get_stageZoom():Float + { + if (currentStage != null) return currentStage.camZoom; + else + return FlxCamera.defaultZoom * 1.05; + } + + /** + * The current HUD camera zoom level. + * + * The camera zoom is increased every beat, and lerped back to this value every frame, creating a smooth 'zoom-in' effect. + */ + public var defaultHUDCameraZoom:Float = FlxCamera.defaultZoom * 1.0; + + /** + * Camera bop intensity multiplier. + * Applied to cameraBopMultiplier on camera bops (usually every beat). + * @default `101.5%` + */ + public var cameraBopIntensity:Float = Constants.DEFAULT_BOP_INTENSITY; + + /** + * Intensity of the HUD camera zoom. + * Need to make this a multiplier later. Just shoving in 0.015 for now so it doesn't break. + * @default `3.0%` + */ + public var hudCameraZoomIntensity:Float = 0.015 * 2.0; + + /** + * How many beats (quarter notes) between camera zooms. + * @default One camera zoom per measure (four beats). + */ + public var cameraZoomRate:Int = Constants.DEFAULT_ZOOM_RATE; + + /** + * Whether the game is currently in the countdown before the song resumes. + */ + public var isInCountdown:Bool = false; + + /** + * Whether the game is currently in Practice Mode. + * If true, player will not lose gain or lose score from notes. + */ + public var isPracticeMode:Bool = false; + + /** + * Whether the game is currently in Bot Play Mode. + * If true, player will not lose gain or lose score from notes. + */ + public var isBotPlayMode:Bool = false; + + /** + * Whether the player has dropped below zero health, + * and we are just waiting for an animation to play out before transitioning. + */ + public var isPlayerDying:Bool = false; + + /** + * In Minimal Mode, the stage and characters are not loaded and a standard background is used. + */ + public var isMinimalMode:Bool = false; + + /** + * Whether the game is currently in an animated cutscene, and gameplay should be stopped. + */ + public var isInCutscene:Bool = false; + + /** + * Whether the inputs should be disabled for whatever reason... + * Used after the song ends, and in the Stage Editor. + */ + public var disableKeys:Bool = false; + + /** + * The previous difficulty the player was playing on. + */ + public var previousDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + public var isSubState(get, never):Bool; + + function get_isSubState():Bool + { + return this._parentState != null; + } + + public var isChartingMode(get, never):Bool; + + function get_isChartingMode():Bool + { + return this._parentState != null && Std.isOfType(this._parentState, ChartEditorState); + } + + /** + * The current dialogue. + */ + public var currentConversation:Conversation; + + /** + * Key press inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so we can account for input latency. + **/ + var inputPressQueue:Array = []; + + /** + * Key release inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so we can account for input latency. + **/ + var inputReleaseQueue:Array = []; + + /** + * If we just unpaused the game, we shouldn't be able to pause again for one frame. + */ + var justUnpaused:Bool = false; + + /** + * PRIVATE INSTANCE VARIABLES + * Private instance variables should be used for information that must be reset or dereferenced + * every time the state is reset, but should not be accessed externally. + */ + /** + * The Array containing the upcoming song events. + * The `update()` function regularly shifts these out to trigger events. + */ + var songEvents:Array; + + /** + * If true, the player is allowed to pause the game. + * Disabled during the ending of a song. + */ + var mayPauseGame:Bool = true; + + /** + * The displayed value of the player's health. + * Used to provide smooth animations based on linear interpolation of the player's health. + */ + var healthLerp:Float = Constants.HEALTH_STARTING; + + /** + * How long the user has held the "Skip Video Cutscene" button for. + */ + var skipHeldTimer:Float = 0; + + /** + * Whether the PlayState was started with instrumentals and vocals already provided. + * Used by the chart editor to prevent replacing the music. + */ + var overrideMusic:Bool = false; + + /** + * Forcibly disables all update logic while the game moves back to the Menu state. + * This is used only when a critical error occurs and the game absolutely cannot continue. + */ + var criticalFailure:Bool = false; + + /** + * False as long as the countdown has not finished yet. + */ + var startingSong:Bool = false; + + /** + * Track if we currently have the music paused for a Pause substate, so we can unpause it when we return. + */ + var musicPausedBySubState:Bool = false; + + /** + * Track any camera tweens we've paused for a Pause substate, so we can unpause them when we return. + */ + var cameraTweensPausedBySubState:List = new List(); + + /** + * False until `create()` has completed. + */ + var initialized:Bool = false; + + /** + * A group of audio tracks, used to play the song's vocals. + */ + public var vocals:VoicesGroup; + + #if FEATURE_DISCORD_RPC + // Discord RPC variables + var discordRPCAlbum:String = ''; + var discordRPCIcon:String = ''; + #end + + /** + * RENDER OBJECTS + */ + /** + * The FlxText which displays the current score. + */ + var scoreText:FlxText; + + /** + * The bar which displays the player's health. + * Dynamically updated based on the value of `healthLerp` (which is based on `health`). + */ + public var healthBar:FlxBar; + + /** + * The background image used for the health bar. + * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`. + */ + public var healthBarBG:FunkinSprite; + + /** + * The health icon representing the player. + */ + public var iconP1:HealthIcon; + + /** + * The health icon representing the opponent. + */ + public var iconP2:HealthIcon; + + /** + * The sprite group containing active player's strumline notes. + */ + public var playerStrumline:Strumline; + + /** + * The sprite group containing opponent's strumline notes. + */ + public var opponentStrumline:Strumline; + + /** + * The camera which contains, and controls visibility of, the user interface elements. + */ + public var camHUD:FlxCamera; + + /** + * The camera which contains, and controls visibility of, the stage and characters. + */ + public var camGame:FlxCamera; + + /** + * Simple helper debug variable, to be able to move the camera around for debug purposes + * without worrying about the camera tweening back to the follow point. + */ + public var debugUnbindCameraZoom:Bool = false; + + /** + * The camera which contains, and controls visibility of, a video cutscene, dialogue, pause menu and sticker transition. + */ + public var camCutscene:FlxCamera; + + /** + * The combo popups. Includes the real-time combo counter and the rating. + */ + public var comboPopUps:PopUpStuff; + + /** + * PROPERTIES + */ + /** + * If a substate is rendering over the PlayState, it is paused and normal update logic is skipped. + * Examples include: + * - The Pause screen is open. + * - The Game Over screen is open. + * - The Chart Editor screen is open. + */ + var isGamePaused(get, never):Bool; + + function get_isGamePaused():Bool + { + // Note: If there is a substate which requires the game to act unpaused, + // this should be changed to include something like `&& Std.isOfType()` + return this.subState != null; + } + + var isExitingViaPauseMenu(get, never):Bool; + + function get_isExitingViaPauseMenu():Bool + { + if (this.subState == null) return false; + if (!Std.isOfType(this.subState, PauseSubState)) return false; + + var pauseSubState:PauseSubState = cast this.subState; + return !pauseSubState.allowInput; + } + + /** + * Data for the current difficulty for the current song. + * Includes chart data, scroll speed, and other information. + */ + public var currentChart(get, never):SongDifficulty; + + function get_currentChart():SongDifficulty + { + if (currentSong == null || currentDifficulty == null) return null; + return currentSong.getDifficulty(currentDifficulty, currentVariation); + } + + /** + * The internal ID of the currently active Stage. + * Used to retrieve the data required to build the `currentStage`. + */ + public var currentStageId(get, never):String; + + function get_currentStageId():String + { + if (currentChart == null || currentChart.stage == null || currentChart.stage == '') return Constants.DEFAULT_STAGE; + return currentChart.stage; + } + + /** + * The length of the current song, in milliseconds. + */ + var currentSongLengthMs(get, never):Float; + + function get_currentSongLengthMs():Float + { + return FlxG?.sound?.music?.length; + } + + // TODO: Refactor or document + var generatedMusic:Bool = false; + + var skipEndingTransition:Bool = false; + + static final BACKGROUND_COLOR:FlxColor = FlxColor.BLACK; + + /** + * Instantiate a new PlayState. + * @param params The parameters used to initialize the PlayState. + * Includes information about what song to play and more. + */ + public function new(params:PlayStateParams) + { + super(); + + // Validate parameters. + if (params == null && lastParams == null) + { + throw 'PlayState constructor called with no available parameters.'; + } + else if (params == null) + { + trace('WARNING: PlayState constructor called with no parameters. Reusing previous parameters.'); + params = lastParams; + } + else + { + lastParams = params; + } + + // Apply parameters. + currentSong = params.targetSong; + if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty; + previousDifficulty = currentDifficulty; + if (params.targetVariation != null) currentVariation = params.targetVariation; + if (params.targetInstrumental != null) currentInstrumental = params.targetInstrumental; + isPracticeMode = params.practiceMode ?? false; + isBotPlayMode = params.botPlayMode ?? false; + isMinimalMode = params.minimalMode ?? false; + startTimestamp = params.startTimestamp ?? 0.0; + playbackRate = params.playbackRate ?? 1.0; + overrideMusic = params.overrideMusic ?? false; + previousCameraFollowPoint = params.cameraFollowPoint; + + // Don't do anything else here! Wait until create() when we attach to the camera. + } + + /** + * Called when the PlayState is switched to. + */ + public override function create():Void + { + if (instance != null) + { + // TODO: Do something in this case? IDK. + trace('WARNING: PlayState instance already exists. This should not happen.'); + } + instance = this; + + if (!assertChartExists()) return; + + // TODO: Add something to toggle this on! + if (false) + { + // Displays the camera follow point as a sprite for debug purposes. + var cameraFollowPoint = new FunkinSprite(0, 0); + cameraFollowPoint.makeSolidColor(8, 8, 0xFF00FF00); + cameraFollowPoint.visible = false; + cameraFollowPoint.zIndex = 1000000; + this.cameraFollowPoint = cameraFollowPoint; + } + else + { + // Camera follow point is an invisible point in space. + cameraFollowPoint = new FlxObject(0, 0); + } + + // This state receives update() even when a substate is active. + this.persistentUpdate = true; + // This state receives draw calls even when a substate is active. + this.persistentDraw = true; + + // Stop any pre-existing music. + if (!overrideMusic && FlxG.sound.music != null) FlxG.sound.music.stop(); + + // Prepare the current song's instrumental and vocals to be played. + if (!overrideMusic && currentChart != null) + { + currentChart.cacheInst(currentInstrumental); + currentChart.cacheVocals(); + } + + // Prepare the Conductor. + Conductor.instance.forceBPM(null); + + if (currentChart.offsets != null) + { + Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(currentInstrumental); + } + + Conductor.instance.mapTimeChanges(currentChart.timeChanges); + var pre:Float = (Conductor.instance.beatLengthMs * -5) + startTimestamp; + + trace('Attempting to start at ' + pre); + + Conductor.instance.update(pre); + + // The song is now loaded. We can continue to initialize the play state. + initCameras(); + initHealthBar(); + if (!isMinimalMode) + { + initStage(); + initCharacters(); + } + else + { + initMinimalMode(); + } + initStrumlines(); + initPopups(); + + #if FEATURE_DISCORD_RPC + // Initialize Discord Rich Presence. + initDiscord(); + #end + + // Read the song's note data and pass it to the strumlines. + generateSong(); + + // Reset the camera's zoom and force it to focus on the camera follow point. + resetCamera(); + + initPreciseInputs(); + + FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height); + + // The song is loaded and in the process of starting. + // This gets set back to false when the chart actually starts. + startingSong = true; + + // TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead. + if ((currentSong?.id ?? '').toLowerCase() == 'winter-horrorland') + { + // VanillaCutscenes will call startCountdown later. + VanillaCutscenes.playHorrorStartCutscene(); + } + else + { + // Call a script event to start the countdown. + // Songs with cutscenes should call event.cancel(). + // As long as they call `PlayState.instance.startCountdown()` later, the countdown will start. + startCountdown(); + } + + // Do this last to prevent beatHit from being called before create() is done. + super.create(); + + leftWatermarkText.cameras = [camHUD]; + rightWatermarkText.cameras = [camHUD]; + + // Initialize some debug stuff. + #if FEATURE_DEBUG_FUNCTIONS + // Display the version number (and git commit hash) in the bottom right corner. + this.rightWatermarkText.text = Constants.VERSION; + + FlxG.console.registerObject('playState', this); + #end + + initialized = true; + + // This step ensures z-indexes are applied properly, + // and it's important to call it last so all elements get affected. + refresh(); + } + + function assertChartExists():Bool + { + // Returns null if the song failed to load or doesn't have the selected difficulty. + if (currentSong == null || currentChart == null || currentChart.notes == null) + { + // We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic. + criticalFailure = true; + + // Choose an error message. + var message:String = 'There was a critical error. Click OK to return to the main menu.'; + if (currentSong == null) + { + message = 'There was a critical error loading this song\'s chart. Click OK to return to the main menu.'; + } + else if (currentDifficulty == null) + { + message = 'There was a critical error selecting a difficulty for this song. Click OK to return to the main menu.'; + } + else if (currentChart == null) + { + message = 'There was a critical error retrieving data for this song on "$currentDifficulty" difficulty with variation "$currentVariation". Click OK to return to the main menu.'; + } + else if (currentChart.notes == null) + { + message = 'There was a critical error retrieving note data for this song on "$currentDifficulty" difficulty with variation "$currentVariation". Click OK to return to the main menu.'; + } + + // Display a popup. This blocks the application until the user clicks OK. + lime.app.Application.current.window.alert(message, 'Error loading PlayState'); + + // Force the user back to the main menu. + if (isSubState) + { + this.close(); + } + else + { + this.remove(currentStage); + FlxG.switchState(() -> new MainMenuState()); + } + return false; + } + + return true; + } + + public override function update(elapsed:Float):Void + { + if (criticalFailure) return; + + super.update(elapsed); + + var list = FlxG.sound.list; + updateHealthBar(); + updateScoreText(); + + // Handle restarting the song when needed (player death or pressing Retry) + if (needsReset) + { + if (!assertChartExists()) return; + + prevScrollTargets = []; + + var retryEvent = new SongRetryEvent(currentDifficulty); + + previousDifficulty = currentDifficulty; + + dispatchEvent(retryEvent); + + resetCamera(); + + var fromDeathState = isPlayerDying; + + persistentUpdate = true; + persistentDraw = true; + + startingSong = true; + isPlayerDying = false; + + // Reset music properly. + if (FlxG.sound.music != null) + { + FlxG.sound.music.time = startTimestamp - Conductor.instance.combinedOffset; + FlxG.sound.music.pitch = playbackRate; + FlxG.sound.music.pause(); + } + + if (!overrideMusic) + { + // Stop the vocals if they already exist. + if (vocals != null) vocals.stop(); + vocals = currentChart.buildVocals(currentInstrumental); + + if (vocals.members.length == 0) + { + trace('WARNING: No vocals found for this song.'); + } + } + vocals.pause(); + vocals.time = 0 - Conductor.instance.combinedOffset; + + if (FlxG.sound.music != null) FlxG.sound.music.volume = 1; + vocals.volume = 1; + vocals.playerVolume = 1; + vocals.opponentVolume = 1; + + if (currentStage != null) currentStage.resetStage(); + + if (!fromDeathState) + { + playerStrumline.vwooshNotes(); + opponentStrumline.vwooshNotes(); + } + + playerStrumline.clean(); + opponentStrumline.clean(); + + // 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; + cameraZoomRate = Constants.DEFAULT_ZOOM_RATE; + + health = Constants.HEALTH_STARTING; + songScore = 0; + Highscore.tallies.combo = 0; + // 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); + + needsReset = false; + } + + // Update the conductor. + if (startingSong) + { + if (isInCountdown) + { + // Do NOT apply offsets at this point, because they already got applied the previous frame! + Conductor.instance.update(Conductor.instance.songPosition + elapsed * 1000, false); + if (Conductor.instance.songPosition >= (startTimestamp + Conductor.instance.combinedOffset)) + { + trace("started song at " + Conductor.instance.songPosition); + startSong(); + } + } + } + else + { + if (Constants.EXT_SOUND == 'mp3') + { + Conductor.instance.formatOffset = Constants.MP3_DELAY_MS; + } + else + { + Conductor.instance.formatOffset = 0.0; + } + + Conductor.instance.update(Conductor.instance.songPosition + elapsed * 1000, false); // Normal conductor update. + + // If, after updating the conductor, the instrumental has finished, end the song immediately. + // This helps prevent a major bug where the level suddenly loops back to the start or middle. + if (Conductor.instance.songPosition >= (FlxG.sound.music.endTime ?? FlxG.sound.music.length)) + { + if (mayPauseGame) endSong(skipEndingTransition); + } + } + + var androidPause:Bool = false; + + #if android + androidPause = FlxG.android.justPressed.BACK; + #end + + // Attempt to pause the game. + if ((controls.PAUSE || androidPause) && isInCountdown && mayPauseGame && !justUnpaused) + { + var event = new PauseScriptEvent(FlxG.random.bool((1 / 1000) * 100)); + + dispatchEvent(event); + + if (!event.eventCanceled) + { + // Pause updates while the substate is open, preventing the game state from advancing. + persistentUpdate = false; + // Enable drawing while the substate is open, allowing the game state to be shown behind the pause menu. + persistentDraw = true; + + // There is a 1/1000 change to use a special pause menu. + // This prevents the player from resuming, but that's the point. + // It's a reference to Gitaroo Man, which doesn't let you pause the game. + if (!isSubState && event.gitaroo) + { + this.remove(currentStage); + FlxG.switchState(() -> new GitarooPause( + { + targetSong: currentSong, + targetDifficulty: currentDifficulty, + targetVariation: currentVariation, + })); + } + else + { + var boyfriendPos:FlxPoint = new FlxPoint(0, 0); + + // Prevent the game from crashing if Boyfriend isn't present. + if (currentStage != null && currentStage.getBoyfriend() != null) + { + boyfriendPos = currentStage.getBoyfriend().getScreenPosition(); + } + + var pauseSubState:FlxSubState = new PauseSubState({mode: isChartingMode ? Charting : Standard}); + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + pauseSubState.camera = camCutscene; + openSubState(pauseSubState); + // boyfriendPos.put(); // TODO: Why is this here? + } + + #if FEATURE_DISCORD_RPC + DiscordClient.instance.setPresence( + { + details: 'Paused - ${buildDiscordRPCDetails()}', + + state: buildDiscordRPCState(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + #end + } + } + + // Cap health. + if (health > Constants.HEALTH_MAX) health = Constants.HEALTH_MAX; + if (health < Constants.HEALTH_MIN) health = Constants.HEALTH_MIN; + + // Apply camera zoom + multipliers. + if (subState == null && cameraZoomRate > 0.0) // && !isInCutscene) + { + cameraBopMultiplier = FlxMath.lerp(1.0, cameraBopMultiplier, 0.95); // Lerp bop multiplier back to 1.0x + var zoomPlusBop = currentCameraZoom * cameraBopMultiplier; // Apply camera bop multiplier. + if (!debugUnbindCameraZoom) FlxG.camera.zoom = zoomPlusBop; // Actually apply the zoom to the camera. + + camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95); + } + + if (currentStage != null && currentStage.getBoyfriend() != null) + { + FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation()); + } + FlxG.watch.addQuick('health', health); + FlxG.watch.addQuick('cameraBopIntensity', cameraBopIntensity); + + // TODO: Add a song event for Handle GF dance speed. + + // Handle player death. + if (!isInCutscene && !disableKeys) + { + // RESET = Quick Game Over Screen + if (controls.RESET) + { + health = Constants.HEALTH_MIN; + trace('RESET = True'); + } + + #if CAN_CHEAT // brandon's a pussy + if (controls.CHEAT) + { + health += 0.25 * Constants.HEALTH_MAX; // +25% health. + trace('User is cheating!'); + } + #end + + if (health <= Constants.HEALTH_MIN && !isPracticeMode && !isPlayerDying) + { + vocals.pause(); + + if (FlxG.sound.music != null) FlxG.sound.music.pause(); + + deathCounter += 1; + #if FEATURE_NEWGROUNDS + Events.logFailSong(currentSong.id, currentVariation); + #end + + dispatchEvent(new ScriptEvent(GAME_OVER)); + + // Disable updates, preventing animations in the background from playing. + persistentUpdate = false; + #if FEATURE_DEBUG_FUNCTIONS + if (FlxG.keys.pressed.THREE) + { + // TODO: Change the key or delete this? + // In debug builds, pressing 3 to kill the player makes the background transparent. + persistentDraw = true; + } + else + { + #end + persistentDraw = false; + #if FEATURE_DEBUG_FUNCTIONS + } + #end + + isPlayerDying = true; + + var deathPreTransitionDelay = currentStage?.getBoyfriend()?.getDeathPreTransitionDelay() ?? 0.0; + if (deathPreTransitionDelay > 0) + { + new FlxTimer().start(deathPreTransitionDelay, function(_) { + moveToGameOver(); + }); + } + else + { + // Transition immediately. + moveToGameOver(); + } + + #if FEATURE_DISCORD_RPC + DiscordClient.instance.setPresence( + { + details: 'Game Over - ${buildDiscordRPCDetails()}', + state: buildDiscordRPCState(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + #end + } + else if (isPlayerDying) + { + // Wait up. + } + } + + processSongEvents(); + + // Handle keybinds. + processInputQueue(); + if (!isInCutscene && !disableKeys) debugKeyShit(); + if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed); + + // Moving notes into position is now done by Strumline.update(). + if (!isInCutscene) processNotes(elapsed); + + justUnpaused = false; + } + + function moveToGameOver():Void + { + // Reset and update a bunch of values in advance for the transition back from the game over substate. + playerStrumline.clean(); + opponentStrumline.clean(); + + songScore = 0; + updateScoreText(); + + health = Constants.HEALTH_STARTING; + healthLerp = health; + + healthBar.value = healthLerp; + + if (!isMinimalMode) + { + if (iconP1 != null) iconP1.updatePosition(); + if (iconP2 != null) iconP2.updatePosition(); + } + + // Transition to the game over substate. + var gameOverSubState = new GameOverSubState( + { + isChartingMode: isChartingMode, + transparent: persistentDraw + }); + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + openSubState(gameOverSubState); + } + + function processSongEvents():Void + { + // Query and activate song events. + // TODO: Check that these work appropriately even when songPosition is less than 0, to play events during countdown. + if (songEvents != null && songEvents.length > 0) + { + var songEventsToActivate:Array = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition); + + if (songEventsToActivate.length > 0) + { + trace('Found ${songEventsToActivate.length} event(s) to activate.'); + for (event in songEventsToActivate) + { + // If an event is trying to play, but it's over 1 second old, skip it. + var eventAge:Float = Conductor.instance.songPosition - event.time; + if (eventAge > 1000) + { + event.activated = true; + continue; + }; + + var eventEvent:SongEventScriptEvent = new SongEventScriptEvent(event); + dispatchEvent(eventEvent); + // Calling event.cancelEvent() skips the event. Neat! + if (!eventEvent.eventCanceled) + { + SongEventRegistry.handleEvent(event); + } + } + } + } + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + // ORDER: Module, Stage, Character, Song, Conversation, Note + // Modules should get the first chance to cancel the event. + + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + // Dispatch event to note kind scripts + NoteKindManager.callEvent(event); + + // Dispatch event to stage script. + ScriptEventDispatcher.callEvent(currentStage, event); + + // Dispatch event to character script(s). + if (currentStage != null) currentStage.dispatchToCharacters(event); + + // Dispatch event to song script. + ScriptEventDispatcher.callEvent(currentSong, event); + + // Dispatch event to conversation script. + ScriptEventDispatcher.callEvent(currentConversation, event); + } + + /** + * Function called before opening a new substate. + * @param subState The substate to open. + */ + public override function openSubState(subState:FlxSubState):Void + { + // If there is a substate which requires the game to continue, + // then make this a condition. + var shouldPause:Bool = (Std.isOfType(subState, PauseSubState) || Std.isOfType(subState, GameOverSubState)); + + if (shouldPause) + { + // Pause the music. + if (FlxG.sound.music != null) + { + if (FlxG.sound.music.playing) + { + FlxG.sound.music.pause(); + musicPausedBySubState = true; + } + + // Pause vocals. + // Not tracking that we've done this via a bool because vocal re-syncing involves pausing the vocals anyway. + if (vocals != null) vocals.pause(); + } + + // Pause camera tweening, and keep track of which tweens we pause. + if (cameraFollowTween != null && cameraFollowTween.active) + { + cameraFollowTween.active = false; + cameraTweensPausedBySubState.add(cameraFollowTween); + } + + if (cameraZoomTween != null && cameraZoomTween.active) + { + cameraZoomTween.active = false; + cameraTweensPausedBySubState.add(cameraZoomTween); + } + + // Pause camera follow + FlxG.camera.followLerp = 0; + + for (tween in scrollSpeedTweens) + { + if (tween != null && tween.active) + { + tween.active = false; + cameraTweensPausedBySubState.add(tween); + } + } + + // Pause the countdown. + Countdown.pauseCountdown(); + } + + super.openSubState(subState); + } + + /** + * Function called before closing the current substate. + * @param subState + */ + public override function closeSubState():Void + { + if (Std.isOfType(subState, PauseSubState)) + { + var event:ScriptEvent = new ScriptEvent(RESUME, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + // Resume music if we paused it. + if (musicPausedBySubState) + { + FlxG.sound.music.play(); + musicPausedBySubState = false; + } + + // Resume camera tweens if we paused any. + for (camTween in cameraTweensPausedBySubState) + { + camTween.active = true; + } + cameraTweensPausedBySubState.clear(); + + // Resume camera follow + FlxG.camera.followLerp = Constants.DEFAULT_CAMERA_FOLLOW_RATE; + + if (currentConversation != null) + { + currentConversation.resumeMusic(); + } + + // Re-sync vocals. + if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); + + // Resume the countdown. + Countdown.resumeCountdown(); + + #if FEATURE_DISCORD_RPC + if (Conductor.instance.songPosition > 0) + { + // DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true, + // currentSongLengthMs - Conductor.instance.songPosition); + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + } + else + { + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + } + #end + + justUnpaused = true; + } + else if (Std.isOfType(subState, Transition)) + { + // Do nothing. + } + + super.closeSubState(); + } + + /** + * Function called when the game window gains focus. + */ + public override function onFocus():Void + { + if (VideoCutscene.isPlaying() && FlxG.autoPause && isGamePaused) VideoCutscene.pauseVideo(); + #if html5 + else + VideoCutscene.resumeVideo(); + #end + + #if FEATURE_DISCORD_RPC + if (health > Constants.HEALTH_MIN && !isGamePaused && FlxG.autoPause) + { + if (Conductor.instance.songPosition > 0.0) + { + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + } + else + { + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + // DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true, + // currentSongLengthMs - Conductor.instance.songPosition); + } + } + #end + + super.onFocus(); + } + + /** + * Function called when the game window loses focus. + */ + public override function onFocusLost():Void + { + #if html5 + if (FlxG.autoPause) VideoCutscene.pauseVideo(); + #end + + #if FEATURE_DISCORD_RPC + if (health > Constants.HEALTH_MIN && !isGamePaused && FlxG.autoPause) + { + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + } + #end + + super.onFocusLost(); + } + + /** + * Call this by pressing F5 on a debug build. + */ + override function reloadAssets():Void + { + performCleanup(); + + funkin.modding.PolymodHandler.forceReloadAssets(); + lastParams.targetSong = SongRegistry.instance.fetchEntry(currentSong.id); + LoadingState.loadPlayState(lastParams); + } + + override function stepHit():Bool + { + if (criticalFailure || !initialized) return false; + + // super.stepHit() returns false if a module cancelled the event. + if (!super.stepHit()) return false; + + if (isGamePaused) return false; + + if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.instance.currentStep)); + if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.instance.currentStep)); + + return true; + } + + override function beatHit():Bool + { + if (criticalFailure || !initialized) return false; + + // super.beatHit() returns false if a module cancelled the event. + if (!super.beatHit()) return false; + + if (isGamePaused) return false; + + if (generatedMusic) + { + // TODO: Sort more efficiently, or less often, to improve performance. + // activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING); + } + + if (FlxG.sound.music != null) + { + var correctSync:Float = Math.min(FlxG.sound.music.length, Math.max(0, Conductor.instance.songPosition - Conductor.instance.combinedOffset)); + var playerVoicesError:Float = 0; + var opponentVoicesError:Float = 0; + + if (vocals != null && vocals.playing) + { + @:privateAccess // todo: maybe make the groups public :thinking: + { + vocals.playerVoices.forEachAlive(function(voice:FunkinSound) { + var currentRawVoiceTime:Float = voice.time + vocals.playerVoicesOffset; + if (Math.abs(currentRawVoiceTime - correctSync) > Math.abs(playerVoicesError)) playerVoicesError = currentRawVoiceTime - correctSync; + }); + + vocals.opponentVoices.forEachAlive(function(voice:FunkinSound) { + var currentRawVoiceTime:Float = voice.time + vocals.opponentVoicesOffset; + if (Math.abs(currentRawVoiceTime - correctSync) > Math.abs(opponentVoicesError)) opponentVoicesError = currentRawVoiceTime - correctSync; + }); + } + } + + if (!startingSong + && (Math.abs(FlxG.sound.music.time - correctSync) > 100 + || Math.abs(playerVoicesError) > 100 + || Math.abs(opponentVoicesError) > 100)) + { + trace("VOCALS NEED RESYNC"); + if (vocals != null) + { + trace(playerVoicesError); + trace(opponentVoicesError); + } + trace(FlxG.sound.music.time); + trace(correctSync); + resyncVocals(); + } + } + + // Only bop camera if zoom level is below 135% + if (Preferences.zoomCamera + && FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom) + && cameraZoomRate > 0 + && Conductor.instance.currentBeat % cameraZoomRate == 0) + { + // Set zoom multiplier for camera bop. + cameraBopMultiplier = cameraBopIntensity; + // HUD camera zoom still uses old system. To change. (+3%) + camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom; + } + // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.instance.currentBeat} % ${cameraZoomRate} == ${Conductor.instance.currentBeat % cameraZoomRate}}'); + + // That combo milestones that got spoiled that one time. + // Comes with NEAT visual and audio effects. + + // bruh this var is bonkers i thot it was a function lmfaooo + + // Break up into individual lines to aid debugging. + + if (playerStrumline != null) playerStrumline.onBeatHit(); + if (opponentStrumline != null) opponentStrumline.onBeatHit(); + + return true; + } + + public override function destroy():Void + { + performCleanup(); + + super.destroy(); + } + + public override function initConsoleHelpers():Void + { + FlxG.console.registerFunction("debugUnbindCameraZoom", () -> { + debugUnbindCameraZoom = !debugUnbindCameraZoom; + }); + }; + + /** + * Initializes the game and HUD cameras. + */ + function initCameras():Void + { + camGame = new FunkinCamera('playStateCamGame'); + camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage. + camHUD = new FlxCamera(); + camHUD.bgColor.alpha = 0; // Show the game scene behind the camera. + camCutscene = new FlxCamera(); + camCutscene.bgColor.alpha = 0; // Show the game scene behind the camera. + + FlxG.cameras.reset(camGame); + FlxG.cameras.add(camHUD, false); + FlxG.cameras.add(camCutscene, false); + + // Configure camera follow point. + if (previousCameraFollowPoint != null) + { + cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); + previousCameraFollowPoint = null; + } + add(cameraFollowPoint); + } + + /** + * Initializes the health bar on the HUD. + */ + function initHealthBar():Void + { + var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9; + healthBarBG = FunkinSprite.create(0, healthBarYPos, 'healthBar'); + healthBarBG.screenCenter(X); + healthBarBG.scrollFactor.set(0, 0); + healthBarBG.zIndex = 800; + add(healthBarBG); + + healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this, + 'healthLerp', 0, 2); + healthBar.scrollFactor.set(); + healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN); + healthBar.zIndex = 801; + add(healthBar); + + // The score text below the health bar. + scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20); + scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + scoreText.scrollFactor.set(); + scoreText.zIndex = 802; + add(scoreText); + + // Move the health bar to the HUD camera. + healthBar.cameras = [camHUD]; + healthBarBG.cameras = [camHUD]; + scoreText.cameras = [camHUD]; + } + + /** + * Generates the stage and all its props. + */ + function initStage():Void + { + loadStage(currentStageId); + } + + function initMinimalMode():Void + { + // Create the green background. + var menuBG = FunkinSprite.create('menuDesat'); + menuBG.color = 0xFF4CAF50; + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + menuBG.zIndex = -1000; + add(menuBG); + } + + /** + * Loads stage data from cache, assembles the props, + * and adds it to the state. + * @param id + */ + function loadStage(id:String):Void + { + currentStage = StageRegistry.instance.fetchEntry(id); + + if (currentStage != null) + { + currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory. + + // Actually create and position the sprites. + var event:ScriptEvent = new ScriptEvent(CREATE, false); + ScriptEventDispatcher.callEvent(currentStage, event); + + resetCameraZoom(); + + // Add the stage to the scene. + this.add(currentStage); + + #if FEATURE_DEBUG_FUNCTIONS + FlxG.console.registerObject('stage', currentStage); + #end + } + else + { + // lolol + lime.app.Application.current.window.alert('Unable to load stage ${id}, is its data corrupted?.', 'Stage Error'); + } + } + + public function resetCameraZoom():Void + { + if (PlayState.instance.isMinimalMode) return; + // Apply camera zoom level from stage data. + currentCameraZoom = stageZoom; + FlxG.camera.zoom = currentCameraZoom; + + // Reset bop multiplier. + cameraBopMultiplier = 1.0; + } + + /** + * Generates the character sprites and adds them to the stage. + */ + function initCharacters():Void + { + if (currentSong == null || currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the variation we are playing on by manipulating targetVariation. + + // + // GIRLFRIEND + // + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.girlfriend); + + if (girlfriend != null) + { + // Don't need to do anything. + } + else if (currentCharacterData.girlfriend != '') + { + trace('WARNING: Could not load girlfriend character with ID ${currentCharacterData.girlfriend}, skipping...'); + } + else + { + // Chosen GF was '' so we don't load one. + } + + // + // DAD + // + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.opponent); + + if (dad != null) + { + // + // OPPONENT HEALTH ICON + // + iconP2 = new HealthIcon('dad', 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + dad.initHealthIcon(true); // Apply the character ID here + iconP2.zIndex = 850; + add(iconP2); + iconP2.cameras = [camHUD]; + + #if FEATURE_DISCORD_RPC + discordRPCAlbum = 'album-${currentChart.album}'; + discordRPCIcon = 'icon-${currentCharacterData.opponent}'; + #end + } + + // + // BOYFRIEND + // + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.player); + + if (boyfriend != null) + { + // + // PLAYER HEALTH ICON + // + iconP1 = new HealthIcon('bf', 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + boyfriend.initHealthIcon(false); // Apply the character ID here + iconP1.zIndex = 850; + add(iconP1); + iconP1.cameras = [camHUD]; + } + + // + // ADD CHARACTERS TO SCENE + // + + if (currentStage != null) + { + // Characters get added to the stage, not the main scene. + if (girlfriend != null) + { + currentStage.addCharacter(girlfriend, GF); + + #if FEATURE_DEBUG_FUNCTIONS + FlxG.console.registerObject('gf', girlfriend); + #end + } + + if (boyfriend != null) + { + currentStage.addCharacter(boyfriend, BF); + + #if FEATURE_DEBUG_FUNCTIONS + FlxG.console.registerObject('bf', boyfriend); + #end + } + + if (dad != null) + { + currentStage.addCharacter(dad, DAD); + // Camera starts at dad. + cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); + + #if FEATURE_DEBUG_FUNCTIONS + FlxG.console.registerObject('dad', dad); + #end + } + + // Rearrange by z-indexes. + currentStage.refresh(); + } + } + + /** + * Constructs the strumlines for each player. + */ + function initStrumlines():Void + { + var noteStyleId:String = currentChart.noteStyle; + var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault(); + + playerStrumline = new Strumline(noteStyle, !isBotPlayMode); + playerStrumline.onNoteIncoming.add(onStrumlineNoteIncoming); + opponentStrumline = new Strumline(noteStyle, false); + opponentStrumline.onNoteIncoming.add(onStrumlineNoteIncoming); + add(playerStrumline); + add(opponentStrumline); + + // Position the player strumline on the right half of the screen + playerStrumline.x = FlxG.width / 2 + Constants.STRUMLINE_X_OFFSET; // Classic style + // playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; // Centered style + playerStrumline.y = Preferences.downscroll ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET - noteStyle.getStrumlineOffsets()[1] : Constants.STRUMLINE_Y_OFFSET; + playerStrumline.zIndex = 1001; + playerStrumline.cameras = [camHUD]; + + // Position the opponent strumline on the left half of the screen + opponentStrumline.x = Constants.STRUMLINE_X_OFFSET; + opponentStrumline.y = Preferences.downscroll ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET - noteStyle.getStrumlineOffsets()[1] : Constants.STRUMLINE_Y_OFFSET; + opponentStrumline.zIndex = 1000; + opponentStrumline.cameras = [camHUD]; + + playerStrumline.fadeInArrows(); + opponentStrumline.fadeInArrows(); + } + + /** + * Configures the judgement and combo popups. + */ + function initPopups():Void + { + var noteStyleId:String = currentChart.noteStyle; + var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault(); + // Initialize the judgements and combo meter. + comboPopUps = new PopUpStuff(noteStyle); + comboPopUps.zIndex = 900; + add(comboPopUps); + comboPopUps.cameras = [camHUD]; + } + + /** + * Initializes the Discord Rich Presence. + */ + function initDiscord():Void + { + #if FEATURE_DISCORD_RPC + // Determine the details strings once and reuse them. + + // Updating Discord Rich Presence. + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + #end + + #if FEATURE_DISCORD_RPC + // Updating Discord Rich Presence. + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + #end + } + + function buildDiscordRPCDetails():String + { + if (PlayStatePlaylist.isStoryMode) + { + return 'Story Mode: ${PlayStatePlaylist.campaignTitle}'; + } + else + { + if (isChartingMode) + { + return 'Chart Editor [Playtest]'; + } + else if (isPracticeMode) + { + return 'Freeplay [Practice]'; + } + else if (isBotPlayMode) + { + return 'Freeplay [Bot Play]'; + } + else + { + return 'Freeplay'; + } + } + } + + function buildDiscordRPCState():String + { + var discordRPCDifficulty = PlayState.instance.currentDifficulty.replace('-', ' ').toTitleCase(); + return '${currentChart.songName} [${discordRPCDifficulty}]'; + } + + function initPreciseInputs():Void + { + PreciseInputManager.instance.onInputPressed.add(onKeyPress); + PreciseInputManager.instance.onInputReleased.add(onKeyRelease); + } + + /** + * Initializes the song (applying the chart, generating the notes, etc.) + * Should be done before the countdown starts. + */ + function generateSong():Void + { + if (currentChart == null) + { + trace('Song difficulty could not be loaded.'); + } + + // Conductor.instance.forceBPM(currentChart.getStartingBPM()); + + if (!overrideMusic) + { + // Stop the vocals if they already exist. + if (vocals != null) vocals.stop(); + vocals = currentChart.buildVocals(currentInstrumental); + + if (vocals.members.length == 0) + { + trace('WARNING: No vocals found for this song.'); + } + } + + regenNoteData(); + + var event:ScriptEvent = new ScriptEvent(CREATE, false); + ScriptEventDispatcher.callEvent(currentSong, event); + + generatedMusic = true; + } + + /** + * Read note data from the chart and generate the notes. + */ + function regenNoteData(startTime:Float = 0):Void + { + Highscore.tallies.combo = 0; + Highscore.tallies = new Tallies(); + + var event:SongLoadScriptEvent = new SongLoadScriptEvent(currentChart.song.id, currentChart.difficulty, currentChart.notes.copy(), currentChart.getEvents()); + + dispatchEvent(event); + + var builtNoteData = event.notes; + var builtEventData = event.events; + + songEvents = builtEventData; + SongEventRegistry.resetEvents(songEvents); + + // Reset the notes on each strumline. + var playerNoteData:Array = []; + var opponentNoteData:Array = []; + + for (songNote in builtNoteData) + { + var strumTime:Float = songNote.time; + if (strumTime < startTime) continue; // Skip notes that are before the start time. + + var noteData:Int = songNote.getDirection(); + var playerNote:Bool = true; + + if (noteData > 3) playerNote = false; + + switch (songNote.getStrumlineIndex()) + { + case 0: + playerNoteData.push(songNote); + // increment totalNotes for total possible notes able to be hit by the player + Highscore.tallies.totalNotes++; + case 1: + opponentNoteData.push(songNote); + } + } + + playerStrumline.applyNoteData(playerNoteData); + opponentStrumline.applyNoteData(opponentNoteData); + } + + function onStrumlineNoteIncoming(noteSprite:NoteSprite):Void + { + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_INCOMING, noteSprite, 0, false); + + dispatchEvent(event); + } + + /** + * Prepares to start the countdown. + * Ends any running cutscenes, creates the strumlines, and starts the countdown. + * This is public so that scripts can call it. + */ + public function startCountdown():Void + { + // If Countdown.performCountdown returns false, then the countdown was canceled by a script. + var result:Bool = Countdown.performCountdown(); + if (!result) return; + + isInCutscene = false; + + // TODO: Maybe tween in the camera after any cutscenes. + camHUD.visible = true; + } + + /** + * Displays a dialogue cutscene with the given ID. + * This is used by song scripts to display dialogue. + */ + public function startConversation(conversationId:String):Void + { + isInCutscene = true; + + currentConversation = ConversationRegistry.instance.fetchEntry(conversationId); + if (currentConversation == null) return; + if (!currentConversation.alive) currentConversation.revive(); + + currentConversation.completeCallback = onConversationComplete; + currentConversation.cameras = [camCutscene]; + currentConversation.zIndex = 1000; + add(currentConversation); + refresh(); + + var event:ScriptEvent = new ScriptEvent(CREATE, false); + ScriptEventDispatcher.callEvent(currentConversation, event); + } + + /** + * Handler function called when a conversation ends. + */ + function onConversationComplete():Void + { + isInCutscene = false; + + if (currentConversation != null) + { + currentConversation.kill(); + remove(currentConversation); + currentConversation = null; + } + + if (startingSong && !isInCountdown) + { + startCountdown(); + } + } + + /** + * Starts playing the song after the countdown has completed. + */ + function startSong():Void + { + startingSong = false; + + if (!overrideMusic && !isGamePaused && currentChart != null) + { + currentChart.playInst(1.0, currentInstrumental, false); + } + + if (FlxG.sound.music == null) + { + FlxG.log.error('PlayState failed to initialize instrumental!'); + return; + } + + FlxG.sound.music.onComplete = function() { + if (mayPauseGame) 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; + // trace('${FlxG.sound.music.time}'); + // trace('${vocals.time}'); + resyncVocals(); + + #if FEATURE_DISCORD_RPC + // Updating Discord Rich Presence (with Time Left) + DiscordClient.instance.setPresence( + { + state: buildDiscordRPCState(), + details: buildDiscordRPCDetails(), + + largeImageKey: discordRPCAlbum, + smallImageKey: discordRPCIcon + }); + // DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true, currentSongLengthMs); + #end + + if (startTimestamp > 0) + { + // FlxG.sound.music.time = startTimestamp - Conductor.instance.combinedOffset; + handleSkippedNotes(); + } + + dispatchEvent(new ScriptEvent(SONG_START)); + + #if FEATURE_NEWGROUNDS + Events.logStartSong(currentSong.id, currentVariation); + #end + } + + /** + * Resyncronize the vocal tracks if they have become offset from the instrumental. + */ + function resyncVocals():Void + { + if (vocals == null) return; + + // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.) + if (!(FlxG.sound.music?.playing ?? false)) return; + + var timeToPlayAt:Float = Math.min(FlxG.sound.music.length, Math.max(0, Conductor.instance.songPosition - Conductor.instance.combinedOffset)); + trace('Resyncing vocals to ${timeToPlayAt}'); + FlxG.sound.music.pause(); + vocals.pause(); + + FlxG.sound.music.time = timeToPlayAt; + FlxG.sound.music.play(false, timeToPlayAt); + + vocals.time = timeToPlayAt; + vocals.play(false, timeToPlayAt); + } + + /** + * Updates the position and contents of the score display. + */ + function updateScoreText():Void + { + // TODO: Add functionality for modules to update the score text. + if (isBotPlayMode) + { + scoreText.text = 'Bot Play Enabled'; + } + else + { + // TODO: Add an option for this maybe? + var commaSeparated:Bool = true; + scoreText.text = 'Score: ${FlxStringUtil.formatMoney(songScore, false, commaSeparated)}'; + } + } + + /** + * Updates the values of the health bar. + */ + function updateHealthBar():Void + { + if (isBotPlayMode) + { + healthLerp = Constants.HEALTH_MAX; + } + else + { + healthLerp = FlxMath.lerp(healthLerp, health, 0.15); + } + } + + /** + * Callback executed when one of the note keys is pressed. + */ + function onKeyPress(event:PreciseInputEvent):Void + { + if (isGamePaused) return; + + // Do the minimal possible work here. + inputPressQueue.push(event); + } + + /** + * Callback executed when one of the note keys is released. + */ + function onKeyRelease(event:PreciseInputEvent):Void + { + if (isGamePaused) return; + + // Do the minimal possible work here. + inputReleaseQueue.push(event); + } + + /** + * Handles opponent note hits and player note misses. + */ + function processNotes(elapsed:Float):Void + { + if (playerStrumline?.notes?.members == null || opponentStrumline?.notes?.members == null) return; + + // Process notes on the opponent's side. + for (note in opponentStrumline.notes.members) + { + if (note == null) continue; + + // TODO: Are offsets being accounted for in the correct direction? + var hitWindowStart = note.strumTime + Conductor.instance.inputOffset - Constants.HIT_WINDOW_MS; + var hitWindowCenter = note.strumTime + Conductor.instance.inputOffset; + var hitWindowEnd = note.strumTime + Conductor.instance.inputOffset + Constants.HIT_WINDOW_MS; + + if (Conductor.instance.songPosition > hitWindowEnd) + { + if (note.hasMissed || note.hasBeenHit) continue; + + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = true; + + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.missedNote = true; + } + } + else if (Conductor.instance.songPosition > hitWindowCenter) + { + if (note.hasBeenHit) continue; + + // Call an event to allow canceling the note hit. + // NOTE: This is what handles the character animations! + + var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', false, 0); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Command the opponent to hit the note on time. + // NOTE: This is what handles the strumline and cleaning up the note itself! + opponentStrumline.hitNote(note); + + if (note.holdNoteSprite != null) + { + opponentStrumline.playNoteHoldCover(note.holdNoteSprite); + } + } + else if (Conductor.instance.songPosition > hitWindowStart) + { + if (note.hasBeenHit || note.hasMissed) continue; + + note.tooEarly = false; + note.mayHit = true; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + else + { + note.tooEarly = true; + note.mayHit = false; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + } + + // Process hold notes on the opponent's side. + for (holdNote in opponentStrumline.holdNotes.members) + { + if (holdNote == null || !holdNote.alive) continue; + + // While the hold note is being hit, and there is length on the hold note... + if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0) + { + // Make sure the opponent keeps singing while the note is held. + if (currentStage != null && currentStage.getDad() != null && currentStage.getDad().isSinging()) + { + currentStage.getDad().holdTimer = 0; + } + } + + if (holdNote.missedNote && !holdNote.handledMiss) + { + // When the opponent drops a hold note. + holdNote.handledMiss = true; + + // We dropped a hold note. + // Play miss animation, but don't penalize. + currentStage.getOpponent().playSingAnimation(holdNote.noteData.getDirection(), true); + } + } + + // Process notes on the player's side. + for (note in playerStrumline.notes.members) + { + if (note == null) continue; + + if (note.hasBeenHit) + { + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = false; + continue; + } + + var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS; + var hitWindowCenter = note.strumTime; + var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; + + if (Conductor.instance.songPosition > hitWindowEnd) + { + if (note.hasMissed || note.hasBeenHit) continue; + note.tooEarly = false; + note.mayHit = false; + note.hasMissed = true; + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.missedNote = true; + } + } + else if (isBotPlayMode && Conductor.instance.songPosition > hitWindowCenter) + { + if (note.hasBeenHit) continue; + + // We call onHitNote to play the proper animations, + // but not goodNoteHit! This means zero score and zero notes hit for the results screen! + + // Call an event to allow canceling the note hit. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', false, 0); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Command the bot to hit the note on time. + // NOTE: This is what handles the strumline and cleaning up the note itself! + playerStrumline.hitNote(note); + + if (note.holdNoteSprite != null) + { + playerStrumline.playNoteHoldCover(note.holdNoteSprite); + } + } + else if (Conductor.instance.songPosition > hitWindowStart) + { + note.tooEarly = false; + note.mayHit = true; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + else + { + note.tooEarly = true; + note.mayHit = false; + note.hasMissed = false; + if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; + } + + // This becomes true when the note leaves the hit window. + // It might still be on screen. + if (note.hasMissed && !note.handledMiss) + { + // Call an event to allow canceling the note miss. + // NOTE: This is what handles the character animations! + var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Constants.HEALTH_MISS_PENALTY, 0, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Skip handling the miss in botplay! + if (!isBotPlayMode) + { + // Judge the miss. + // NOTE: This is what handles the scoring. + trace('Missed note! ${note.noteData}'); + onNoteMiss(note, event.playSound, event.healthChange); + } + + note.handledMiss = true; + } + } + + // Process hold notes on the player's side. + // This handles scoring so we don't need it on the opponent's side. + for (holdNote in playerStrumline.holdNotes.members) + { + if (holdNote == null || !holdNote.alive) continue; + + // While the hold note is being hit, and there is length on the hold note... + if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0) + { + // Grant the player health. + if (!isBotPlayMode) + { + health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed; + songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed); + } + + // Make sure the player keeps singing while the note is held by the bot. + if (isBotPlayMode && currentStage != null && currentStage.getBoyfriend() != null && currentStage.getBoyfriend().isSinging()) + { + currentStage.getBoyfriend().holdTimer = 0; + } + } + + if (holdNote.missedNote && !holdNote.handledMiss) + { + // The player dropped a hold note. + holdNote.handledMiss = true; + + // Mute vocals and play miss animation. + // vocals.playerVolume = 0; + // if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true); + + if (!isBotPlayMode) + { + if (holdNote.sustainLength > Constants.HOLD_DROP_PENALTY_THRESHOLD_MS) + { + // Penalize the player for letting go of a hold note too early. + trace('Player dropped a hold note, penalizing... (has hit: ${holdNote.hitNote})'); + + // Different penalty based on whether the note itself was missed, + // or the note was hit and then the hold was dropped. + var remainingLengthSec = holdNote.sustainLength / Constants.MS_PER_SEC; + var healthChangeUncapped = remainingLengthSec * Constants.HEALTH_HOLD_DROP_PENALTY_PER_SECOND; + // If the base note of the hold was missed, don't penalize them more on top of that. + var healthChangeMax = Constants.HEALTH_HOLD_DROP_PENALTY_MAX - (holdNote.hitNote ? -Constants.HEALTH_MISS_PENALTY : 0); + var healthChange = healthChangeUncapped.clamp(healthChangeMax, 0); + var scoreChange = Std.int(Constants.SCORE_HOLD_DROP_PENALTY_PER_SECOND * remainingLengthSec); + + var event:HoldNoteScriptEvent = new HoldNoteScriptEvent(NOTE_HOLD_DROP, holdNote, healthChange, scoreChange, true); + dispatchEvent(event); + + trace('Penalizing score by ${event.score} and health by ${event.healthChange} for dropping hold note (is combo break: ${event.isComboBreak})!'); + applyScore(event.score, '', event.healthChange, event.isComboBreak); + + // Play the miss sound. + vocals.playerVolume = 0; + FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.5, 0.6)); + } + else + { + trace('Hold note too short, not penalizing...'); + } + } + } + } + } + + function handleSkippedNotes():Void + { + for (note in playerStrumline.notes.members) + { + if (note == null || note.hasBeenHit) continue; + var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; + + if (Conductor.instance.songPosition > hitWindowEnd) + { + // We have passed this note. + // Flag the note for deletion without actually penalizing the player. + note.handledMiss = true; + } + } + + // Respawns notes that were b + playerStrumline.handleSkippedNotes(); + opponentStrumline.handleSkippedNotes(); + } + + /** + * PreciseInputEvents are put into a queue between update() calls, + * and then processed here. + */ + function processInputQueue():Void + { + if (inputPressQueue.length + inputReleaseQueue.length == 0) return; + + // Ignore inputs during cutscenes. + if (isInCutscene || disableKeys) + { + inputPressQueue = []; + inputReleaseQueue = []; + return; + } + + // Generate a list of notes within range. + var notesInRange:Array = playerStrumline.getNotesMayHit(); + var holdNotesInRange:Array = playerStrumline.getHoldNotesHitOrMissed(); + + var notesByDirection:Array> = [[], [], [], []]; + + for (note in notesInRange) + notesByDirection[note.direction].push(note); + + while (inputPressQueue.length > 0) + { + var input:PreciseInputEvent = inputPressQueue.shift(); + + playerStrumline.pressKey(input.noteDirection); + + // Don't credit or penalize inputs in Bot Play. + if (isBotPlayMode) continue; + + var notesInDirection:Array = notesByDirection[input.noteDirection]; + + #if FEATURE_GHOST_TAPPING + if ((!playerStrumline.mayGhostTap()) && notesInDirection.length == 0) + #else + if (notesInDirection.length == 0) + #end + { + // Pressed a wrong key with no notes nearby. + // Perform a ghost miss (anti-spam). + ghostNoteMiss(input.noteDirection, notesInRange.length > 0); + + // Play the strumline animation. + playerStrumline.playPress(input.noteDirection); + trace('PENALTY Score: ${songScore}'); + } + else if (notesInDirection.length == 0) + { + // Press a key with no penalty. + + // Play the strumline animation. + playerStrumline.playPress(input.noteDirection); + trace('NO PENALTY Score: ${songScore}'); + } + else + { + // Choose the first note, deprioritizing low priority notes. + var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority); + if (targetNote == null) targetNote = notesInDirection[0]; + if (targetNote == null) continue; + + // Judge and hit the note. + // trace('Hit note! ${targetNote.noteData}'); + goodNoteHit(targetNote, input); + // trace('Score: ${songScore}'); + + notesInDirection.remove(targetNote); + + // Play the strumline animation. + playerStrumline.playConfirm(input.noteDirection); + } + } + + while (inputReleaseQueue.length > 0) + { + var input:PreciseInputEvent = inputReleaseQueue.shift(); + + // Play the strumline animation. + playerStrumline.playStatic(input.noteDirection); + + playerStrumline.releaseKey(input.noteDirection); + } + } + + function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void + { + // Calculate the input latency (do this as late as possible). + // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}'); + var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp; + var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS; + // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!'); + + // Get the offset and compensate for input latency. + // Round inward (trim remainder) for consistency. + var noteDiff:Int = Std.int(Conductor.instance.songPosition - note.noteData.time - inputLatencyMs); + + var score = Scoring.scoreNote(noteDiff, PBOT1); + var daRating = Scoring.judgeNote(noteDiff, PBOT1); + + var healthChange = 0.0; + var isComboBreak = false; + switch (daRating) + { + case 'sick': + healthChange = Constants.HEALTH_SICK_BONUS; + isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK; + case 'good': + healthChange = Constants.HEALTH_GOOD_BONUS; + isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK; + case 'bad': + healthChange = Constants.HEALTH_BAD_BONUS; + isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK; + case 'shit': + healthChange = Constants.HEALTH_SHIT_BONUS; + isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK; + } + + // Send the note hit event. + var event:HitNoteScriptEvent = new HitNoteScriptEvent(note, healthChange, score, daRating, isComboBreak, Highscore.tallies.combo + 1, noteDiff, + daRating == 'sick'); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) return; + + Highscore.tallies.totalNotesHit++; + // Display the hit on the strums + playerStrumline.hitNote(note, !event.isComboBreak); + if (event.doesNotesplash) playerStrumline.playNoteSplash(note.noteData.getDirection()); + if (note.isHoldNote && note.holdNoteSprite != null) playerStrumline.playNoteHoldCover(note.holdNoteSprite); + vocals.playerVolume = 1; + + // Display the combo meter and add the calculation to the score. + applyScore(event.score, event.judgement, event.healthChange, event.isComboBreak); + popUpScore(event.judgement); + } + + /** + * Called when a note leaves the screen and is considered missed by the player. + * @param note + */ + function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthChange:Float):Void + { + // If we are here, we already CALLED the onNoteMiss script hook! + + if (!isPracticeMode) + { + // messy copy paste rn lol + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + } + vocals.playerVolume = 0; + + applyScore(Scoring.getMissScore(), 'miss', healthChange, true); + + if (playSound) + { + vocals.playerVolume = 0; + FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.5, 0.6)); + } + } + + /** + * Called when a player presses a key with no note present. + * Scripts can modify the amount of health/score lost, whether player animations or sounds are used, + * or even cancel the event entirely. + * + * @param direction + * @param hasPossibleNotes + */ + function ghostNoteMiss(direction:NoteDirection, hasPossibleNotes:Bool = true):Void + { + var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in. + hasPossibleNotes, // Whether there was a note you could have hit. + Constants.HEALTH_GHOST_MISS_PENALTY, // How much health to add (negative). + - 10 // Amount of score to add (negative). + ); + dispatchEvent(event); + + // Calling event.cancelEvent() skips animations and penalties. Neat! + if (event.eventCanceled) return; + + health += event.healthChange; + songScore += event.scoreChange; + + if (!isPracticeMode) + { + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + } + + if (event.playSound) + { + vocals.playerVolume = 0; + FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + } + } + + /** + * Debug keys. Disabled while in cutscenes. + */ + function debugKeyShit():Void + { + #if FEATURE_STAGE_EDITOR + // Open the stage editor overlaying the current state. + if (controls.DEBUG_STAGE) + { + // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!! + disableKeys = true; + persistentUpdate = false; + openSubState(new StageOffsetSubState()); + } + #end + + #if FEATURE_CHART_EDITOR + // Redirect to the chart editor playing the current song. + if (controls.DEBUG_CHART) + { + disableKeys = true; + persistentUpdate = false; + if (isChartingMode) + { + // Close the playtest substate. + FlxG.sound.music?.pause(); + this.close(); + } + else + { + this.remove(currentStage); + FlxG.switchState(() -> new ChartEditorState( + { + targetSongId: currentSong.id, + })); + } + } + #end + + #if FEATURE_DEBUG_FUNCTIONS + // H: Hide the HUD. + if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible; + + // 1: End the song immediately. + if (FlxG.keys.justPressed.ONE) endSong(true); + + // 2: Gain 10% health. + if (FlxG.keys.justPressed.TWO) health += 0.1 * Constants.HEALTH_MAX; + + // 3: Lose 5% health. + if (FlxG.keys.justPressed.THREE) health -= 0.05 * Constants.HEALTH_MAX; + #end + + // 9: Toggle the old icon. + if (FlxG.keys.justPressed.NINE && iconP1 != null) iconP1.toggleOldIcon(); + + #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 + } + + /** + * Handles applying health, score, and ratings. + */ + function applyScore(score:Int, daRating:String, healthChange:Float, isComboBreak:Bool) + { + switch (daRating) + { + case 'sick': + Highscore.tallies.sick += 1; + case 'good': + Highscore.tallies.good += 1; + case 'bad': + Highscore.tallies.bad += 1; + case 'shit': + Highscore.tallies.shit += 1; + case 'miss': + Highscore.tallies.missed += 1; + default: + // Nothing! + } + health += healthChange; + if (isComboBreak) + { + // Break the combo, but don't increment tallies.misses. + if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0); + Highscore.tallies.combo = 0; + } + else + { + Highscore.tallies.combo++; + if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; + } + songScore += score; + } + + /** + * Handles rating popups when a note is hit. + */ + function popUpScore(daRating:String, ?combo:Int):Void + { + if (daRating == 'miss') + { + // If daRating is 'miss', that means we made a mistake and should not continue. + FlxG.log.warn('popUpScore judged a note as a miss!'); + // TODO: Remove this. + // comboPopUps.displayRating('miss'); + return; + } + if (combo == null) combo = Highscore.tallies.combo; + + if (!isPracticeMode) + { + // TODO: Input splitter uses old input system, make it pull from the precise input queue directly. + var pressArray:Array = [ + controls.NOTE_LEFT_P, + controls.NOTE_DOWN_P, + controls.NOTE_UP_P, + controls.NOTE_RIGHT_P + ]; + + var indices:Array = []; + for (i in 0...pressArray.length) + { + if (pressArray[i]) indices.push(i); + } + } + comboPopUps.displayRating(daRating); + if (combo >= 10) comboPopUps.displayCombo(combo); + + vocals.playerVolume = 1; + } + + /** + * Handle keyboard inputs during cutscenes. + * This includes advancing conversations and skipping videos. + * @param elapsed Time elapsed since last game update. + */ + function handleCutsceneKeys(elapsed:Float):Void + { + if (isGamePaused) return; + + if (currentConversation != null) + { + // Pause/unpause may conflict with advancing the conversation! + if (controls.CUTSCENE_ADVANCE && !justUnpaused) + { + currentConversation.advanceConversation(); + } + else if (controls.PAUSE && !justUnpaused) + { + currentConversation.pauseMusic(); + + var pauseSubState:FlxSubState = new PauseSubState({mode: Conversation}); + + persistentUpdate = false; + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + pauseSubState.camera = camCutscene; + openSubState(pauseSubState); + } + } + else if (VideoCutscene.isPlaying()) + { + // This is a video cutscene. + if (controls.PAUSE && !justUnpaused) + { + VideoCutscene.pauseVideo(); + + var pauseSubState:FlxSubState = new PauseSubState({mode: Cutscene}); + + persistentUpdate = false; + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + pauseSubState.camera = camCutscene; + openSubState(pauseSubState); + } + } + } + + /** + * Handle logic for actually skipping a video cutscene after it has been held. + */ + function skipVideoCutscene():Void + { + VideoCutscene.finishVideo(); + } + + /** + * End the song. Handle saving high scores and transitioning to the results screen. + * + * Broadcasts an `onSongEnd` event, which can be cancelled to prevent the song from ending (for a cutscene or something). + * Remember to call `endSong` again when the song should actually end! + * @param rightGoddamnNow If true, don't play the fancy animation where you zoom onto Girlfriend. Used after a cutscene. + */ + public function endSong(rightGoddamnNow:Bool = false):Void + { + if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; + vocals.volume = 0; + mayPauseGame = false; + + // Prevent ghost misses while the song is ending. + disableKeys = true; + + // Check if any events want to prevent the song from ending. + var event = new ScriptEvent(SONG_END, true); + dispatchEvent(event); + if (event.eventCanceled) return; + + deathCounter = 0; + + // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. + // `easy`, `erect`, `normal-pico`, etc. + var suffixedDifficulty = (currentVariation != Constants.DEFAULT_VARIATION + && currentVariation != 'erect') ? '$currentDifficulty-${currentVariation}' : currentDifficulty; + + var isNewHighscore = false; + var prevScoreData:Null = Save.instance.getSongScore(currentSong.id, suffixedDifficulty); + + if (currentSong != null && currentSong.validScore) + { + // crackhead double thingie, sets whether was new highscore, AND saves the song! + var data = + { + score: songScore, + tallies: + { + sick: Highscore.tallies.sick, + good: Highscore.tallies.good, + bad: Highscore.tallies.bad, + shit: Highscore.tallies.shit, + missed: Highscore.tallies.missed, + combo: Highscore.tallies.combo, + maxCombo: Highscore.tallies.maxCombo, + totalNotesHit: Highscore.tallies.totalNotesHit, + totalNotes: Highscore.tallies.totalNotes, + }, + }; + + // adds current song data into the tallies for the level (story levels) + Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel); + + #if FEATURE_NEWGROUNDS + Leaderboards.submitSongScore(currentSong.id, suffixedDifficulty, songScore); + #end + + if (!isPracticeMode && !isBotPlayMode) + { + #if FEATURE_NEWGROUNDS + Events.logCompleteSong(currentSong.id, currentVariation); + #end + + isNewHighscore = Save.instance.isSongHighScore(currentSong.id, suffixedDifficulty, data); + + // If no high score is present, save both score and rank. + // If score or rank are better, save the highest one. + // If neither are higher, nothing will change. + Save.instance.applySongRank(currentSong.id, suffixedDifficulty, data); + + if (isNewHighscore) {} + } + } + + #if FEATURE_NEWGROUNDS + // Only award medals if we are LEGIT. + if (!isPracticeMode && !isBotPlayMode && !isChartingMode && currentSong.validScore) + { + // Award a medal for beating at least one song on any difficulty on a Friday. + if (Date.now().getDay() == 5) Medals.award(FridayNight); + + // Determine the score rank for this song we just finished. + var scoreRank:ScoringRank = Scoring.calculateRank( + { + score: songScore, + tallies: + { + sick: Highscore.tallies.sick, + good: Highscore.tallies.good, + bad: Highscore.tallies.bad, + shit: Highscore.tallies.shit, + missed: Highscore.tallies.missed, + combo: Highscore.tallies.combo, + maxCombo: Highscore.tallies.maxCombo, + totalNotesHit: Highscore.tallies.totalNotesHit, + totalNotes: Highscore.tallies.totalNotes, + } + }); + + // Award various medals based on variation, difficulty, song ID, and scoring rank. + if (scoreRank == ScoringRank.SHIT) Medals.award(LossRating); + if (scoreRank >= ScoringRank.PERFECT && currentDifficulty == 'hard') Medals.award(PerfectRatingHard); + if (scoreRank == ScoringRank.PERFECT_GOLD && currentDifficulty == 'hard') Medals.award(GoldPerfectRatingHard); + if (Constants.DEFAULT_DIFFICULTY_LIST_ERECT.contains(currentDifficulty)) Medals.award(ErectDifficulty); + if (scoreRank == ScoringRank.PERFECT_GOLD && currentDifficulty == 'nightmare') Medals.award(GoldPerfectRatingNightmare); + if (currentVariation == 'pico' && !PlayStatePlaylist.isStoryMode) Medals.award(FreeplayPicoMix); + if (currentVariation == 'pico' && currentSong.id == 'stress') Medals.award(FreeplayStressPico); + + Events.logEarnRank(scoreRank.toString()); + } + #end + + if (PlayStatePlaylist.isStoryMode) + { + isNewHighscore = false; + + PlayStatePlaylist.campaignScore += songScore; + + // Pop the next song ID from the list. + // Returns null if the list is empty. + var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); + + if (targetSongId == null) + { + if (currentSong.validScore) + { + var data = + { + score: PlayStatePlaylist.campaignScore, + tallies: + { + // TODO: Sum up the values for the whole week! + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + }, + }; + + #if FEATURE_NEWGROUNDS + // Award a medal for beating a Story level. + Medals.awardStoryLevel(PlayStatePlaylist.campaignId); + + // Submit the score for the Story level to Newgrounds. + Leaderboards.submitLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, PlayStatePlaylist.campaignScore); + + Events.logCompleteLevel(PlayStatePlaylist.campaignId); + #end + + if (Save.instance.isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data)) + { + Save.instance.setLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data); + isNewHighscore = true; + } + } + + if (isSubState) + { + this.close(); + } + else + { + if (rightGoddamnNow) + { + moveToResultsScreen(isNewHighscore); + } + else + { + zoomIntoResultsScreen(isNewHighscore); + } + } + } + else + { + var difficulty:String = ''; + + trace('Loading next song ($targetSongId : $difficulty)'); + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + if (FlxG.sound.music != null) FlxG.sound.music.stop(); + vocals.stop(); + + // TODO: Softcode this cutscene. + if (currentSong.id == 'eggnog') + { + var blackBG:FunkinSprite = new FunkinSprite(-FlxG.width * FlxG.camera.zoom, -FlxG.height * FlxG.camera.zoom); + blackBG.makeSolidColor(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK); + blackBG.scrollFactor.set(); + add(blackBG); + camHUD.visible = false; + isInCutscene = true; + + FunkinSound.playOnce(Paths.sound('Lights_Shut_off'), function() { + // no camFollow so it centers on horror tree + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); + var targetVariation:String = currentVariation; + if (!targetSong.hasDifficulty(PlayStatePlaylist.campaignDifficulty, currentVariation)) + { + targetVariation = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty) ?? Constants.DEFAULT_VARIATION; + } + this.remove(currentStage); + LoadingState.loadPlayState( + { + targetSong: targetSong, + targetDifficulty: PlayStatePlaylist.campaignDifficulty, + targetVariation: targetVariation, + cameraFollowPoint: cameraFollowPoint.getPosition(), + }); + }); + } + else + { + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); + var targetVariation:String = currentVariation; + if (!targetSong.hasDifficulty(PlayStatePlaylist.campaignDifficulty, currentVariation)) + { + targetVariation = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty) ?? Constants.DEFAULT_VARIATION; + } + this.remove(currentStage); + LoadingState.loadPlayState( + { + targetSong: targetSong, + targetDifficulty: PlayStatePlaylist.campaignDifficulty, + targetVariation: targetVariation, + cameraFollowPoint: cameraFollowPoint.getPosition(), + }); + } + } + } + else + { + if (isSubState) + { + this.close(); + } + else + { + if (rightGoddamnNow) + { + moveToResultsScreen(isNewHighscore, prevScoreData); + } + else + { + zoomIntoResultsScreen(isNewHighscore, prevScoreData); + } + } + } + } + + public override function close():Void + { + criticalFailure = true; // Stop game updates. + performCleanup(); + super.close(); + } + + /** + * Perform necessary cleanup before leaving the PlayState. + */ + function performCleanup():Void + { + // If the camera is being tweened, stop it. + cancelAllCameraTweens(); + + // Dispatch the destroy event. + dispatchEvent(new ScriptEvent(DESTROY, false)); + + if (currentConversation != null) + { + remove(currentConversation); + currentConversation.kill(); + } + + if (currentChart != null) + { + // TODO: Uncache the song. + } + + if (overrideMusic) + { + // Stop the music. Do NOT destroy it, something still references it! + if (FlxG.sound.music != null) FlxG.sound.music.pause(); + if (vocals != null) + { + vocals.pause(); + remove(vocals); + } + } + else + { + // Stop and destroy the music. + if (FlxG.sound.music != null) FlxG.sound.music.pause(); + if (vocals != null) + { + vocals.destroy(); + remove(vocals); + } + } + + // Remove reference to stage and remove sprites from it to save memory. + if (currentStage != null) + { + remove(currentStage); + currentStage.kill(); + currentStage = null; + } + + GameOverSubState.reset(); + PauseSubState.reset(); + Countdown.reset(); + + // Clear the static reference to this state. + instance = null; + } + + /** + * Play the camera zoom animation and then move to the results screen once it's done. + */ + function zoomIntoResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void + { + trace('WENT TO RESULTS SCREEN!'); + + // Stop camera zooming on beat. + cameraZoomRate = 0; + + // Cancel camera and scroll tweening if it's active. + cancelAllCameraTweens(); + cancelScrollSpeedTweens(); + + // If the opponent is GF, zoom in on the opponent. + // Else, if there is no GF, zoom in on BF. + // Else, zoom in on GF. + var targetDad:Bool = currentStage.getDad() != null && currentStage.getDad().characterId == 'gf'; + var targetBF:Bool = currentStage.getGirlfriend() == null && !targetDad; + + if (targetBF) + { + FlxG.camera.follow(currentStage.getBoyfriend(), null, 0.05); + } + else if (targetDad) + { + FlxG.camera.follow(currentStage.getDad(), null, 0.05); + } + else + { + FlxG.camera.follow(currentStage.getGirlfriend(), null, 0.05); + } + + // TODO: Make target offset configurable. + // In the meantime, we have to replace the zoom animation with a fade out. + FlxG.camera.targetOffset.y -= 350; + FlxG.camera.targetOffset.x += 20; + + // Replace zoom animation with a fade out for now. + FlxG.camera.fade(FlxColor.BLACK, 0.6); + + FlxTween.tween(camHUD, {alpha: 0}, 0.6, + { + onComplete: function(_) { + moveToResultsScreen(isNewHighscore, prevScoreData); + } + }); + + // Zoom in on Girlfriend (or BF if no GF) + new FlxTimer().start(0.8, function(_) { + if (targetBF) + { + currentStage.getBoyfriend().animation.play('hey'); + } + else if (targetDad) + { + currentStage.getDad().animation.play('cheer'); + } + else + { + currentStage.getGirlfriend().animation.play('cheer'); + } + + // Zoom over to the Results screen. + // TODO: Re-enable this. + /* + FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1, + { + ease: FlxEase.expoIn, + }); + */ + }); + } + + /** + * Move to the results screen right goddamn now. + */ + function moveToResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void + { + persistentUpdate = false; + vocals.stop(); + camHUD.alpha = 1; + + var talliesToUse:Tallies = PlayStatePlaylist.isStoryMode ? Highscore.talliesLevel : Highscore.tallies; + + var res:ResultState = new ResultState( + { + storyMode: PlayStatePlaylist.isStoryMode, + songId: currentChart.song.id, + difficultyId: currentDifficulty, + variationId: currentVariation, + characterId: currentChart.characters.player, + title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'), + prevScoreData: prevScoreData, + scoreData: + { + score: PlayStatePlaylist.isStoryMode ? PlayStatePlaylist.campaignScore : songScore, + tallies: + { + sick: talliesToUse.sick, + good: talliesToUse.good, + bad: talliesToUse.bad, + shit: talliesToUse.shit, + missed: talliesToUse.missed, + combo: talliesToUse.combo, + maxCombo: talliesToUse.maxCombo, + totalNotesHit: talliesToUse.totalNotesHit, + totalNotes: talliesToUse.totalNotes, + }, + }, + isNewHighscore: isNewHighscore, + isPracticeMode: isPracticeMode, + isBotPlayMode: isBotPlayMode, + }); + this.persistentDraw = false; + openSubState(res); + } + + /** + * Pauses music and vocals easily. + */ + public function pauseMusic():Void + { + if (FlxG.sound.music != null) FlxG.sound.music.pause(); + if (vocals != null) vocals.pause(); + } + + /** + * Resets the camera's zoom level and focus point. + */ + public function resetCamera(?resetZoom:Bool = true, ?cancelTweens:Bool = true, ?snap:Bool = true):Void + { + // Cancel camera tweens if any are active. + if (cancelTweens) + { + cancelAllCameraTweens(); + } + + FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE); + FlxG.camera.targetOffset.set(); + + if (resetZoom) + { + resetCameraZoom(); + } + + // Snap the camera to the follow point immediately. + if (snap) FlxG.camera.focusOn(cameraFollowPoint.getPosition()); + } + + /** + * Sets the camera follow point's position and tweens the camera there. + */ + public function tweenCameraToPosition(?x:Float, ?y:Float, ?duration:Float, ?ease:NullFloat>):Void + { + cameraFollowPoint.setPosition(x, y); + tweenCameraToFollowPoint(duration, ease); + } + + /** + * Disables camera following and tweens the camera to the follow point manually. + */ + public function tweenCameraToFollowPoint(?duration:Float, ?ease:NullFloat>):Void + { + // Cancel the current tween if it's active. + cancelCameraFollowTween(); + + if (duration == 0) + { + // Instant movement. Just reset the camera to force it to the follow point. + resetCamera(false, false); + } + else + { + // Disable camera following for the duration of the tween. + FlxG.camera.target = null; + + // Follow tween! Caching it so we can cancel/pause it later if needed. + var followPos:FlxPoint = cameraFollowPoint.getPosition() - FlxPoint.weak(FlxG.camera.width * 0.5, FlxG.camera.height * 0.5); + cameraFollowTween = FlxTween.tween(FlxG.camera.scroll, {x: followPos.x, y: followPos.y}, duration, + { + ease: ease, + onComplete: function(_) { + resetCamera(false, false); // Re-enable camera following when the tween is complete. + } + }); + } + } + + public function cancelCameraFollowTween() + { + if (cameraFollowTween != null) + { + cameraFollowTween.cancel(); + } + } + + /** + * Tweens the camera zoom to the desired amount. + */ + public function tweenCameraZoom(?zoom:Float, ?duration:Float, ?direct:Bool, ?ease:NullFloat>):Void + { + // Cancel the current tween if it's active. + cancelCameraZoomTween(); + + // Direct mode: Set zoom directly. + // Stage mode: Set zoom as a multiplier of the current stage's default zoom. + var targetZoom = zoom * (direct ? FlxCamera.defaultZoom : stageZoom); + + if (duration == 0) + { + // Instant zoom. No tween needed. + currentCameraZoom = targetZoom; + } + else + { + // Zoom tween! Caching it so we can cancel/pause it later if needed. + cameraZoomTween = FlxTween.tween(this, {currentCameraZoom: targetZoom}, duration, {ease: ease}); + } + } + + public function cancelCameraZoomTween() + { + if (cameraZoomTween != null) + { + cameraZoomTween.cancel(); + } + } + + /** + * Cancel all active camera tweens simultaneously. + */ + public function cancelAllCameraTweens() + { + cancelCameraFollowTween(); + cancelCameraZoomTween(); + } + + var prevScrollTargets:Array = []; // used to snap scroll speed when things go unruely + + /** + * The magical function that shall tween the scroll speed. + */ + public function tweenScrollSpeed(?speed:Float, ?duration:Float, ?ease:NullFloat>, strumlines:Array):Void + { + // Cancel the current tween if it's active. + cancelScrollSpeedTweens(); + + // Snap to previous event value to prevent the tween breaking when another event cancels the previous tween. + for (i in prevScrollTargets) + { + var value:Float = i[0]; + var strum:Strumline = Reflect.getProperty(this, i[1]); + strum.scrollSpeed = value; + } + + // for next event, clean array. + prevScrollTargets = []; + + for (i in strumlines) + { + var value:Float = speed; + var strum:Strumline = Reflect.getProperty(this, i); + + if (duration == 0) + { + strum.scrollSpeed = value; + } + else + { + scrollSpeedTweens.push(FlxTween.tween(strum, + { + 'scrollSpeed': value + }, duration, {ease: ease})); + } + // make sure charts dont break if the charter is dumb and stupid + prevScrollTargets.push([value, i]); + } + } + + public function cancelScrollSpeedTweens() + { + for (tween in scrollSpeedTweens) + { + if (tween != null) + { + tween.cancel(); + } + } + scrollSpeedTweens = []; + } + + #if FEATURE_DEBUG_FUNCTIONS + /** + * 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 + { + // FlxG.sound.music.pause(); + + 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); + // regenNoteData(FlxG.sound.music.time); + + Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); + + resyncVocals(); + } + #end +} diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx new file mode 100644 index 00000000..e47a6288 --- /dev/null +++ b/source/funkin/play/PlayStatePlaylist.hx @@ -0,0 +1,53 @@ +package funkin.play; + +/** + * Manages playback of multiple songs in a row. + * + * TODO: Add getters/setters for all these properties to validate them. + */ +@:nullSafety +class PlayStatePlaylist +{ + /** + * Whether the game is currently in Story Mode. If false, we are in Free Play Mode. + */ + public static var isStoryMode:Bool = false; + + /** + * The loist of upcoming songs to be played. + * When the user completes a song in Story Mode, the first entry in this list is played. + * When this list is empty, move to the Results screen instead. + */ + public static var playlistSongIds:Array = []; + + /** + * The cumulative score for all the songs in the playlist. + */ + public static var campaignScore:Int = 0; + + /** + * The title of this playlist, for example `Week 4` or `Weekend 1` + */ + public static var campaignTitle:String = 'UNKNOWN'; + + /** + * The internal ID of the current playlist, for example `week4` or `weekend-1`. + * @default `null`, used when no playlist is loaded + */ + public static var campaignId:Null = null; + + public static var campaignDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * Resets the playlist to its default state. + */ + public static function reset():Void + { + isStoryMode = false; + playlistSongIds = []; + campaignScore = 0; + campaignTitle = 'UNKNOWN'; + campaignId = null; + campaignDifficulty = Constants.DEFAULT_DIFFICULTY; + } +} diff --git a/source/funkin/play/ResultScore.hx b/source/funkin/play/ResultScore.hx new file mode 100644 index 00000000..31cbb0bc --- /dev/null +++ b/source/funkin/play/ResultScore.hx @@ -0,0 +1,218 @@ +package funkin.play; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import flixel.tweens.FlxEase; + +class ResultScore extends FlxTypedSpriteGroup +{ + public var scoreShit(default, set):Int = 0; + + public var scoreStart:Int = 0; + + function set_scoreShit(val):Int + { + if (group == null || group.members == null) return val; + var loopNum:Int = group.members.length - 1; + 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; + group.members[loopNum].finalDigit = dumbNumb % 10; + + // var funnyNum = group.members[loopNum]; + // prevNum = group.members[loopNum + 1]; + + // if (prevNum != null) + // { + // funnyNum.x = prevNum.x - (funnyNum.width * 0.7); + // } + + // funnyNum.y = (funnyNum.baseY - (funnyNum.height / 2)) + 73; + // funnyNum.x = (funnyNum.baseX - (funnyNum.width / 2)) + 450; // this plus value is hand picked lol! + + dumbNumb = Math.floor(dumbNumb / 10); + loopNum--; + } + + while (loopNum > 0) + { + group.members[loopNum].digit = 10; + loopNum--; + } + + return val; + } + + public function animateNumbers():Void + { + for (i in group.members.length - scoreStart...group.members.length) + { + // if(i.finalDigit == 10) continue; + + new FlxTimer().start((i - 1) / 24, _ -> { + group.members[i].finalDelay = scoreStart - (i - 1); + group.members[i].playAnim(); + group.members[i].shuffle(); + }); + } + } + + public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100) + { + super(x, y); + + for (i in 0...digitCount) + { + add(new ScoreNum(x + (65 * i), y)); + } + + this.scoreShit = scoreShit; + } + + public function updateScore(scoreNew:Int) + { + scoreShit = scoreNew; + } +} + +class ScoreNum extends FlxSprite +{ + public var digit(default, set):Int = 10; + public var finalDigit(default, set):Int = 10; + public var glow:Bool = true; + + function set_finalDigit(val):Int + { + animation.play('GONE', true, false, 0); + + return finalDigit = val; + } + + function set_digit(val):Int + { + if (val >= 0 && animation.curAnim != null && animation.curAnim.name != numToString[val]) + { + if (glow) + { + animation.play(numToString[val], true, false, 0); + glow = false; + } + else + { + animation.play(numToString[val], true, false, 4); + } + updateHitbox(); + + switch (val) + { + case 1: + // offset.x -= 15; + case 5: + // set offsets + // offset.x += 0; + // offset.y += 10; + + case 7: + // offset.y += 6; + case 4: + // offset.y += 5; + case 9: + // offset.y += 5; + default: + centerOffsets(false); + } + } + + return digit = val; + } + + public function playAnim():Void + { + animation.play(numToString[digit], true, false, 0); + } + + public var shuffleTimer:FlxTimer; + public var finalTween:FlxTween; + public var finalDelay:Float = 0; + + public var baseY:Float = 0; + public var baseX:Float = 0; + + var numToString:Array = [ + "ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "DISABLED" + ]; + + function finishShuffleTween():Void + { + var tweenFunction = function(x) { + var digitRounded = Math.floor(x); + // if(digitRounded == finalDigit) glow = true; + digit = digitRounded; + }; + + finalTween = FlxTween.num(0.0, finalDigit, 23 / 24, + { + ease: FlxEase.quadOut, + onComplete: function(input) { + new FlxTimer().start((finalDelay) / 24, _ -> { + animation.play(animation.curAnim.name, true, false, 0); + }); + // fuck + } + }, tweenFunction); + } + + function shuffleProgress(shuffleTimer:FlxTimer):Void + { + var tempDigit:Int = digit; + tempDigit += 1; + if (tempDigit > 9) tempDigit = 0; + if (tempDigit < 0) tempDigit = 0; + digit = tempDigit; + + if (shuffleTimer.loops > 0 && shuffleTimer.loopsLeft == 0) + { + // digit = finalDigit; + finishShuffleTween(); + } + } + + public function shuffle():Void + { + var duration:Float = 41 / 24; + var interval:Float = 1 / 24; + shuffleTimer = new FlxTimer().start(interval, shuffleProgress, Std.int(duration / interval)); + } + + public function new(x:Float, y:Float) + { + super(x, y); + + baseY = y; + baseX = x; + + frames = Paths.getSparrowAtlas('resultScreen/score-digital-numbers'); + + for (i in 0...10) + { + var stringNum:String = numToString[i]; + animation.addByPrefix(stringNum, '$stringNum DIGITAL', 24, false); + } + + animation.addByPrefix('DISABLED', 'DISABLED', 24, false); + animation.addByPrefix('GONE', 'GONE', 24, false); + + this.digit = 10; + + animation.play(numToString[digit], true); + + updateHitbox(); + } +} diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx new file mode 100644 index 00000000..7e565530 --- /dev/null +++ b/source/funkin/play/ResultState.hx @@ -0,0 +1,967 @@ +package funkin.play; + +import flixel.addons.display.FlxBackdrop; +import flixel.effects.FlxFlicker; +import flixel.FlxSprite; +import flixel.FlxSubState; +import flixel.graphics.frames.FlxBitmapFont; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import flixel.text.FlxBitmapText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxGradient; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerData.PlayerResultsAnimationData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.graphics.shaders.LeftMaskShader; +import funkin.play.components.ClearPercentCounter; +import funkin.play.components.TallyCounter; +import funkin.play.scoring.Scoring; +import funkin.play.song.Song; +import funkin.data.song.SongRegistry; +import funkin.save.Save.SaveScoreData; +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 + +/** + * The state for the results screen after a song or week is finished. + */ +@:nullSafety +class ResultState extends MusicBeatSubState +{ + final params:ResultsStateParams; + + final rank:ScoringRank; + final songName:FlxBitmapText; + final difficulty:FlxSprite; + final clearPercentSmall:ClearPercentCounter; + + final maskShaderSongName:LeftMaskShader = new LeftMaskShader(); + final maskShaderDifficulty:LeftMaskShader = new LeftMaskShader(); + + final resultsAnim:FunkinSprite; + final ratingsPopin:FunkinSprite; + final scorePopin:FunkinSprite; + + final bgFlash:FlxSprite; + + final highscoreNew:FlxSprite; + final score:ResultScore; + + var characterAtlasAnimations:Array< + { + sprite:FlxAtlasSprite, + delay:Float, + forceLoop:Bool, + startFrameLabel:String, + sound:String + }> = []; + var characterSparrowAnimations:Array< + { + sprite:FunkinSprite, + delay:Float + }> = []; + + var playerCharacterId:Null = null; + var playerCharacter:Null = null; + + var introMusicAudio:Null = null; + + var rankBg:FunkinSprite; + final cameraBG:FunkinCamera; + final cameraScroll:FunkinCamera; + final cameraEverything:FunkinCamera; + + public function new(params:ResultsStateParams) + { + super(); + + this.params = params; + + rank = Scoring.calculateRank(params.scoreData) ?? SHIT; + + cameraBG = new FunkinCamera('resultsBG', 0, 0, FlxG.width, FlxG.height); + cameraScroll = new FunkinCamera('resultsScroll', 0, 0, FlxG.width, FlxG.height); + cameraEverything = new FunkinCamera('resultsEverything', 0, 0, FlxG.width, FlxG.height); + + // We build a lot of this stuff in the constructor, then place it in create(). + // This prevents having to do `null` checks everywhere. + + var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890"; + songName = new FlxBitmapText(FlxBitmapFont.fromMonospace(Paths.image("resultScreen/tardlingSpritesheet"), fontLetters, FlxPoint.get(49, 62))); + songName.text = params.title; + songName.letterSpacing = -15; + songName.angle = -4.4; + songName.zIndex = 1000; + + difficulty = new FlxSprite(555); + difficulty.zIndex = 1000; + + clearPercentSmall = new ClearPercentCounter(FlxG.width / 2 + 300, FlxG.height / 2 - 100, 100, true); + clearPercentSmall.zIndex = 1000; + clearPercentSmall.visible = false; + + bgFlash = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFFF1A6, 0xFFFFF1BE], 90); + + resultsAnim = FunkinSprite.createSparrow(-200, -10, "resultScreen/results"); + + ratingsPopin = FunkinSprite.createSparrow(-135, 135, "resultScreen/ratingsPopin"); + + scorePopin = FunkinSprite.createSparrow(-180, 515, "resultScreen/scorePopin"); + + highscoreNew = new FlxSprite(44, 557); + + score = new ResultScore(35, 305, 10, params.scoreData.score); + + rankBg = new FunkinSprite(0, 0); + } + + override function create():Void + { + if (FlxG.sound.music != null) FlxG.sound.music.stop(); + + // We need multiple cameras so we can put one at an angle. + cameraScroll.angle = -3.8; + + cameraBG.bgColor = FlxColor.MAGENTA; + cameraScroll.bgColor = FlxColor.TRANSPARENT; + cameraEverything.bgColor = FlxColor.TRANSPARENT; + + FlxG.cameras.add(cameraBG, false); + FlxG.cameras.add(cameraScroll, false); + FlxG.cameras.add(cameraEverything, false); + + FlxG.cameras.setDefaultDrawTarget(cameraEverything, true); + this.camera = cameraEverything; + + // Reset the camera zoom on the results screen. + FlxG.camera.zoom = 1.0; + + var bg:FlxSprite = FlxGradient.createGradientFlxSprite(FlxG.width, FlxG.height, [0xFFFECC5C, 0xFFFDC05C], 90); + bg.scrollFactor.set(); + bg.zIndex = 10; + bg.cameras = [cameraBG]; + add(bg); + + bgFlash.scrollFactor.set(); + bgFlash.visible = false; + bgFlash.zIndex = 20; + // bgFlash.cameras = [cameraBG]; + add(bgFlash); + + // The sound system which falls into place behind the score text. Plays every time! + var soundSystem:FlxSprite = FunkinSprite.createSparrow(-15, -180, 'resultScreen/soundSystem'); + soundSystem.animation.addByPrefix("idle", "sound system", 24, false); + soundSystem.visible = false; + new FlxTimer().start(8 / 24, _ -> { + soundSystem.animation.play("idle"); + soundSystem.visible = true; + }); + soundSystem.zIndex = 1100; + add(soundSystem); + + // Fetch playable character data. Default to BF on the results screen if we can't find it. + playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(params.characterId); + playerCharacter = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? 'bf'); + + trace('Got playable character: ${playerCharacter?.getName()}'); + // Query JSON data based on the rank, then use that to build the animation(s) the player sees. + var playerAnimationDatas:Array = playerCharacter != null ? playerCharacter.getResultsAnimationDatas(rank) : []; + + for (animData in playerAnimationDatas) + { + if (animData == null) continue; + + if (animData.filter != "both") + { + if (Preferences.naughtyness && animData.filter != "naughty" || !Preferences.naughtyness && animData.filter != "safe") continue; + } + + 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': + @:nullSafety(Off) + var animation:FlxAtlasSprite = null; + + if (animData.scriptClass != null) animation = ScriptedFlxAtlasSprite.init(animData.scriptClass, offsets[0], offsets[1]); + else + animation = new FlxAtlasSprite(offsets[0], offsets[1], Paths.animateAtlas(animPath, animLibrary)); + + if (animation == null) continue; + + animation.zIndex = animData.zIndex ?? 500; + + animation.scale.set(animData.scale ?? 1.0, animData.scale ?? 1.0); + + if (!(animData.looped ?? true)) + { + // Animation is not looped. + animation.onAnimationComplete.add((_name:String) -> { + if (animation != null) + { + animation.anim.pause(); + } + }); + } + else if (animData.loopFrameLabel != null) + { + animation.onAnimationComplete.add((_name:String) -> { + if (animation != null) + { + animation.playAnimation(animData.loopFrameLabel ?? '', true, false, true); // unpauses this anim, since it's on PlayOnce! + } + }); + } + else if (animData.loopFrame != null) + { + animation.onAnimationComplete.add((_name:String) -> { + if (animation != null) + { + animation.anim.curFrame = animData.loopFrame ?? 0; + animation.anim.play(); // unpauses this anim, since it's on PlayOnce! + } + }); + } + // Hide until ready to play. + animation.visible = false; + // Queue to play. + characterAtlasAnimations.push( + { + sprite: animation, + delay: animData.delay ?? 0.0, + forceLoop: (animData.loopFrame ?? -1) == 0, + startFrameLabel: (animData.startFrameLabel ?? ""), + sound: (animData.sound ?? "") + }); + // Add to the scene. + add(animation); + case 'sparrow': + var animation:FunkinSprite = FunkinSprite.createSparrow(offsets[0], offsets[1], animPath); + animation.animation.addByPrefix('idle', '', 24, false, false, false); + + if (animData.loopFrame != null) + { + animation.animation.finishCallback = (_name:String) -> { + if (animation != null) + { + animation.animation.play('idle', true, false, animData.loopFrame ?? 0); + } + } + } + + // Hide until ready to play. + animation.visible = false; + // Queue to play. + characterSparrowAnimations.push( + { + sprite: animation, + delay: animData.delay ?? 0.0 + }); + // Add to the scene. + add(animation); + } + } + + var diffSpr:String = 'diff_${params?.difficultyId ?? Constants.DEFAULT_DIFFICULTY}'; + difficulty.loadGraphic(Paths.image("resultScreen/" + diffSpr)); + add(difficulty); + + add(songName); + + var angleRad = songName.angle * Math.PI / 180; + speedOfTween.x = -1.0 * Math.cos(angleRad); + speedOfTween.y = -1.0 * Math.sin(angleRad); + + timerThenSongName(1.0, false); + + songName.shader = maskShaderSongName; + difficulty.shader = maskShaderDifficulty; + + maskShaderDifficulty.swagMaskX = difficulty.x - 30; + + var blackTopBar:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/topBarBlack")); + blackTopBar.y = -blackTopBar.height; + FlxTween.tween(blackTopBar, {y: 0}, 7 / 24, {ease: FlxEase.quartOut, startDelay: 3 / 24}); + blackTopBar.zIndex = 1010; + add(blackTopBar); + + resultsAnim.animation.addByPrefix("result", "results instance 1", 24, false); + resultsAnim.visible = false; + resultsAnim.zIndex = 1200; + add(resultsAnim); + new FlxTimer().start(6 / 24, _ -> { + resultsAnim.visible = true; + resultsAnim.animation.play("result"); + }); + + ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false); + ratingsPopin.visible = false; + ratingsPopin.zIndex = 1200; + add(ratingsPopin); + new FlxTimer().start(21 / 24, _ -> { + ratingsPopin.visible = true; + ratingsPopin.animation.play("idle"); + }); + + scorePopin.animation.addByPrefix("score", "tally score", 24, false); + scorePopin.visible = false; + scorePopin.zIndex = 1200; + add(scorePopin); + new FlxTimer().start(36 / 24, _ -> { + scorePopin.visible = true; + scorePopin.animation.play("score"); + scorePopin.animation.finishCallback = anim -> {}; + }); + + new FlxTimer().start(37 / 24, _ -> { + score.visible = true; + score.animateNumbers(); + startRankTallySequence(); + }); + + new FlxTimer().start(rank.getBFDelay(), _ -> { + afterRankTallySequence(); + }); + + new FlxTimer().start(rank.getFlashDelay(), _ -> { + displayRankText(); + }); + + highscoreNew.frames = Paths.getSparrowAtlas("resultScreen/highscoreNew"); + highscoreNew.animation.addByPrefix("new", "highscoreAnim0", 24, false); + highscoreNew.visible = false; + // highscoreNew.setGraphicSize(Std.int(highscoreNew.width * 0.8)); + highscoreNew.updateHitbox(); + highscoreNew.zIndex = 1200; + add(highscoreNew); + + new FlxTimer().start(rank.getHighscoreDelay(), _ -> { + if (params.isNewHighscore ?? false) + { + highscoreNew.visible = true; + highscoreNew.animation.play("new"); + highscoreNew.animation.finishCallback = _ -> highscoreNew.animation.play("new", true, false, 16); + } + else + { + highscoreNew.visible = false; + } + }); + + var hStuf:Int = 50; + + var ratingGrp:FlxTypedGroup = new FlxTypedGroup(); + ratingGrp.zIndex = 1200; + add(ratingGrp); + + /** + * NOTE: We display how many notes were HIT, not how many notes there were in total. + * + */ + var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.scoreData.tallies.totalNotesHit); + ratingGrp.add(totalHit); + + var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.scoreData.tallies.maxCombo); + ratingGrp.add(maxCombo); + + if (params.scoreData.tallies.totalNotesHit >= 1000) + { + totalHit.x -= 30; + maxCombo.x -= 30; + } + + hStuf += 2; + var extraYOffset:Float = 7; + + hStuf += 2; + + var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.scoreData.tallies.sick, 0xFF89E59E); + ratingGrp.add(tallySick); + + var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.scoreData.tallies.good, 0xFF89C9E5); + ratingGrp.add(tallyGood); + + var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.scoreData.tallies.bad, 0xFFE6CF8A); + ratingGrp.add(tallyBad); + + var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.scoreData.tallies.shit, 0xFFE68C8A); + ratingGrp.add(tallyShit); + + var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6); + ratingGrp.add(tallyMissed); + + score.visible = false; + score.zIndex = 1200; + add(score); + + for (ind => rating in ratingGrp.members) + { + rating.visible = false; + new FlxTimer().start((0.3 * ind) + 1.20, _ -> { + rating.visible = true; + FlxTween.tween(rating, {curNumber: rating.neededNumber}, 0.5, {ease: FlxEase.quartOut}); + }); + } + + // if (params.isNewHighscore ?? false) + // { + // highscoreNew.visible = true; + // highscoreNew.animation.play("new"); + // //FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut}); + // } + // else + // { + // highscoreNew.visible = false; + // } + + new FlxTimer().start(rank.getMusicDelay(), _ -> { + var introMusic:String = Paths.music(getMusicPath(playerCharacter, rank) + '/' + getMusicPath(playerCharacter, rank) + '-intro'); + if (Assets.exists(introMusic)) + { + // Play the intro music. + introMusicAudio = FunkinSound.load(introMusic, 1.0, false, true, true, () -> { + introMusicAudio = null; + FunkinSound.playMusic(getMusicPath(playerCharacter, rank), + { + startingVolume: 1.0, + overrideExisting: true, + restartTrack: true + }); + }); + } + else + { + FunkinSound.playMusic(getMusicPath(playerCharacter, rank), + { + startingVolume: 1.0, + overrideExisting: true, + restartTrack: true + }); + } + }); + + rankBg.makeSolidColor(FlxG.width, FlxG.height, 0xFF000000); + rankBg.zIndex = 99999; + add(rankBg); + + rankBg.alpha = 0; + + refresh(); + + super.create(); + } + + function getMusicPath(playerCharacter:Null, rank:ScoringRank):String + { + return playerCharacter?.getResultsMusicPath(rank) ?? 'resultsNORMAL'; + } + + var rankTallyTimer:Null = null; + var clearPercentTarget:Int = 100; + var clearPercentLerp:Int = 0; + + function startRankTallySequence():Void + { + bgFlash.visible = true; + FlxTween.tween(bgFlash, {alpha: 0}, 5 / 24); + // NOTE: Only divide if totalNotes > 0 to prevent divide-by-zero errors. + var clearPercentFloat = params.scoreData.tallies.totalNotes == 0 ? 0.0 : (params.scoreData.tallies.sick + + params.scoreData.tallies.good + - params.scoreData.tallies.missed) / params.scoreData.tallies.totalNotes * 100; + clearPercentTarget = Math.floor(clearPercentFloat); + // Prevent off-by-one errors. + + clearPercentLerp = Std.int(Math.max(0, clearPercentTarget - 36)); + + trace('Clear percent target: ' + clearPercentFloat + ', round: ' + clearPercentTarget); + + var clearPercentCounter:ClearPercentCounter = new ClearPercentCounter(FlxG.width / 2 + 190, FlxG.height / 2 - 70, clearPercentLerp); + FlxTween.tween(clearPercentCounter, {curNumber: clearPercentTarget}, 58 / 24, + { + ease: FlxEase.quartOut, + onUpdate: _ -> { + clearPercentLerp = Math.round(clearPercentLerp); + clearPercentCounter.curNumber = Math.round(clearPercentCounter.curNumber); + // Only play the tick sound if the number increased. + if (clearPercentLerp != clearPercentCounter.curNumber) + { + // trace('$clearPercentLerp and ${clearPercentCounter.curNumber}'); + clearPercentLerp = clearPercentCounter.curNumber; + FunkinSound.playOnce(Paths.sound('scrollMenu')); + } + }, + onComplete: _ -> { + // Play confirm sound. + FunkinSound.playOnce(Paths.sound('confirmMenu')); + + // Just to be sure that the lerp didn't mess things up. + clearPercentCounter.curNumber = clearPercentTarget; + + #if FEATURE_NEWGROUNDS + var isScoreValid = !(params?.isPracticeMode ?? false) && !(params?.isBotPlayMode ?? false); + // This is the easiest spot to do the medal calculation lol. + if (isScoreValid && clearPercentTarget == 69) Medals.award(Nice); + #end + + clearPercentCounter.flash(true); + new FlxTimer().start(0.4, _ -> { + clearPercentCounter.flash(false); + }); + + // displayRankText(); + + // previously 2.0 seconds + new FlxTimer().start(0.25, _ -> { + FlxTween.tween(clearPercentCounter, {alpha: 0}, 0.5, + { + startDelay: 0.5, + ease: FlxEase.quartOut, + onComplete: _ -> { + remove(clearPercentCounter); + } + }); + + // afterRankTallySequence(); + }); + } + }); + clearPercentCounter.zIndex = 450; + add(clearPercentCounter); + + if (ratingsPopin == null) + { + trace("Could not build ratingsPopin!"); + } + else + { + // ratingsPopin.animation.play("idle"); + // ratingsPopin.visible = true; + + ratingsPopin.animation.finishCallback = anim -> { + // scorePopin.animation.play("score"); + + // scorePopin.visible = true; + + if (params.isNewHighscore ?? false) + { + highscoreNew.visible = true; + highscoreNew.animation.play("new"); + } + else + { + highscoreNew.visible = false; + } + }; + } + + refresh(); + } + + function displayRankText():Void + { + bgFlash.visible = true; + bgFlash.alpha = 1; + FlxTween.tween(bgFlash, {alpha: 0}, 14 / 24); + + var rankTextVert:FlxBackdrop = new FlxBackdrop(Paths.image(rank.getVerTextAsset()), Y, 0, 30); + rankTextVert.x = FlxG.width - 44; + rankTextVert.y = 100; + rankTextVert.zIndex = 990; + add(rankTextVert); + + FlxFlicker.flicker(rankTextVert, 2 / 24 * 3, 2 / 24, true); + + // Scrolling. + new FlxTimer().start(30 / 24, _ -> { + rankTextVert.velocity.y = -80; + }); + + for (i in 0...12) + { + var rankTextBack:FlxBackdrop = new FlxBackdrop(Paths.image(rank.getHorTextAsset()), X, 10, 0); + rankTextBack.x = FlxG.width / 2 - 320; + rankTextBack.y = 50 + (135 * i / 2) + 10; + // rankTextBack.angle = -3.8; + rankTextBack.zIndex = 100; + rankTextBack.cameras = [cameraScroll]; + add(rankTextBack); + + // Scrolling. + rankTextBack.velocity.x = (i % 2 == 0) ? -7.0 : 7.0; + } + + refresh(); + } + + function afterRankTallySequence():Void + { + showSmallClearPercent(); + + for (atlas in characterAtlasAnimations) + { + new FlxTimer().start(atlas.delay, _ -> { + if (atlas.sprite == null) return; + atlas.sprite.visible = true; + atlas.sprite.playAnimation(atlas.startFrameLabel); + if (atlas.sound != "") + { + var sndPath:String = Paths.stripLibrary(atlas.sound); + var sndLibrary:String = Paths.getLibrary(atlas.sound); + + FunkinSound.playOnce(Paths.sound(sndPath, sndLibrary), 1.0); + } + }); + } + + for (sprite in characterSparrowAnimations) + { + new FlxTimer().start(sprite.delay, _ -> { + if (sprite.sprite == null) return; + sprite.sprite.visible = true; + sprite.sprite.animation.play('idle', true); + }); + } + } + + function timerThenSongName(timerLength:Float = 3.0, autoScroll:Bool = true):Void + { + movingSongStuff = false; + + difficulty.x = 555; + + var diffYTween:Float = 122; + + difficulty.y = -difficulty.height; + FlxTween.tween(difficulty, {y: diffYTween}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.8}); + + if (clearPercentSmall != null) + { + clearPercentSmall.x = (difficulty.x + difficulty.width) + 60; + clearPercentSmall.y = -clearPercentSmall.height; + FlxTween.tween(clearPercentSmall, {y: 122 - 5}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.85}); + } + + songName.y = -songName.height; + var fuckedupnumber = (10) * (songName.text.length / 15); + FlxTween.tween(songName, {y: diffYTween - 25 - fuckedupnumber}, 0.5, {ease: FlxEase.expoOut, startDelay: 0.9}); + songName.x = clearPercentSmall.x + 94; + + new FlxTimer().start(timerLength, _ -> { + var tempSpeed = FlxPoint.get(speedOfTween.x, speedOfTween.y); + + speedOfTween.set(0, 0); + FlxTween.tween(speedOfTween, {x: tempSpeed.x, y: tempSpeed.y}, 0.7, {ease: FlxEase.quadIn}); + + movingSongStuff = (autoScroll); + }); + } + + function showSmallClearPercent():Void + { + if (clearPercentSmall != null) + { + add(clearPercentSmall); + clearPercentSmall.visible = true; + clearPercentSmall.flash(true); + new FlxTimer().start(0.4, _ -> { + clearPercentSmall.flash(false); + }); + + clearPercentSmall.curNumber = clearPercentTarget; + clearPercentSmall.zIndex = 1000; + refresh(); + } + + new FlxTimer().start(2.5, _ -> { + movingSongStuff = true; + }); + } + + var movingSongStuff:Bool = false; + var speedOfTween:FlxPoint = FlxPoint.get(-1, 1); + + override function draw():Void + { + super.draw(); + + songName.clipRect = FlxRect.get(Math.max(0, 520 - songName.x), 0, FlxG.width, songName.height); + + // PROBABLY SHOULD FIX MEMORY FREE OR WHATEVER THE PUT() FUNCTION DOES !!!! FEELS LIKE IT STUTTERS!!! + + // if (songName != null && songName.frame != null) + // maskShaderSongName.frameUV = songName.frame.uv; + } + + override function update(elapsed:Float):Void + { + maskShaderDifficulty.swagSprX = difficulty.x; + + if (movingSongStuff) + { + songName.x += speedOfTween.x; + difficulty.x += speedOfTween.x; + clearPercentSmall.x += speedOfTween.x; + songName.y += speedOfTween.y; + difficulty.y += speedOfTween.y; + clearPercentSmall.y += speedOfTween.y; + + if (songName.x + songName.width < 100) + { + timerThenSongName(); + } + } + + if (controls.PAUSE || controls.ACCEPT) + { + if (_parentState is funkin.ui.debug.results.ResultsDebugSubState) + { + if (introMusicAudio != null) + { + introMusicAudio.stop(); + introMusicAudio.destroy(); + introMusicAudio = null; + } + close(); // IF we are a substate, we will close ourselves. This is used from ResultsDebugSubState + } + else if (introMusicAudio != null) + { + @:nullSafety(Off) + introMusicAudio.onComplete = null; + + FlxTween.tween(introMusicAudio, {volume: 0}, 0.8, + { + onComplete: _ -> { + if (introMusicAudio != null) + { + introMusicAudio.stop(); + introMusicAudio.destroy(); + introMusicAudio = null; + } + } + }); + FlxTween.tween(introMusicAudio, {pitch: 3}, 0.1, + { + onComplete: _ -> { + FlxTween.tween(introMusicAudio, {pitch: 0.5}, 0.4); + } + }); + } + else if (FlxG.sound.music != null) + { + FlxTween.tween(FlxG.sound.music, {volume: 0}, 0.8, + { + onComplete: _ -> { + FlxG.sound.music.stop(); + FlxG.sound.music.destroy(); + } + }); + FlxTween.tween(FlxG.sound.music, {pitch: 3}, 0.1, + { + onComplete: _ -> { + FlxTween.tween(FlxG.sound.music, {pitch: 0.5}, 0.4); + } + }); + } + + // Determining the target state(s) to go to. + // Default to main menu because that's better than `null`. + var targetState:flixel.FlxState = new funkin.ui.mainmenu.MainMenuState(); + var shouldTween = false; + var shouldUseSubstate = false; + + var stickerPackId:Null = null; + + var song:Null = params.songId == null ? null : SongRegistry.instance.fetchEntry(params.songId); + + if (song != null) + { + stickerPackId = song.getStickerPackId(params?.difficultyId ?? Constants.DEFAULT_DIFFICULTY, params?.variationId ?? Constants.DEFAULT_VARIATION); + } + if (stickerPackId == null && playerCharacter != null) + { + stickerPackId = playerCharacter.getStickerPackID(); + } + + if (params.storyMode) + { + if (PlayerRegistry.instance.hasNewCharacter()) + { + // New character, display the notif. + targetState = new StoryMenuState(null); + + var newCharacters = PlayerRegistry.instance.listNewCharacters(); + + for (charId in newCharacters) + { + shouldTween = true; + // This works recursively, ehe! + targetState = new funkin.ui.charSelect.CharacterUnlockState(charId, targetState); + } + } + else + { + // No new characters. + shouldTween = false; + shouldUseSubstate = true; + targetState = new funkin.ui.transition.stickers.StickerSubState( + { + targetState: (sticker) -> new StoryMenuState(sticker), + stickerPack: stickerPackId + }); + } + } + else + { + var isScoreValid = !(params?.isPracticeMode ?? false) && !(params?.isBotPlayMode ?? false); + + var isPersonalBest = rank > Scoring.calculateRank(params?.prevScoreData); + + if (isScoreValid && isPersonalBest) + { + trace('THE RANK IS Higher.....'); + + shouldTween = true; + targetState = FreeplayState.build( + { + { + character: playerCharacterId ?? "bf", + fromResults: + { + oldRank: Scoring.calculateRank(params?.prevScoreData), + newRank: rank, + songId: params.songId, + difficultyId: params.difficultyId, + playRankAnim: true + } + } + }); + } + else + { + shouldTween = false; + shouldUseSubstate = true; + targetState = new funkin.ui.transition.stickers.StickerSubState( + { + targetState: (sticker) -> FreeplayState.build(null, sticker), + stickerPack: stickerPackId + }); + } + } + + if (shouldTween) + { + FlxTween.tween(rankBg, {alpha: 1}, 0.5, + { + ease: FlxEase.expoOut, + onComplete: function(_) { + if (shouldUseSubstate && targetState is FlxSubState) + { + openSubState(cast targetState); + } + else + { + FlxG.switchState(targetState); + } + } + }); + } + else + { + if (shouldUseSubstate && targetState is FlxSubState) + { + openSubState(cast targetState); + } + else + { + FlxG.switchState(targetState); + } + } + } + + super.update(elapsed); + } +} + +typedef ResultsStateParams = +{ + /** + * True if results are for a level, false if results are for a single song. + */ + var storyMode:Bool; + + /** + * A readable title for the song we just played. + * Either "Song Name by Artist Name" or "Week Name" + */ + var title:String; + + /** + * The internal song ID for the song we just played. + */ + var songId:String; + + /** + * The character ID for the song we just played. + * @default `bf` + */ + var ?characterId:String; + + /** + * Whether the displayed score is a new highscore + */ + var ?isNewHighscore:Bool; + + /** + * Whether the displayed score is from a song played with Practice Mode enabled. + */ + var ?isPracticeMode:Bool; + + /** + * Whether the displayed score is from a song played with Bot Play Mode enabled. + */ + var ?isBotPlayMode:Bool; + + /** + * The difficulty ID of the song/week we just played. + * @default `Constants.DEFAULT_DIFFICULTY` + */ + var ?difficultyId:String; + + /** + * The variation ID of the song/week we just played. + * @default `Constants.DEFAULT_VARIATION` + */ + var ?variationId:String; + + /** + * The score, accuracy, and judgements. + */ + var scoreData:SaveScoreData; + + /** + * The previous score data, used for rank comparision. + */ + var ?prevScoreData:SaveScoreData; +}; diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx new file mode 100644 index 00000000..de9ed373 --- /dev/null +++ b/source/funkin/play/character/AnimateAtlasCharacter.hx @@ -0,0 +1,838 @@ +package funkin.play.character; + +import flixel.animation.FlxAnimationController; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFrame; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.math.FlxMath; +import flixel.math.FlxPoint.FlxCallbackPoint; +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import funkin.graphics.FunkinSprite; +import flixel.system.FlxAssets.FlxGraphicAsset; +import flixel.util.FlxColor; +import flixel.util.FlxDestroyUtil; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.modding.events.ScriptEvent; +import funkin.play.character.CharacterData.CharacterRenderType; +import flixel.util.FlxDirectionFlags; +import openfl.display.BitmapData; +import openfl.display.BlendMode; + +/** + * Individual animation data for an AnimateAtlasCharacter. + */ +typedef AnimateAtlasAnimation = +{ + name:String, + prefix:String, + offsets:Null>, + looped:Bool, +} + +/** + * An AnimateAtlasCharacter is a Character which is rendered by + * displaying an animation derived from an Adobe Animate texture atlas spritesheet file. + * + * BaseCharacter has game logic, AnimateAtlasCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class AnimateAtlasCharacter extends BaseCharacter +{ + // BaseCharacter extends FlxSprite but we can't make it also extend FlxAtlasSprite UGH + // I basically copied the code from FlxSpriteGroup to make the FlxAtlasSprite a "child" of this class + var mainSprite:FlxAtlasSprite; + + var _skipTransformChildren:Bool = false; + + var animations:Map = new Map(); + var currentAnimName:Null = null; + var animFinished:Bool = false; + + public function new(id:String) + { + super(id, CharacterRenderType.AnimateAtlas); + } + + override function initVars():Void + { + // this.flixelType = SPRITEGROUP; + + // TODO: Make `animation` a stub that redirects calls to `mainSprite`? + animation = new FlxAnimationController(this); + + offset = new FlxCallbackPoint(offsetCallback); + origin = new FlxCallbackPoint(originCallback); + scale = new FlxCallbackPoint(scaleCallback); + scrollFactor = new FlxCallbackPoint(scrollFactorCallback); + + scale.set(1, 1); + scrollFactor.set(1, 1); + + initMotionVars(); + } + + override function onCreate(event:ScriptEvent):Void + { + // Display a custom scope for debugging purposes. + #if FEATURE_DEBUG_TRACY + cpp.vm.tracy.TracyProfiler.zoneScoped('AnimateAtlasCharacter.create(${this.characterId})'); + #end + + try + { + trace('Loading assets for Animate Atlas character "${characterId}"', flixel.util.FlxColor.fromString("#89CFF0")); + var atlasSprite:FlxAtlasSprite = loadAtlasSprite(); + setSprite(atlasSprite); + + loadAnimations(); + } + catch (e) + { + throw "Exception thrown while building FlxAtlasSprite: " + e; + } + + super.onCreate(event); + } + + public override function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reverse:Bool = false):Void + { + var correctName = correctAnimationName(name); + if (correctName == null) + { + trace('$characterName Could not find Atlas animation: ' + name); + return; + } + + var animData = getAnimationData(correctName); + currentAnimName = correctName; + var prefix:String = animData.prefix; + if (prefix == null) prefix = correctName; + var loop:Bool = animData.looped; + + this.mainSprite.playAnimation(prefix, restart, ignoreOther, loop); + } + + public override function hasAnimation(name:String):Bool + { + return getAnimationData(name) != null; + } + + /** + * Returns true if the animation has finished playing. + * Never true if animation is configured to loop. + */ + public override function isAnimationFinished():Bool + { + return mainSprite?.isAnimationFinished() ?? false; + } + + function loadAtlasSprite():FlxAtlasSprite + { + trace('[ATLASCHAR] Loading sprite atlas for ${characterId}.'); + + var animLibrary:String = Paths.getLibrary(_data.assetPath); + var animPath:String = Paths.stripLibrary(_data.assetPath); + var assetPath:String = Paths.animateAtlas(animPath, animLibrary); + + var sprite:FlxAtlasSprite = new FlxAtlasSprite(0, 0, assetPath); + + // sprite.onAnimationComplete.removeAll(); + sprite.onAnimationComplete.add(this.onAnimationFinished); + + return sprite; + } + + override function onAnimationFinished(prefix:String):Void + { + super.onAnimationFinished(prefix); + + if (!getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX) + && hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)) + { + playAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX); + } + + if (getAnimationData() != null && getAnimationData().looped) + { + if (StringTools.endsWith(prefix, "-hold")) trace(prefix); + playAnimation(prefix, true, false); + } + else + { + // Make the game hold on the last frame. + this.mainSprite.cleanupAnimation(prefix); + // currentAnimName = null; + + // Fallback to idle! + // playAnimation('idle', true, false); + } + } + + function setSprite(sprite:FlxAtlasSprite):Void + { + trace('[ATLASCHAR] Applying sprite properties to ${characterId}'); + + this.mainSprite = sprite; + + mainSprite.ignoreExclusionPref = ["sing"]; + + // This forces the atlas to recalcuate its width and height + this.mainSprite.alpha = 0.0001; + this.mainSprite.draw(); + this.mainSprite.alpha = 1.0; + + var feetPos:FlxPoint = feetPosition; + this.updateHitbox(); + + sprite.x = this.x; + sprite.y = this.y; + sprite.alpha *= alpha; + sprite.flipX = flipX; + sprite.flipY = flipY; + sprite.scrollFactor.copyFrom(scrollFactor); + sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null + + if (clipRect != null) clipRectTransform(sprite, clipRect); + } + + function loadAnimations():Void + { + trace('[ATLASCHAR] Attempting to load ${_data.animations.length} animations for ${characterId}'); + + var animData:Array = cast _data.animations; + + for (anim in animData) + { + // Validate the animation before adding. + var prefix = anim.prefix; + if (!this.mainSprite.hasAnimation(prefix)) + { + FlxG.log.warn('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}'); + trace('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}'); + continue; + } + animations.set(anim.name, anim); + trace('[ATLASCHAR] - Successfully loaded animation ${anim.name} to ${characterId}'); + } + + trace('[ATLASCHAR] Loaded ${animations.size()} animations for ${characterId}'); + } + + public override function getCurrentAnimation():String + { + // return this.mainSprite.getCurrentAnimation(); + return currentAnimName; + } + + function getAnimationData(name:String = null):AnimateAtlasAnimation + { + if (name == null) name = getCurrentAnimation(); + return animations.get(name); + } + + // + // + // Code copied from FlxSpriteGroup + // + // + + /** + * Handy function that allows you to quickly transform one property of sprites in this group at a time. + * + * @param callback Function to transform the sprites. Example: + * `function(sprite, v:Dynamic) { s.acceleration.x = v; s.makeGraphic(10,10,0xFF000000); }` + * @param value Value which will passed to lambda function. + */ + @:generic + public function transformChildren(callback:FlxAtlasSprite->V->Void, value:V):Void + { + if (_skipTransformChildren || this.mainSprite == null) return; + + callback(this.mainSprite, value); + } + + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + _skipTransformChildren = true; + super.kill(); + _skipTransformChildren = false; + if (this.mainSprite != null) + { + this.mainSprite.kill(); + this.mainSprite = null; + } + } + + /** + * Revives the group. + */ + public override function revive():Void + { + _skipTransformChildren = true; + super.revive(); // calls set_exists and set_alive + _skipTransformChildren = false; + this.mainSprite.revive(); + } + + /** + * **WARNING:** A destroyed `FlxBasic` can't be used anymore. + * It may even cause crashes if it is still part of a group or state. + * You may want to use `kill()` instead if you want to disable the object temporarily only and `revive()` it later. + * + * This function is usually not called manually (Flixel calls it automatically during state switches for all `add()`ed objects). + * + * Override this function to `null` out variables manually or call `destroy()` on class members if necessary. + * Don't forget to call `super.destroy()`! + */ + public override function destroy():Void + { + // normally don't have to destroy FlxPoints, but these are FlxCallbackPoints! + offset = FlxDestroyUtil.destroy(offset); + origin = FlxDestroyUtil.destroy(origin); + scale = FlxDestroyUtil.destroy(scale); + scrollFactor = FlxDestroyUtil.destroy(scrollFactor); + + this.mainSprite = FlxDestroyUtil.destroy(this.mainSprite); + + super.destroy(); + } + + /** + * Check and see if any sprite in this group is currently on screen. + * + * @param Camera Specify which game camera you want. If `null`, it will just grab the first global camera. + * @return Whether the object is on screen or not. + */ + public override function isOnScreen(?camera:FlxCamera):Bool + { + if (this.mainSprite != null && this.mainSprite.exists && this.mainSprite.visible && this.mainSprite.isOnScreen(camera)) return true; + + return false; + } + + /** + * Checks to see if a point in 2D world space overlaps any `FlxSprite` object from this group. + * + * @param Point The point in world space you want to check. + * @param InScreenSpace Whether to take scroll factors into account when checking for overlap. + * @param Camera Specify which game camera you want. If `null`, it will just grab the first global camera. + * @return Whether or not the point overlaps this group. + */ + public override function overlapsPoint(point:FlxPoint, inScreenSpace:Bool = false, camera:FlxCamera = null):Bool + { + var result:Bool = false; + result = this.mainSprite.overlapsPoint(point, inScreenSpace, camera); + return result; + } + + /** + * Checks to see if a point in 2D world space overlaps any of FlxSprite object's current displayed pixels. + * This check is ALWAYS made in screen space, and always takes scroll factors into account. + * + * @param Point The point in world space you want to check. + * @param Mask Used in the pixel hit test to determine what counts as solid. + * @param Camera Specify which game camera you want. If `null`, it will just grab the first global camera. + * @return Whether or not the point overlaps this object. + */ + public override function pixelsOverlapPoint(point:FlxPoint, Mask:Int = 0xFF, Camera:FlxCamera = null):Bool + { + var result:Bool = false; + if (this.mainSprite != null && this.mainSprite.exists && this.mainSprite.visible) + { + result = this.mainSprite.pixelsOverlapPoint(point, Mask, Camera); + } + return result; + } + + public override function update(elapsed:Float):Void + { + this.mainSprite.update(elapsed); + + if (moves) updateMotion(elapsed); + } + + public override function draw():Void + { + this.mainSprite.draw(); + + #if FLX_DEBUG + if (FlxG.debugger.drawDebug) drawDebug(); + #end + } + + inline function xTransform(sprite:FlxSprite, x:Float):Void + sprite.x += x; // addition + + inline function yTransform(sprite:FlxSprite, y:Float):Void + sprite.y += y; // addition + + inline function angleTransform(sprite:FlxSprite, angle:Float):Void + sprite.angle += angle; // addition + + inline function alphaTransform(sprite:FlxSprite, alpha:Float):Void + { + if (sprite.alpha != 0 || alpha == 0) + { + sprite.alpha *= alpha; // multiplication + } + else + { + sprite.alpha = 1 / alpha; // direct set to avoid stuck sprites + } + } + + inline function directAlphaTransform(sprite:FlxSprite, alpha:Float):Void + sprite.alpha = alpha; // direct set + + inline function facingTransform(sprite:FlxSprite, facing:FlxDirectionFlags):Void + sprite.facing = facing; + + inline function flipXTransform(sprite:FlxSprite, flipX:Bool):Void + sprite.flipX = flipX; + + inline function flipYTransform(sprite:FlxSprite, flipY:Bool):Void + sprite.flipY = flipY; + + inline function movesTransform(sprite:FlxSprite, moves:Bool):Void + sprite.moves = moves; + + inline function pixelPerfectTransform(sprite:FlxSprite, pixelPerfect:Bool):Void + sprite.pixelPerfectRender = pixelPerfect; + + inline function gColorTransform(sprite:FlxSprite, color:Int):Void + sprite.color = color; + + inline function blendTransform(sprite:FlxSprite, blend:BlendMode):Void + sprite.blend = blend; + + inline function immovableTransform(sprite:FlxSprite, immovable:Bool):Void + sprite.immovable = immovable; + + inline function visibleTransform(sprite:FlxSprite, visible:Bool):Void + sprite.visible = visible; + + inline function activeTransform(sprite:FlxSprite, active:Bool):Void + sprite.active = active; + + inline function solidTransform(sprite:FlxSprite, solid:Bool):Void + sprite.solid = solid; + + inline function aliveTransform(sprite:FlxSprite, alive:Bool):Void + sprite.alive = alive; + + inline function existsTransform(sprite:FlxSprite, exists:Bool):Void + sprite.exists = exists; + + inline function cameraTransform(sprite:FlxSprite, camera:FlxCamera):Void + sprite.camera = camera; + + inline function camerasTransform(sprite:FlxSprite, cameras:Array):Void + sprite.cameras = cameras; + + inline function offsetTransform(sprite:FlxSprite, offset:FlxPoint):Void + sprite.offset.copyFrom(offset); + + inline function originTransform(sprite:FlxSprite, origin:FlxPoint):Void + sprite.origin.copyFrom(origin); + + inline function scaleTransform(sprite:FlxSprite, scale:FlxPoint):Void + sprite.scale.copyFrom(scale); + + inline function scrollFactorTransform(sprite:FlxSprite, scrollFactor:FlxPoint):Void + sprite.scrollFactor.copyFrom(scrollFactor); + + inline function clipRectTransform(sprite:FlxSprite, clipRect:FlxRect):Void + { + if (clipRect == null) + { + sprite.clipRect = null; + } + else + { + sprite.clipRect = FlxRect.get(clipRect.x - sprite.x + x, clipRect.y - sprite.y + y, clipRect.width, clipRect.height); + } + } + + var resS:FlxPoint = new FlxPoint(); + + /** + * Reset the character so it can be used at the start of the level. + * Call this when restarting the level. + */ + override public function resetCharacter(resetCamera:Bool = true):Void + { + trace("RESETTING ATLAS " + characterName); + + // Reset the animation offsets. This will modify x and y to be the absolute position of the character. + // this.animOffsets = [0, 0]; + + // Now we can set the x and y to be their original values without having to account for animOffsets. + this.resetPosition(); + mainSprite.setPosition(originalPosition.x, originalPosition.y); + + // Then reapply animOffsets... + // applyAnimationOffsets(getCurrentAnimation()); + + // Make sure we are playing the idle animation + // ...then update the hitbox so that this.width and this.height are correct. + + mainSprite.scale.set(1, 1); + mainSprite.alpha = 0.0001; + mainSprite.width = 0; + mainSprite.height = 0; + this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song. + + mainSprite.draw(); // refresh frame + + if (resS.x == 0) + { + resS.x = mainSprite.width; // clunky bizz + resS.y = mainSprite.height; + } + + mainSprite.alpha = alpha; + + mainSprite.width = resS.x; + mainSprite.height = resS.y; + frameWidth = 0; + frameHeight = 0; + + scaleCallback(scale); + this.updateHitbox(); + + // Reset the camera focus point while we're at it. + if (resetCamera) this.resetCameraFocusPoint(); + } + + inline function offsetCallback(offset:FlxPoint):Void + transformChildren(offsetTransform, offset); + + inline function originCallback(origin:FlxPoint):Void + transformChildren(originTransform, origin); + + inline function scaleCallback(scale:FlxPoint):Void + transformChildren(scaleTransform, scale); + + inline function scrollFactorCallback(scrollFactor:FlxPoint):Void + transformChildren(scrollFactorTransform, scrollFactor); + + override function set_camera(value:FlxCamera):FlxCamera + { + if (camera != value) transformChildren(cameraTransform, value); + return super.set_camera(value); + } + + override function set_cameras(value:Array):Array + { + if (cameras != value) transformChildren(camerasTransform, value); + return super.set_cameras(value); + } + + override function set_exists(value:Bool):Bool + { + if (exists != value) transformChildren(existsTransform, value); + return super.set_exists(value); + } + + override function set_visible(value:Bool):Bool + { + if (exists && visible != value) transformChildren(visibleTransform, value); + return super.set_visible(value); + } + + override function set_active(value:Bool):Bool + { + if (exists && active != value) transformChildren(activeTransform, value); + return super.set_active(value); + } + + override function set_alive(value:Bool):Bool + { + if (alive != value) transformChildren(aliveTransform, value); + return super.set_alive(value); + } + + override function set_x(value:Float):Float + { + if (!exists || x == value) return x; // early return (no need to transform) + + transformChildren(xTransform, value - x); // offset + return x = value; + } + + override function set_y(value:Float):Float + { + if (exists && y != value) transformChildren(yTransform, value - y); // offset + return y = value; + } + + override function set_angle(value:Float):Float + { + if (exists && angle != value) transformChildren(angleTransform, value - angle); // offset + return angle = value; + } + + override function set_alpha(value:Float):Float + { + value = FlxMath.bound(value, 0, 1); + + if (exists && alpha != value) + { + transformChildren(directAlphaTransform, value); + } + return alpha = value; + } + + override function set_facing(value:FlxDirectionFlags):FlxDirectionFlags + { + if (exists && facing != value) transformChildren(facingTransform, value); + return facing = value; + } + + override function set_flipX(value:Bool):Bool + { + if (exists && flipX != value) transformChildren(flipXTransform, value); + return flipX = value; + } + + override function set_flipY(value:Bool):Bool + { + if (exists && flipY != value) transformChildren(flipYTransform, value); + return flipY = value; + } + + override function set_moves(value:Bool):Bool + { + if (exists && moves != value) transformChildren(movesTransform, value); + return moves = value; + } + + override function set_immovable(value:Bool):Bool + { + if (exists && immovable != value) transformChildren(immovableTransform, value); + return immovable = value; + } + + override function set_solid(value:Bool):Bool + { + if (exists && solid != value) transformChildren(solidTransform, value); + return super.set_solid(value); + } + + override function set_color(value:Int):Int + { + if (exists && color != value) transformChildren(gColorTransform, value); + return color = value; + } + + override function set_blend(value:BlendMode):BlendMode + { + if (exists && blend != value) transformChildren(blendTransform, value); + return blend = value; + } + + override function set_clipRect(rect:FlxRect):FlxRect + { + if (exists) transformChildren(clipRectTransform, rect); + return super.set_clipRect(rect); + } + + override function set_pixelPerfectRender(value:Bool):Bool + { + if (exists && pixelPerfectRender != value) transformChildren(pixelPerfectTransform, value); + return super.set_pixelPerfectRender(value); + } + + override function set_width(value:Float):Float + { + return value; + } + + override function get_width():Float + { + if (this.mainSprite == null) return 0; + + return this.mainSprite.width; + } + + /** + * Returns the left-most position of the left-most member. + * If there are no members, x is returned. + * + * @since 5.0.0 + * @return the left-most position of the left-most member + */ + public function findMinX():Float + { + return this.mainSprite == null ? x : findMinXHelper(); + } + + function findMinXHelper():Float + { + return this.mainSprite.x; + } + + /** + * Returns the right-most position of the right-most member. + * If there are no members, x is returned. + * + * @since 5.0.0 + * @return the right-most position of the right-most member + */ + public function findMaxX():Float + { + return this.mainSprite == null ? x : findMaxXHelper(); + } + + function findMaxXHelper():Float + { + return this.mainSprite.x + this.mainSprite.width; + } + + /** + * This functionality isn't supported in SpriteGroup + */ + override function set_height(value:Float):Float + { + return value; + } + + override function get_height():Float + { + if (this.mainSprite == null) return 0; + + return this.mainSprite.height; + } + + /** + * Returns the top-most position of the top-most member. + * If there are no members, y is returned. + * + * @since 5.0.0 + * @return the top-most position of the top-most member + */ + public function findMinY():Float + { + return this.mainSprite == null ? y : findMinYHelper(); + } + + function findMinYHelper():Float + { + return this.mainSprite.y; + } + + /** + * Returns the top-most position of the top-most member. + * If there are no members, y is returned. + * + * @since 5.0.0 + * @return the bottom-most position of the bottom-most member + */ + public function findMaxY():Float + { + return this.mainSprite == null ? y : findMaxYHelper(); + } + + function findMaxYHelper():Float + { + return this.mainSprite.y + this.mainSprite.height; + } + + /** + * This functionality isn't supported in SpriteGroup + * @return this sprite group + */ + public override function loadGraphicFromSprite(Sprite:FlxSprite):FunkinSprite + { + #if FLX_DEBUG + throw "This function is not supported in FlxSpriteGroup"; + #end + return this; + } + + /** + * This functionality isn't supported in SpriteGroup + * @return this sprite group + */ + public override function loadGraphic(Graphic:FlxGraphicAsset, Animated:Bool = false, Width:Int = 0, Height:Int = 0, Unique:Bool = false, + ?Key:String):FlxSprite + { + return this; + } + + /** + * This functionality isn't supported in SpriteGroup + * @return this sprite group + */ + public override function loadRotatedGraphic(Graphic:FlxGraphicAsset, Rotations:Int = 16, Frame:Int = -1, AntiAliasing:Bool = false, AutoBuffer:Bool = false, + ?Key:String):FlxSprite + { + #if FLX_DEBUG + throw "This function is not supported in FlxSpriteGroup"; + #end + return this; + } + + /** + * This functionality isn't supported in SpriteGroup + * @return this sprite group + */ + public override function makeGraphic(Width:Int, Height:Int, Color:Int = FlxColor.WHITE, Unique:Bool = false, ?Key:String):FlxSprite + { + #if FLX_DEBUG + throw "This function is not supported in FlxSpriteGroup"; + #end + return this; + } + + override function set_pixels(value:BitmapData):BitmapData + { + return value; + } + + override function set_frame(value:FlxFrame):FlxFrame + { + return value; + } + + override function get_pixels():BitmapData + { + return null; + } + + /** + * Internal function to update the current animation frame. + * + * @param RunOnCpp Whether the frame should also be recalculated if we're on a non-flash target + */ + override inline function calcFrame(RunOnCpp:Bool = false):Void + { + // Nothing to do here + } + + /** + * This functionality isn't supported in SpriteGroup + */ + override inline function resetHelpers():Void {} + + /** + * This functionality isn't supported in SpriteGroup + */ + public override inline function stamp(Brush:FlxSprite, X:Int = 0, Y:Int = 0):Void {} + + override function set_frames(Frames:FlxFramesCollection):FlxFramesCollection + { + return Frames; + } + + /** + * This functionality isn't supported in SpriteGroup + */ + override inline function updateColorTransform():Void {} +} diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx new file mode 100644 index 00000000..edcf231e --- /dev/null +++ b/source/funkin/play/character/BaseCharacter.hx @@ -0,0 +1,719 @@ +package funkin.play.character; + +import flixel.math.FlxPoint; +import funkin.modding.events.ScriptEvent; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.character.CharacterData.CharacterRenderType; +import funkin.play.stage.Bopper; +import funkin.play.notes.NoteDirection; + +/** + * A Character is a stage prop which bops to the music as well as controlled by the strumlines. + * + * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom) + */ +class BaseCharacter extends Bopper +{ + // Metadata about a character. + public var characterId(default, null):String; + public var characterName(default, null):String; + + /** + * Whether the player is an active character (Boyfriend) or not. + */ + public var characterType(default, set):CharacterType = OTHER; + + function set_characterType(value:CharacterType):CharacterType + { + return this.characterType = value; + } + + /** + * Tracks how long, in seconds, the character has been playing the current `sing` animation. + * This is used to ensure that characters play the `sing` animations for at least one beat, + * preventing them from reverting to the `idle` animation between notes. + */ + public var holdTimer:Float = 0; + + /** + * Set to true when the character dead. Part of the handling for death animations. + */ + public var isDead:Bool = false; + + /** + * Set to true when the character being used in a special way. + * This includes the Chart Editor and the Animation Editor. + * + * Used by scripts to ensure that they don't try to run code to interact with the stage when the stage doesn't actually exist. + */ + public var debug:Bool = false; + + /** + * This character plays a given animation when hitting these specific combo numbers. + */ + public var comboNoteCounts(default, null):Array; + + /** + * This character plays a given animation when dropping combos larger than these numbers. + */ + public var dropNoteCounts(default, null):Array; + + @:allow(funkin.ui.debug.anim.DebugBoundingState) + final _data:CharacterData; + final singTimeSteps:Float; + + /** + * The offset between the corner of the sprite and the origin of the sprite (at the character's feet). + * cornerPosition = stageData - characterOrigin + */ + public var characterOrigin(get, never):FlxPoint; + + function get_characterOrigin():FlxPoint + { + var xPos = (width / 2); // Horizontal center + var yPos = (height); // Vertical bottom + return new FlxPoint(xPos, yPos); + } + + /** + * The absolute position of the top-left of the character. + * @return + */ + public var cornerPosition(get, set):FlxPoint; + + function get_cornerPosition():FlxPoint + { + return new FlxPoint(x, y); + } + + function set_cornerPosition(value:FlxPoint):FlxPoint + { + var xDiff:Float = value.x - this.x; + var yDiff:Float = value.y - this.y; + + this.cameraFocusPoint.x += xDiff; + this.cameraFocusPoint.y += yDiff; + + super.set_x(value.x); + super.set_y(value.y); + + return value; + } + + /** + * The absolute position of the character's feet, at the bottom-center of the sprite. + */ + public var feetPosition(get, never):FlxPoint; + + function get_feetPosition():FlxPoint + { + return new FlxPoint(x + characterOrigin.x, y + characterOrigin.y); + } + + /** + * Returns the point the camera should focus on. + * Should be approximately centered on the character, and should not move based on the current animation. + * + * Set the position of this rather than reassigning it, so that anything referencing it will not be affected. + */ + public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0); + + /** + * If the x position changes, other than via changing the animation offset, + * then we need to update the camera focus point. + */ + override function set_x(value:Float):Float + { + if (value == this.x) return value; + + var xDiff = value - this.x; + this.cameraFocusPoint.x += xDiff; + + return super.set_x(value); + } + + /** + * If the y position changes, other than via changing the animation offset, + * then we need to update the camera focus point. + */ + override function set_y(value:Float):Float + { + if (value == this.y) return value; + + var yDiff = value - this.y; + this.cameraFocusPoint.y += yDiff; + + return super.set_y(value); + } + + public function new(id:String, renderType:CharacterRenderType) + { + super(CharacterDataParser.DEFAULT_DANCEEVERY); + + this.characterId = id; + + ignoreExclusionPref = ["sing"]; + + _data = CharacterDataParser.fetchCharacterData(this.characterId); + if (_data == null) + { + throw 'Could not find character data for characterId: $characterId'; + } + else if (_data.renderType != renderType) + { + throw 'Render type mismatch for character ($characterId): expected ${renderType}, got ${_data.renderType}'; + } + else + { + this.characterName = _data.name; + this.name = _data.name; + this.danceEvery = _data.danceEvery; + this.singTimeSteps = _data.singTime; + this.globalOffsets = _data.offsets; + this.flipX = _data.flipX; + } + + shouldBop = false; + } + + public function getDeathCameraOffsets():Array + { + return _data.death?.cameraOffsets ?? [0.0, 0.0]; + } + + public function getBaseScale():Float + { + return _data.scale; + } + + public function getDeathCameraZoom():Float + { + return _data.death?.cameraZoom ?? 1.0; + } + + public function getDeathPreTransitionDelay():Float + { + return _data.death?.preTransitionDelay ?? 0.0; + } + + /** + * Gets the value of flipX from the character data. + * `!getFlipX()` is the direction Boyfriend should face. + */ + public function getDataFlipX():Bool + { + return _data.flipX; + } + + function findCountAnimations(prefix:String):Array + { + var animNames:Array = this.animation.getNameList(); + + var result:Array = []; + + for (anim in animNames) + { + if (anim.startsWith(prefix)) + { + var comboNum:Null = Std.parseInt(anim.substring(prefix.length)); + if (comboNum != null) + { + result.push(comboNum); + } + } + } + + // Sort numerically. + result.sort((a, b) -> a - b); + return result; + } + + /** + * Reset the character so it can be used at the start of the level. + * Call this when restarting the level. + */ + public function resetCharacter(resetCamera:Bool = true):Void + { + // Set the x and y to be their original values. + this.resetPosition(); + + this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song. + // Make sure we are playing the idle animation + // ...then update the hitbox so that this.width and this.height are correct. + this.updateHitbox(); + + // Reset the camera focus point while we're at it. + if (resetCamera) this.resetCameraFocusPoint(); + } + + /** + * Set the character's sprite scale to the appropriate value. + * @param scale The desired scale. + */ + public function setScale(scale:Null):Void + { + if (scale == null) scale = 1.0; + + var feetPos:FlxPoint = feetPosition; + this.scale.x = scale; + this.scale.y = scale; + this.updateHitbox(); + // Reposition with newly scaled sprite. + this.x = feetPos.x - characterOrigin.x + globalOffsets[0]; + this.y = feetPos.y - characterOrigin.y + globalOffsets[1]; + } + + /** + * The per-character camera offset. + */ + var characterCameraOffsets(get, never):Array; + + function get_characterCameraOffsets():Array + { + return _data.cameraOffsets; + } + + override function onCreate(event:ScriptEvent):Void + { + super.onCreate(event); + + // Make sure we are playing the idle animation... + this.dance(true); + // ...then update the hitbox so that this.width and this.height are correct. + this.updateHitbox(); + // Without the above code, width and height (and therefore character position) + // will be based on the first animation in the sheet rather than the default animation. + + this.resetCameraFocusPoint(); + + // Child class should have created animations by now, + // so we can query which ones are available. + this.comboNoteCounts = findCountAnimations('combo'); // example: combo50 + this.dropNoteCounts = findCountAnimations('drop'); // example: drop50 + if (comboNoteCounts.length > 0) trace('Combo note counts: ' + this.comboNoteCounts); + if (dropNoteCounts.length > 0) trace('Drop note counts: ' + this.dropNoteCounts); + + super.onCreate(event); + } + + override function onAnimationFinished(animationName:String):Void + { + super.onAnimationFinished(animationName); + + // trace('${characterId} has finished animation: ${animationName}'); + if ((animationName.endsWith(Constants.ANIMATION_END_SUFFIX) && !animationName.startsWith('idle') && !animationName.startsWith('dance')) + || animationName.startsWith('combo') + || animationName.startsWith('drop')) + { + // Force the character to play the idle after the animation ends. + this.dance(true); + } + } + + function resetCameraFocusPoint():Void + { + // Calculate the camera focus point + var charCenterX = this.x + this.width / 2; + var charCenterY = this.y + this.height / 2; + this.cameraFocusPoint = new FlxPoint(charCenterX + _data.cameraOffsets[0], charCenterY + _data.cameraOffsets[1]); + } + + public function getHealthIconId():String + { + return _data?.healthIcon?.id ?? Constants.DEFAULT_HEALTH_ICON; + } + + public function initHealthIcon(isOpponent:Bool):Void + { + if (!isOpponent) + { + if (PlayState.instance.iconP1 == null) + { + trace('[WARN] Player 1 health icon not found!'); + return; + } + PlayState.instance.iconP1.configure(_data?.healthIcon); + PlayState.instance.iconP1.flipX = !PlayState.instance.iconP1.flipX; // BF is looking the other way. + } + else + { + if (PlayState.instance.iconP2 == null) + { + trace('[WARN] Player 2 health icon not found!'); + return; + } + PlayState.instance.iconP2.configure(_data?.healthIcon); + } + } + + public override function onUpdate(event:UpdateScriptEvent):Void + { + super.onUpdate(event); + + // Reset hold timer for each note pressed. + if (justPressedNote() && this.characterType == BF) + { + holdTimer = 0; + } + + if (isDead) + { + // playDeathAnimation(); + return; + } + + // If there is an animation, and another animation with the same name + "-hold" exists, + // the second animation will play (and be looped if configured to do so) after the first animation finishes. + // This is good for characters that need to hold a pose while maintaining an animation, like the parents (this keeps their eyes flickering) + // and Darnell (this keeps the flame on his lighter flickering). + // Works for idle, singLEFT/RIGHT/UP/DOWN, alt singing animations, and anything else really. + + if (isAnimationFinished() + && !getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX) + && hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)) + { + playAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX); + } + else + { + if (isAnimationFinished()) + { + // trace('Not playing hold (${getCurrentAnimation()}) (${isAnimationFinished()}, ${getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX)}, ${hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)})'); + } + } + + // Handle character note hold time. + if (isSinging()) + { + // TODO: Rework this code (and all character animations ugh) + // such that the hold time is handled by padding frames, + // and reverting to the idle animation is done when `isAnimationFinished()`. + // This lets you add frames to the end of the sing animation to ease back into the idle! + + holdTimer += event.elapsed; + var singTimeSec:Float = singTimeSteps * (Conductor.instance.stepLengthMs / Constants.MS_PER_SEC); // x beats, to ms. + + if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss??? + + // Without this check here, the player character would only play the `sing` animation + // for one beat, as opposed to holding it as long as the player is holding the button. + var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true; + + FlxG.watch.addQuick('singTimeSec-${characterId}', singTimeSec); + if (holdTimer > singTimeSec && shouldStopSinging) + { + // trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation'); + holdTimer = 0; + + var currentAnimation:String = getCurrentAnimation(); + // Strip "-hold" from the end. + if (currentAnimation.endsWith(Constants.ANIMATION_HOLD_SUFFIX)) currentAnimation = currentAnimation.substring(0, + currentAnimation.length - Constants.ANIMATION_HOLD_SUFFIX.length); + + var endAnimation:String = currentAnimation + Constants.ANIMATION_END_SUFFIX; + if (hasAnimation(endAnimation)) + { + // Play the '-end' animation, if one exists. + trace('${characterId}: playing ${endAnimation}'); + playAnimation(endAnimation); + } + else + { + // Play the idle animation. + dance(true); + } + } + } + else + { + holdTimer = 0; + // super.onBeatHit handles the regular `dance()` calls. + } + FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer); + } + + public function isSinging():Bool + { + var currentAnimation:String = getCurrentAnimation(); + return currentAnimation.startsWith('sing') && !currentAnimation.endsWith(Constants.ANIMATION_END_SUFFIX); + } + + override function dance(force:Bool = false):Void + { + // Prevent default dancing behavior. + if (isDead) return; + + if (!force) + { + // Prevent dancing while a singing animation is playing. + if (isSinging()) return; + + // Prevent dancing while a non-idle special animation is playing. + var currentAnimation:String = getCurrentAnimation(); + if (!currentAnimation.startsWith('dance') && !currentAnimation.startsWith('idle') && !isAnimationFinished()) return; + } + + // Otherwise, fallback to the super dance() method, which handles playing the idle animation. + super.dance(); + } + + /** + * Returns true if the player just pressed a note. + * Used when determing whether a the player character should revert to the `idle` animation. + * On non-player characters, this should be ignored. + */ + function justPressedNote(player:Int = 1):Bool + { + // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. + switch (player) + { + case 1: + return PlayerSettings.player1.controls.NOTE_LEFT_P + || PlayerSettings.player1.controls.NOTE_DOWN_P + || PlayerSettings.player1.controls.NOTE_UP_P + || PlayerSettings.player1.controls.NOTE_RIGHT_P; + case 2: + return PlayerSettings.player2.controls.NOTE_LEFT_P + || PlayerSettings.player2.controls.NOTE_DOWN_P + || PlayerSettings.player2.controls.NOTE_UP_P + || PlayerSettings.player2.controls.NOTE_RIGHT_P; + } + return false; + } + + /** + * Returns true if the player is holding a note. + * Used when determing whether a the player character should revert to the `idle` animation. + * On non-player characters, this should be ignored. + */ + function isHoldingNote(player:Int = 1):Bool + { + // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. + switch (player) + { + case 1: + return PlayerSettings.player1.controls.NOTE_LEFT + || PlayerSettings.player1.controls.NOTE_DOWN + || PlayerSettings.player1.controls.NOTE_UP + || PlayerSettings.player1.controls.NOTE_RIGHT; + case 2: + return PlayerSettings.player2.controls.NOTE_LEFT + || PlayerSettings.player2.controls.NOTE_DOWN + || PlayerSettings.player2.controls.NOTE_UP + || PlayerSettings.player2.controls.NOTE_RIGHT; + } + return false; + } + + /** + * Every time a note is hit, check if the note is from the same strumline. + * If it is, then play the sing animation. + */ + public override function onNoteHit(event:HitNoteScriptEvent) + { + super.onNoteHit(event); + + // If another script cancelled the event, don't do anything. + if (event.eventCanceled) return; + + if (event.note.noteData.getMustHitNote() && characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.noteData.getDirection(), false); + holdTimer = 0; + } + else if (!event.note.noteData.getMustHitNote() && characterType == DAD) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.noteData.getDirection(), false); + holdTimer = 0; + } + else if (characterType == GF && event.note.noteData.getMustHitNote()) + { + switch (event.judgement) + { + case 'sick' | 'good': + playComboAnimation(event.comboCount); + default: + playComboDropAnimation(event.comboCount); + } + } + } + + /** + * Every time a note is missed, check if the note is from the same strumline. + * If it is, then play the sing animation. + */ + public override function onNoteMiss(event:NoteScriptEvent) + { + super.onNoteMiss(event); + + // If another script cancelled the event, don't do anything. + if (event.eventCanceled) return; + + if (event.note.noteData.getMustHitNote() && characterType == BF) + { + // If the note is from the same strumline, play the miss animation. + this.playSingAnimation(event.note.noteData.getDirection(), true); + } + else if (!event.note.noteData.getMustHitNote() && characterType == DAD) + { + // If the note is from the same strumline, play the miss animation. + this.playSingAnimation(event.note.noteData.getDirection(), true); + } + else if (event.note.noteData.getMustHitNote() && characterType == GF) + { + playComboDropAnimation(event.comboCount); + } + } + + public override function onNoteHoldDrop(event:HoldNoteScriptEvent) + { + super.onNoteHoldDrop(event); + + // If another script cancelled the event, don't do anything. + if (event.eventCanceled) return; + + if (event.holdNote.noteData.getMustHitNote() && characterType == BF) + { + // If the note is from the same strumline, play the miss animation. + this.playSingAnimation(event.holdNote.noteData.getDirection(), true); + } + else if (!event.holdNote.noteData.getMustHitNote() && characterType == DAD) + { + // If the note is from the same strumline, play the miss animation. + this.playSingAnimation(event.holdNote.noteData.getDirection(), true); + } + else if (event.holdNote.noteData.getMustHitNote() && event.isComboBreak && characterType == GF) + { + playComboDropAnimation(event.comboCount); + } + } + + function playComboAnimation(comboCount:Int):Void + { + var comboAnim = 'combo${comboCount}'; + if (hasAnimation(comboAnim)) + { + trace('Playing GF combo animation: ${comboAnim}'); + this.playAnimation(comboAnim, true, true); + } + } + + function playComboDropAnimation(comboCount:Int):Void + { + var dropAnim:Null = null; + + // Choose the combo drop anim to play. + // If there are several (for example, drop10 and drop50) the highest one will be used. + // If the combo count is too low, no animation will be played. + for (count in dropNoteCounts) + { + if (comboCount >= count) + { + dropAnim = 'drop${count}'; + } + } + + if (dropAnim != null) + { + trace('Playing GF combo drop animation: ${dropAnim}'); + this.playAnimation(dropAnim, true, true); + } + } + + /** + * Every time a wrong key is pressed, play the miss animation if we are Boyfriend. + */ + public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void + { + super.onNoteGhostMiss(event); + + if (event.eventCanceled || !event.playAnim) + { + // Skipping... + return; + } + + if (characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + // trace('Playing ghost miss animation...'); + this.playSingAnimation(event.dir, true); + } + } + + public override function onDestroy(event:ScriptEvent):Void + { + this.characterType = OTHER; + } + + /** + * Play the appropriate singing animation, for the given note direction. + * @param dir The direction of the note. + * @param miss If true, play the miss animation instead of the sing animation. + * @param suffix A suffix to append to the animation name, like `alt`. + */ + public function playSingAnimation(dir:NoteDirection, miss:Bool = false, ?suffix:String = ''):Void + { + var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}'; + + // restart even if already playing, because the character might sing the same note twice. + // trace('Playing ${anim}...'); + playAnimation(anim, true); + } + + public override function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reversed:Bool = false):Void + { + super.playAnimation(name, restart, ignoreOther, reversed); + } + + public function getDeathQuote():Null + { + return null; + } +} + +/** + * The type of a given character sprite. Defines its default behaviors. + */ +enum CharacterType +{ + /** + * The BF character has the following behaviors. + * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. + * - When the player hits a note, plays the appropriate `singDIR` animation until BF is done singing. + * - If there is a `singDIR-end` animation, the `singDIR` animation will play once before looping the `singDIR-end` animation until BF is done singing. + * - If the player misses or hits a ghost note, plays the appropriate `singDIR-miss` animation until BF is done singing. + */ + BF; + + /** + * The DAD character has the following behaviors. + * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. + * - When the CPU hits a note, plays the appropriate `singDIR` animation until DAD is done singing. + * - If there is a `singDIR-end` animation, the `singDIR` animation will play once before looping the `singDIR-end` animation until DAD is done singing. + * - When the CPU misses a note (NOTE: This only happens via script, not by default), + * plays the appropriate `singDIR-miss` animation until DAD is done singing. + */ + DAD; + + /** + * The GF character has the following behaviors. + * - At idle, dances with `danceLeft` and `danceRight` if available, or `idle` if not. + * - If available, `combo###` animations will play when certain combo counts are reached. + * - For example, `combo50` will play when the player hits 50 notes in a row. + * - Multiple combo animations can be provided for different thresholds. + * - If available, `drop###` animations will play when combos are dropped above certain thresholds. + * - For example, `drop10` will play when the player drops a combo larger than 10. + * - Multiple drop animations can be provided for different thresholds (i.e. dropping larger combos). + * - No drop animation will play if one isn't applicable (i.e. if the combo count is too low). + */ + GF; + + /** + * The OTHER character will only perform the `danceLeft`/`danceRight` or `idle` animation by default, depending on what's available. + * Additional behaviors can be performed via scripts. + */ + OTHER; +} diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx new file mode 100644 index 00000000..9465ccfc --- /dev/null +++ b/source/funkin/play/character/CharacterData.hx @@ -0,0 +1,801 @@ +package funkin.play.character; + +import funkin.data.animation.AnimationData; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.ScriptedCharacter.ScriptedAnimateAtlasCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedBaseCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedMultiSparrowCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter; +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; +import haxe.Json; +import flixel.graphics.frames.FlxFrame; + +class CharacterDataParser +{ + /** + * The current version string for the stage data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateStageData()` function. + * + * - Version 1.0.1 adds `death.cameraOffsets` + */ + public static final CHARACTER_DATA_VERSION:String = '1.0.1'; + + /** + * The current version rule check for the stage data format. + */ + public static final CHARACTER_DATA_VERSION_RULE:String = '1.0.x'; + + static final characterCache:Map = new Map(); + static final characterScriptedClass:Map = new Map(); + + static final DEFAULT_CHAR_ID:String = 'UNKNOWN'; + + /** + * Parses and preloads the game's stage data and scripts when the game starts. + * + * If you want to force stages to be reloaded, you can just call this function again. + */ + public static function loadCharacterCache():Void + { + // Clear any stages that are cached if there were any. + clearCharacterCache(); + trace('[CHARACTER] Parsing all entries...'); + + // + // UNSCRIPTED CHARACTERS + // + var charIdList:Array = DataAssets.listDataFilesInPath('characters/'); + var unscriptedCharIds:Array = charIdList.filter(function(charId:String):Bool { + return !characterCache.exists(charId); + }); + trace(' Fetching data for ${unscriptedCharIds.length} characters...'); + for (charId in unscriptedCharIds) + { + try + { + var charData:CharacterData = parseCharacterData(charId); + if (charData != null) + { + trace(' Loaded character data: ${charId}'); + characterCache.set(charId, charData); + } + } + catch (e) + { + // Assume error was already logged. + continue; + } + } + + // + // SCRIPTED CHARACTERS + // + + // Fuck I wish scripted classes supported static functions. + + var scriptedCharClassNames1:Array = ScriptedSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames1.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames1) + { + try + { + var character:SparrowCharacter = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + trace(' Initialized character ${character.characterName}'); + characterScriptedClass.set(character.characterId, charCls); + } + catch (e) + { + trace(' FAILED to instantiate scripted Sparrow character: ${charCls}'); + trace(e); + } + } + } + + var scriptedCharClassNames2:Array = ScriptedPackerCharacter.listScriptClasses(); + if (scriptedCharClassNames2.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...'); + for (charCls in scriptedCharClassNames2) + { + try + { + var character:PackerCharacter = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + catch (e) + { + trace(' FAILED to instantiate scripted Packer character: ${charCls}'); + trace(e); + } + } + } + + var scriptedCharClassNames3:Array = ScriptedMultiSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames3.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames3) + { + try + { + var character:MultiSparrowCharacter = ScriptedMultiSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + catch (e) + { + trace(' FAILED to instantiate scripted Multi-Sparrow character: ${charCls}'); + trace(e); + } + } + } + + var scriptedCharClassNames4:Array = ScriptedAnimateAtlasCharacter.listScriptClasses(); + if (scriptedCharClassNames4.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames4.length} (Animate Atlas) scripted characters...'); + for (charCls in scriptedCharClassNames4) + { + try + { + var character:AnimateAtlasCharacter = ScriptedAnimateAtlasCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + catch (e) + { + trace(' FAILED to instantiate scripted Animate Atlas character: ${charCls}'); + trace(e); + } + } + } + + // NOTE: Only instantiate the ones not populated above. + // ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes. + var scriptedCharClassNames:Array = ScriptedBaseCharacter.listScriptClasses(); + scriptedCharClassNames = scriptedCharClassNames.filter(function(charCls:String):Bool { + return !(scriptedCharClassNames1.contains(charCls) + || scriptedCharClassNames2.contains(charCls) + || scriptedCharClassNames3.contains(charCls) + || scriptedCharClassNames4.contains(charCls)); + }); + + if (scriptedCharClassNames.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...'); + for (charCls in scriptedCharClassNames) + { + var character:BaseCharacter = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID, Custom); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + else + { + trace(' Successfully instantiated scripted character: ${charCls}'); + characterScriptedClass.set(character.characterId, charCls); + } + } + } + + trace(' Successfully loaded ${Lambda.count(characterCache)} stages.'); + } + + /** + * Fetches data for a character and returns a BaseCharacter instance, + * ready to be added to the scene. + * @param charId The character ID to fetch. + * @return The character instance, or null if the character was not found. + */ + public static function fetchCharacter(charId:String, debug:Bool = false):Null + { + if (charId == null || charId == '' || !characterCache.exists(charId)) + { + // Gracefully handle songs that don't use this character, + // or throw an error if the character is missing. + + if (charId != null && charId != '') trace('Failed to build character, not found in cache: ${charId}'); + return null; + } + + var charData:CharacterData = characterCache.get(charId); + var charScriptClass:String = characterScriptedClass.get(charId); + + var char:BaseCharacter; + + if (charScriptClass != null) + { + switch (charData.renderType) + { + case CharacterRenderType.AnimateAtlas: + char = ScriptedAnimateAtlasCharacter.init(charScriptClass, charId); + case CharacterRenderType.MultiSparrow: + char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.Sparrow: + char = ScriptedSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.Packer: + char = ScriptedPackerCharacter.init(charScriptClass, charId); + default: + // We're going to assume that the script class does the rendering. + char = ScriptedBaseCharacter.init(charScriptClass, charId, CharacterRenderType.Custom); + } + } + else + { + switch (charData.renderType) + { + case CharacterRenderType.AnimateAtlas: + char = new AnimateAtlasCharacter(charId); + case CharacterRenderType.MultiSparrow: + char = new MultiSparrowCharacter(charId); + case CharacterRenderType.Sparrow: + char = new SparrowCharacter(charId); + case CharacterRenderType.Packer: + char = new PackerCharacter(charId); + default: + trace('[WARN] Creating character with undefined renderType ${charData.renderType}'); + char = new BaseCharacter(charId, CharacterRenderType.Custom); + } + } + + if (char == null) + { + trace('Failed to instantiate character: ${charId}'); + return null; + } + + trace('Successfully instantiated character (${debug ? 'debug' : 'stable'}): ${charId}'); + + char.debug = debug; + + // Call onCreate only in the fetchCharacter() function, not at application initialization. + ScriptEventDispatcher.callEvent(char, new ScriptEvent(CREATE)); + + return char; + } + + /** + * Fetches just the character data for a character. + * @param charId The character ID to fetch. + * @return The character data, or null if the character was not found. + */ + public static function fetchCharacterData(charId:String):Null + { + if (characterCache.exists(charId)) return characterCache.get(charId); + + return null; + } + + /** + * Lists all the valid character IDs. + * @return An array of character IDs. + */ + public static function listCharacterIds():Array + { + return characterCache.keys().array(); + } + + /** + * Returns the idle frame of a character. + */ + public static function getCharPixelIconAsset(char:String):FlxFrame + { + var charPath:String = "freeplay/icons/"; + + // FunkinCrew please dont skin me alive for copying pixelated icon and changing it a tiny bit + switch (char) + { + case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf" | "bf-dark": + charPath += "bfpixel"; + case "monster-christmas": + charPath += "monsterpixel"; + case "mom" | "mom-car": + charPath += "mommypixel"; + case "pico-blazin" | "pico-playable" | "pico-speaker" | "pico-pixel" | "pico-holding-nene": + charPath += "picopixel"; + case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen" | "gf-dark": + charPath += "gfpixel"; + case "dad": + charPath += "dadpixel"; + case "darnell-blazin": + charPath += "darnellpixel"; + case "senpai-angry": + charPath += "senpaipixel"; + case "spooky-dark": + charPath += "spookypixel"; + case "tankman-atlas" | "tankman-bloody": + charPath += "tankmanpixel"; + case "pico-christmas" | "pico-dark": + charPath += "picopixel"; + default: + charPath += '${char}pixel'; + } + + if (!Assets.exists(Paths.image(charPath))) + { + trace('[WARN] Character ${char} has no freeplay icon.'); + return null; + } + + var isAnimated = Assets.exists(Paths.file('images/$charPath.xml')); + var frame:FlxFrame = null; + + if (isAnimated) + { + var frames = Paths.getSparrowAtlas(charPath); + + var idleFrame:FlxFrame = frames.frames.find(function(frame:FlxFrame):Bool { + return frame.name.startsWith('idle'); + }); + + if (idleFrame == null) + { + trace('[WARN] Character ${char} has no idle in their freeplay icon.'); + return null; + } + + // so, haxe.ui.backend.AssetsImpl uses the parent width and height, which makes the image go crazy when rendered + // so this is a work around so that it uses the actual width and height + var imageGraphic = flixel.graphics.FlxGraphic.fromFrame(idleFrame); + + var imageFrame = flixel.graphics.frames.FlxImageFrame.fromImage(imageGraphic); + frame = imageFrame.frame; + } + else + { + var imageFrame = flixel.graphics.frames.FlxImageFrame.fromImage(Paths.image(charPath)); + frame = imageFrame.frame; + } + + return frame; + } + + /** + * Clears the character data cache. + */ + static function clearCharacterCache():Void + { + if (characterCache != null) + { + characterCache.clear(); + } + if (characterScriptedClass != null) + { + characterScriptedClass.clear(); + } + } + + /** + * Load a character's JSON file and parse its data. + * + * @param charId The character to load. + * @return The character data, or null if validation failed. + */ + public static function parseCharacterData(charId:String):Null + { + var rawJson:String = loadCharacterFile(charId); + + var charData:CharacterData = migrateCharacterData(rawJson, charId); + + return validateCharacterData(charId, charData); + } + + static function loadCharacterFile(charPath:String):String + { + var charFilePath:String = Paths.json('characters/${charPath}'); + var rawJson = Assets.getText(charFilePath).trim(); + + while (!StringTools.endsWith(rawJson, '}')) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } + + return rawJson; + } + + static function migrateCharacterData(rawJson:String, charId:String):Null + { + // If you update the character data format in a breaking way, + // handle migration here by checking the `version` value. + + try + { + var charData:CharacterData = cast Json.parse(rawJson); + return charData; + } + catch (e) + { + trace(' Error parsing data for character: ${charId}'); + trace(' ${e}'); + return null; + } + } + + /** + * The default time the character should sing for, in steps. + * Values that are too low will cause the character to stop singing between notes. + * Values that are too high will cause the character to hold their singing pose for too long after they're done. + * @default `8 steps` + */ + public static final DEFAULT_SINGTIME:Float = 8.0; + + public static final DEFAULT_DANCEEVERY:Float = 1.0; + public static final DEFAULT_FLIPX:Bool = false; + public static final DEFAULT_FLIPY:Bool = false; + public static final DEFAULT_FRAMERATE:Int = 24; + public static final DEFAULT_ISPIXEL:Bool = false; + public static final DEFAULT_LOOP:Bool = false; + public static final DEFAULT_NAME:String = 'Untitled Character'; + public static final DEFAULT_OFFSETS:Array = [0, 0]; + public static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25]; + public static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.Sparrow; + public static final DEFAULT_SCALE:Float = 1; + public static final DEFAULT_SCROLL:Array = [0, 0]; + public static final DEFAULT_STARTINGANIM:String = 'idle'; + + /** + * Set unspecified parameters to their defaults. + * If the parameter is mandatory, print an error message. + * @param id + * @param input + * @return The validated character data + */ + static function validateCharacterData(id:String, input:CharacterData):Null + { + if (input == null) + { + trace('ERROR: Could not parse character data for "${id}".'); + return null; + } + + if (input.version == null) + { + trace('WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); + input.version = CHARACTER_DATA_VERSION; + } + + if (!VersionUtil.validateVersionStr(input.version, CHARACTER_DATA_VERSION_RULE)) + { + trace('ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})'); + return null; + } + + if (input.name == null) + { + trace('WARN: Character data for "$id" missing name'); + input.name = DEFAULT_NAME; + } + + if (input.renderType == null) + { + input.renderType = DEFAULT_RENDERTYPE; + } + + if (input.assetPath == null) + { + trace('ERROR: Could not load character data for "$id": missing assetPath'); + return null; + } + + if (input.offsets == null) + { + input.offsets = DEFAULT_OFFSETS; + } + + if (input.cameraOffsets == null) + { + input.cameraOffsets = DEFAULT_OFFSETS; + } + + if (input.healthIcon == null) + { + input.healthIcon = + { + id: null, + scale: null, + flipX: null, + isPixel: null, + offsets: null + }; + } + + if (input.healthIcon.id == null) + { + input.healthIcon.id = id; + } + + if (input.healthIcon.scale == null) + { + input.healthIcon.scale = DEFAULT_SCALE; + } + + if (input.healthIcon.flipX == null) + { + input.healthIcon.flipX = DEFAULT_FLIPX; + } + + if (input.healthIcon.offsets == null) + { + input.healthIcon.offsets = DEFAULT_OFFSETS; + } + + if (input.startingAnimation == null) + { + input.startingAnimation = DEFAULT_STARTINGANIM; + } + + if (input.scale == null) + { + input.scale = DEFAULT_SCALE; + } + + if (input.isPixel == null) + { + input.isPixel = DEFAULT_ISPIXEL; + } + + if (input.healthIcon.isPixel == null) + { + input.healthIcon.isPixel = input.isPixel; + } + + if (input.danceEvery == null) + { + input.danceEvery = DEFAULT_DANCEEVERY; + } + + if (input.singTime == null) + { + input.singTime = DEFAULT_SINGTIME; + } + + if (input.animations == null || input.animations.length == 0) + { + trace('ERROR: Could not load character data for "$id": missing animations'); + input.animations = []; + } + + if (input.flipX == null) + { + input.flipX = DEFAULT_FLIPX; + } + + if (input.animations.length == 0 && input.startingAnimation != null) + { + return null; + } + + for (inputAnimation in input.animations) + { + if (inputAnimation.name == null) + { + trace('ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"'); + return null; + } + + if (inputAnimation.frameRate == null) + { + inputAnimation.frameRate = DEFAULT_FRAMERATE; + } + + if (inputAnimation.offsets == null) + { + inputAnimation.offsets = DEFAULT_OFFSETS; + } + + if (inputAnimation.looped == null) + { + inputAnimation.looped = DEFAULT_LOOP; + } + + if (inputAnimation.flipX == null) + { + inputAnimation.flipX = DEFAULT_FLIPX; + } + + if (inputAnimation.flipY == null) + { + inputAnimation.flipY = DEFAULT_FLIPY; + } + } + + // All good! + return input; + } +} + +/** + * Describes the available rendering types for a character. + */ +enum abstract CharacterRenderType(String) from String to String +{ + /** + * Renders the character using a single spritesheet and XML data. + */ + public var Sparrow = 'sparrow'; + + /** + * Renders the character using a single spritesheet and TXT data. + */ + public var Packer = 'packer'; + + /** + * Renders the character using multiple spritesheets and XML data. + */ + public var MultiSparrow = 'multisparrow'; + + /** + * Renders the character using a spritesheet of symbols and JSON data. + */ + public var AnimateAtlas = 'animateatlas'; + + /** + * Renders the character using a custom method. + */ + public var Custom = 'custom'; +} + +/** + * The JSON data schema used to define a character. + */ +typedef CharacterData = +{ + /** + * The sematic version number of the character data JSON format. + */ + var version:String; + + /** + * The readable name of the character. + */ + var name:String; + + /** + * The type of rendering system to use for the character. + * @default sparrow + */ + var renderType:CharacterRenderType; + + /** + * Behavior varies by render type: + * - SPARROW: Path to retrieve both the spritesheet and the XML data from. + * - PACKER: Path to retrieve both the spritsheet and the TXT data from. + */ + var assetPath:String; + + /** + * The scale of the graphic as a float. + * Pro tip: On pixel-art levels, save the sprites small and set this value to 6 or so to save memory. + * @default 1 + */ + var scale:Null; + + /** + * Optional data about the health icon for the character. + */ + var healthIcon:Null; + + var death:Null; + + /** + * The global offset to the character's position, in pixels. + * @default [0, 0] + */ + var offsets:Null>; + + /** + * The amount to offset the camera by while focusing on this character. + * Default value focuses on the character directly. + * @default [0, 0] + */ + var cameraOffsets:Array; + + /** + * Setting this to true disables anti-aliasing for the character. + * @default false + */ + var isPixel:Null; + + /** + * The frequency at which the character will play its idle animation, in beats. + * Increasing this number will make the character dance less often. + * Supports up to `0.25` precision. + * @default `1.0` on characters + */ + @:optional + @:default(1.0) + var danceEvery:Null; + + /** + * The minimum duration that a character will play a note animation for, in beats. + * If this number is too low, you may see the character start playing the idle animation between notes. + * If this number is too high, you may see the the character play the sing animation for too long after the notes are gone. + * + * Examples: + * - Daddy Dearest uses a value of `1.525`. + * @default 1.0 + */ + var singTime:Null; + + /** + * An optional array of animations which the character can play. + */ + var animations:Array; + + /** + * If animations are used, this is the name of the animation to play first. + * @default idle + */ + var startingAnimation:Null; + + /** + * Whether or not the whole ass sprite is flipped by default. + * Useful for characters that could also be played (Pico) + * + * @default false + */ + var flipX:Null; +}; + +/** + * The JSON data schema used to define the health icon for a character. + */ +typedef HealthIconData = +{ + /** + * The ID to use for the health icon. + * @default The character's ID + */ + var id:Null; + + /** + * The scale of the health icon. + */ + var scale:Null; + + /** + * Whether to flip the health icon horizontally. + * @default false + */ + var flipX:Null; + + /** + * Multiply scale by 6 and disable antialiasing + * @default false + */ + var isPixel:Null; + + /** + * The offset of the health icon, in pixels. + * @default [0, 25] + */ + var offsets:Null>; +} + +typedef DeathData = +{ + /** + * The amount to offset the camera by while focusing on this character as they die. + * Default value focuses on the character's graphic midpoint. + * @default [0, 0] + */ + var ?cameraOffsets:Array; + + /** + * The amount to zoom the camera by while focusing on this character as they die. + * Value is a multiplier of the default camera zoom for the stage. + * @default 1.0 + */ + var ?cameraZoom:Float; + + /** + * Impose a delay between when the character reaches `0` health and when the death animation plays. + * @default 0.0 + */ + var ?preTransitionDelay:Float; +} diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx new file mode 100644 index 00000000..0b97837e --- /dev/null +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -0,0 +1,133 @@ +package funkin.play.character; + +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; +import funkin.play.character.CharacterData.CharacterRenderType; + +/** + * For some characters which use Sparrow atlases, the spritesheets need to be split + * into multiple files. This character renderer concatenates these together into a single sprite. + * + * Examples in base game include BF Holding GF (most of the sprites are in one file + * but the death animation is in a separate file). + * Only example I can think of in mods is Tricky (which has a separate file for each animation). + * + * BaseCharacter has game logic, MultiSparrowCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class MultiSparrowCharacter extends BaseCharacter +{ + public function new(id:String) + { + super(id, CharacterRenderType.MultiSparrow); + } + + override function onCreate(event:ScriptEvent):Void + { + // Display a custom scope for debugging purposes. + #if FEATURE_DEBUG_TRACY + cpp.vm.tracy.TracyProfiler.zoneScoped('MultiSparrowCharacter.create(${this.characterId})'); + #end + + buildSprites(); + super.onCreate(event); + } + + function buildSprites():Void + { + buildSpritesheet(); + buildAnimations(); + + if (_data.isPixel) + { + this.isPixel = true; + this.antialiasing = false; + // pixelPerfectRender = true; + // pixelPerfectPosition = true; + } + else + { + this.isPixel = false; + this.antialiasing = true; + } + } + + function buildSpritesheet():Void + { + trace('Loading assets for Multi-Sparrow character "${characterId}"', flixel.util.FlxColor.fromString("#89CFF0")); + + var assetList = []; + for (anim in _data.animations) + { + if (anim.assetPath != null && !assetList.contains(anim.assetPath)) + { + assetList.push(anim.assetPath); + } + } + + var texture:FlxAtlasFrames = Paths.getSparrowAtlas(_data.assetPath); + + if (texture == null) + { + trace('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}'); + FlxG.log.error('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}'); + return; + } + else + { + trace('Creating multi-sparrow atlas: ${_data.assetPath}'); + texture.parent.destroyOnNoUse = false; + } + + for (asset in assetList) + { + var subTexture:FlxAtlasFrames = Paths.getSparrowAtlas(asset); + // If we don't do this, the unused textures will be removed as soon as they're loaded. + + if (subTexture == null) + { + trace('Multi-Sparrow atlas could not load subtexture: ${asset}'); + } + else + { + trace('Concatenating multi-sparrow atlas: ${asset}'); + subTexture.parent.destroyOnNoUse = false; + } + + texture.addAtlas(subTexture); + } + + this.frames = texture; + this.setScale(_data.scale); + } + + function buildAnimations() + { + trace('[MULTISPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + + // We need to swap to the proper frame collection before adding the animations, I think? + for (anim in _data.animations) + { + FlxAnimationUtil.addAtlasAnimation(this, anim); + + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames = this.animation.getNameList(); + trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } + + public override function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reverse:Bool = false):Void + { + super.playAnimation(name, restart, ignoreOther, reverse); + } +} diff --git a/source/funkin/play/character/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx new file mode 100644 index 00000000..5e23176b --- /dev/null +++ b/source/funkin/play/character/PackerCharacter.hx @@ -0,0 +1,82 @@ +package funkin.play.character; + +import flixel.graphics.frames.FlxFramesCollection; +import funkin.modding.events.ScriptEvent; +import funkin.play.character.CharacterData.CharacterRenderType; +import funkin.util.assets.FlxAnimationUtil; + +/** + * A PackerCharacter is a Character which is rendered by + * displaying an animation derived from a Packer spritesheet file. + */ +class PackerCharacter extends BaseCharacter +{ + public function new(id:String) + { + super(id, CharacterRenderType.Packer); + } + + override function onCreate(event:ScriptEvent):Void + { + // Display a custom scope for debugging purposes. + #if FEATURE_DEBUG_TRACY + cpp.vm.tracy.TracyProfiler.zoneScoped('PackerCharacter.create(${this.characterId})'); + #end + + loadSpritesheet(); + loadAnimations(); + + super.onCreate(event); + } + + function loadSpritesheet():Void + { + trace('Loading assets for Packer character "${characterId}"', flixel.util.FlxColor.fromString("#89CFF0")); + + var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath); + if (tex == null) + { + trace('Could not load Packer sprite: ${_data.assetPath}'); + return; + } + + this.frames = tex; + + if (_data.isPixel) + { + this.isPixel = true; + this.antialiasing = false; + // pixelPerfectRender = true; + // pixelPerfectPosition = true; + } + else + { + this.isPixel = false; + this.antialiasing = true; + } + + this.setScale(_data.scale); + } + + function loadAnimations():Void + { + trace('[PACKERCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); + + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames = this.animation.getNameList(); + trace('[PACKERCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } +} diff --git a/source/funkin/play/character/ScriptedCharacter.hx b/source/funkin/play/character/ScriptedCharacter.hx new file mode 100644 index 00000000..3d9ad7ed --- /dev/null +++ b/source/funkin/play/character/ScriptedCharacter.hx @@ -0,0 +1,43 @@ +package funkin.play.character; + +/** + * A script that can be tied to a BaseCharacter, which persists across states. + * Create a scripted class that extends BaseCharacter to use this. + * Note: Making a scripted class extending BaseCharacter is not recommended. + * Do so ONLY if are handling all the character rendering yourself, + * and can't use one of the built-in render modes. + */ +@:hscriptClass +class ScriptedBaseCharacter extends BaseCharacter implements polymod.hscript.HScriptedClass {} + +/** + * A script that can be tied to a SparrowCharacter, which persists across states. + * Create a scripted class that extends SparrowCharacter, + * then call `super('charId')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedSparrowCharacter extends SparrowCharacter implements polymod.hscript.HScriptedClass {} + +/** + * A script that can be tied to a MultiSparrowCharacter, which persists across states. + * Create a scripted class that extends MultiSparrowCharacter, + * then call `super('charId')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedMultiSparrowCharacter extends MultiSparrowCharacter implements polymod.hscript.HScriptedClass {} + +/** + * A script that can be tied to a PackerCharacter, which persists across states. + * Create a scripted class that extends PackerCharacter, + * then call `super('charId')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedPackerCharacter extends PackerCharacter implements polymod.hscript.HScriptedClass {} + +/** + * A script that can be tied to an AnimateAtlasCharacter, which persists across states. + * Create a scripted class that extends AnimateAtlasCharacter, + * then call `super('charId')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedAnimateAtlasCharacter extends AnimateAtlasCharacter implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx new file mode 100644 index 00000000..ea743c6d --- /dev/null +++ b/source/funkin/play/character/SparrowCharacter.hx @@ -0,0 +1,85 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.play.character.CharacterData.CharacterRenderType; + +/** + * A SparrowCharacter is a Character which is rendered by + * displaying an animation derived from a SparrowV2 atlas spritesheet file. + * + * BaseCharacter has game logic, SparrowCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class SparrowCharacter extends BaseCharacter +{ + public function new(id:String) + { + super(id, CharacterRenderType.Sparrow); + } + + override function onCreate(event:ScriptEvent):Void + { + // Display a custom scope for debugging purposes. + #if FEATURE_DEBUG_TRACY + cpp.vm.tracy.TracyProfiler.zoneScoped('SparrowCharacter.create(${this.characterId})'); + #end + + loadSpritesheet(); + loadAnimations(); + + super.onCreate(event); + } + + function loadSpritesheet() + { + trace('Loading assets for Sparrow character "${characterId}"', flixel.util.FlxColor.fromString("#89CFF0")); + + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); + if (tex == null) + { + trace('Could not load Sparrow sprite: ${_data.assetPath}'); + return; + } + + this.frames = tex; + + if (_data.isPixel) + { + this.isPixel = true; + this.antialiasing = false; + // pixelPerfectRender = true; + // pixelPerfectPosition = true; + } + else + { + this.isPixel = false; + this.antialiasing = true; + } + + this.setScale(_data.scale); + } + + function loadAnimations() + { + trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); + + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames = this.animation.getNameList(); + trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } +} diff --git a/source/funkin/play/components/ClearPercentCounter.hx b/source/funkin/play/components/ClearPercentCounter.hx new file mode 100644 index 00000000..2e3a01a0 --- /dev/null +++ b/source/funkin/play/components/ClearPercentCounter.hx @@ -0,0 +1,135 @@ +package funkin.play.components; + +import funkin.graphics.FunkinSprite; +import funkin.graphics.shaders.PureColor; +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.util.FlxColor; + +/** + * Numerical counters used to display the clear percent. + */ +class ClearPercentCounter extends FlxTypedSpriteGroup +{ + public var curNumber(default, set):Int = 0; + + var numberChanged:Bool = false; + + function set_curNumber(val:Int):Int + { + numberChanged = true; + return curNumber = val; + } + + var small:Bool = false; + var flashShader:PureColor; + + public function new(x:Float, y:Float, startingNumber:Int = 0, small:Bool = false) + { + super(x, y); + + flashShader = new PureColor(FlxColor.WHITE); + flashShader.colorSet = false; + + curNumber = startingNumber; + + this.small = small; + + var clearPercentText:FunkinSprite = FunkinSprite.create(0, 0, 'resultScreen/clearPercent/clearPercentText${small ? 'Small' : ''}'); + clearPercentText.x = small ? 40 : 0; + add(clearPercentText); + + drawNumbers(); + } + + /** + * Make the counter flash turn white or stop being all white. + * @param enabled Whether the counter should be white. + */ + public function flash(enabled:Bool):Void + { + flashShader.colorSet = enabled; + } + + var tmr:Float = 0; + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (numberChanged) drawNumbers(); + } + + function drawNumbers():Void + { + var seperatedScore:Array = []; + var tempCombo:Int = Math.round(curNumber); + + while (tempCombo > 0 && tempCombo != 0) + { + seperatedScore.push(tempCombo % 10); + tempCombo = Math.floor(tempCombo / 10); + } + + if (seperatedScore.length == 0) seperatedScore.push(0); + + seperatedScore.reverse(); + + for (ind => num in seperatedScore) + { + var digitIndex:Int = ind + 1; + // If there's only one digit, move it to the right + // If there's three digits, move them all to the left + var digitOffset = (seperatedScore.length == 1) ? 1 : (seperatedScore.length == 3) ? -1 : 0; + var digitSize = small ? 32 : 72; + var digitHeightOffset = small ? -4 : 0; + + var xPos = (digitIndex - 1 + digitOffset) * (digitSize * this.scale.x); + xPos += small ? -24 : 0; + var yPos = (digitIndex - 1 + digitOffset) * (digitHeightOffset * this.scale.y); + yPos += small ? 0 : 72; + + if (digitIndex >= members.length) + { + // Three digits = LLR because the 1 and 0 won't be the same anyway. + var variant:Bool = (seperatedScore.length == 3) ? (digitIndex >= 2) : (digitIndex >= 1); + // var variant:Bool = (seperatedScore.length % 2 != 0) ? (digitIndex % 2 == 0) : (digitIndex % 2 == 1); + var numb:ClearPercentNumber = new ClearPercentNumber(xPos, yPos, num, variant, this.small); + numb.scale.set(this.scale.x, this.scale.y); + numb.shader = flashShader; + numb.visible = true; + add(numb); + } + else + { + members[digitIndex].animation.play(Std.string(num)); + // Reset the position of the number + members[digitIndex].x = xPos + this.x; + members[digitIndex].y = yPos + this.y; + members[digitIndex].visible = true; + } + } + for (ind in (seperatedScore.length + 1)...(members.length)) + { + members[ind].visible = false; + } + } +} + +class ClearPercentNumber extends FlxSprite +{ + public function new(x:Float, y:Float, digit:Int, variant:Bool, small:Bool) + { + super(x, y); + + frames = Paths.getSparrowAtlas('resultScreen/clearPercent/clearPercentNumber${small ? 'Small' : variant ? 'Right' : 'Left'}'); + + for (i in 0...10) + { + animation.addByPrefix('$i', 'number $i 0', 24, false); + } + + animation.play('$digit'); + updateHitbox(); + } +} diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx new file mode 100644 index 00000000..f6430457 --- /dev/null +++ b/source/funkin/play/components/HealthIcon.hx @@ -0,0 +1,554 @@ +package funkin.play.components; + +import funkin.play.character.CharacterData; +import flixel.FlxSprite; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.graphics.FunkinSprite; +import funkin.util.MathUtil; + +/** + * This is a rework of the health icon with the following changes: + * - The health icon now owns its own state logic. It queries health and updates the sprite itself, + * rather than relying on PlayState to command it. + * - The health icon now supports animations. + * - The health icon will now search for a SparrowV2 (XML) spritesheet, and use that for rendering if it can. + * - If it can't find a spritesheet, it will the old format; a two-frame 300x150 image. + * - If the spritesheet is found, the health icon will attempt to load and use the following animations as appropriate: + * - `idle`, `winning`, `losing`, `toWinning`, `fromWinning`, `toLosing`, `fromLosing` + * - The health icon is now easier to control via scripts. + * - Set `autoUpdate` to false to prevent the health icon from changing its own animations. + * - Once `autoUpdate` is false, you can manually call `playAnimation()` to play a specific animation. + * - i.e. `PlayState.instance.iconP1.playAnimation("losing")` + * - Scripts can also utilize all functionality that a normal FlxSprite would have access to, such as adding supplimental animations. + * - i.e. `PlayState.instance.iconP1.animation.addByPrefix("jumpscare", "jumpscare", 24, false);` + * @author EliteMasterEric + */ +@:nullSafety +class HealthIcon extends FunkinSprite +{ + /** + * The character this icon is representing. + * Setting this variable will automatically update the graphic. + */ + public var characterId(default, set):String = Constants.DEFAULT_HEALTH_ICON; + + /** + * Whether this health icon should automatically update its state based on the character's health. + * Note that turning this off means you have to manually do the following: + * - Boping the icon on the beat. + * - Switching between winning/losing/idle animations. + * - Repositioning the icon as health changes. + */ + public var autoUpdate:Bool = true; + + /** + * Since the `scale` of the sprite dynamically changes over time, + * this value allows you to set a relative scale for the icon. + * @default 1x scale = 150px width and height. + */ + public var size:FlxPoint; + + /** + * Apply the "bop" animation once every X steps. + * Defaults to once per beat. + */ + public var bopEvery:Int = Constants.STEPS_PER_BEAT; + + /** + * The amount, in degrees, to rotate the icon by when boping. + * ERIC NOTE: I experimented with this a bit but ended up turning it off, + * but why not leave it in for the script kiddies? + */ + public var bopAngle:Float = 0.0; + + /** + * The player the health icon is attached to. + */ + var playerId:Int = 0; + + /** + * Whether the sprite is pixel art or not. + */ + public var isPixel(default, set):Bool = false; + + /** + * Whether this is a legacy icon or not. + */ + var isLegacyStyle:Bool = false; + + /** + * At this amount of health, play the Winning animation instead of the idle. + */ + static final WINNING_THRESHOLD:Float = 0.8 * 2; + + /** + * At this amount of health, play the Losing animation instead of the idle. + */ + static final LOSING_THRESHOLD:Float = 0.2 * 2; + + /** + * The maximum health of the player. + */ + static final MAXIMUM_HEALTH:Float = 2; + + /** + * The size of a non-pixel icon when using the legacy format. + * Remember, modern icons can be any size. + */ + public static final HEALTH_ICON_SIZE:Int = 150; + + /** + * The size of a pixel icon when using the legacy format. + * Remember, modern icons can be any size. + */ + static final PIXEL_ICON_SIZE:Int = 32; + + /** + * The amount, in percent, to scale the icon by when bopping. + */ + static final BOP_SCALE:Float = 0.2; + + /** + * shitty hardcoded value for a specific positioning!!! + */ + static final POSITION_OFFSET:Int = 26; + + public function new(char:Null, playerId:Int = 0) + { + super(0, 0); + this.playerId = playerId; + this.size = new FlxCallbackPoint(onSetSize); + this.scrollFactor.set(); + size.set(1.0, 1.0); + this.characterId = char; + } + + function onSetSize(value:FlxPoint):Void + { + snapToTargetSize(); + } + + function set_characterId(value:Null):String + { + if (value == characterId) return value; + + characterId = value ?? Constants.DEFAULT_HEALTH_ICON; + return characterId; + } + + function set_isPixel(value:Bool):Bool + { + if (value == isPixel) return value; + + isPixel = value; + return isPixel; + } + + /** + * Easter egg; press 9 in the PlayState to use the old player icon. + */ + public function toggleOldIcon():Void + { + if (characterId == 'bf-old') + { + isPixel = PlayState.instance.currentStage.getBoyfriend().isPixel; + PlayState.instance.currentStage.getBoyfriend().initHealthIcon(false); + } + else + { + characterId = 'bf-old'; + isPixel = false; + loadCharacter(characterId); + } + + lerpIconSize(true); + } + + /** + * Use the provided CharacterHealthIconData to configure this health icon's appearance. + * @param data The data to use to configure this health icon. + */ + public function configure(data:Null):Void + { + if (data == null) + { + this.characterId = Constants.DEFAULT_HEALTH_ICON; + this.isPixel = false; + + loadCharacter(characterId); + + this.size.set(1.0, 1.0); + this.offset.x = 0.0; + this.offset.y = 0.0; + this.flipX = false; + } + else + { + this.characterId = data.id; + this.isPixel = data.isPixel ?? false; + + loadCharacter(characterId); + + this.size.set(data.scale ?? 1.0, data.scale ?? 1.0); + this.offset.x = (data.offsets != null) ? data.offsets[0] : 0.0; + this.offset.y = (data.offsets != null) ? data.offsets[1] : 0.0; + this.flipX = data.flipX ?? false; // Face the OTHER way by default, since that is more common. + } + } + + /** + * Called by Flixel every frame. Includes logic to manage the currently playing animation. + */ + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (bopEvery != 0) + { + lerpIconSize(); + + // Lerp the health icon back to its normal angle. + this.angle = MathUtil.coolLerp(this.angle, 0, 0.15); + } + + this.updatePosition(); + } + + /** + * Does the calculation to lerp the icon size. Usually called every frame, but can be forced to the target size. + * Mainly forced when changing to old icon to not have a weird lerp related to changing from pixel icon to non-pixel old icon + * @param force Force the icon immedialtely to be the target size. Defaults to false. + */ + function lerpIconSize(force:Bool = false):Void + { + // Lerp the health icon back to its normal size, + // while maintaining aspect ratio. + if (this.width > this.height) + { + // Apply linear interpolation while accounting for frame rate. + var targetSize:Int = Std.int(MathUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15)); + + if (force) targetSize = Std.int(HEALTH_ICON_SIZE * this.size.x); + + setGraphicSize(targetSize, 0); + } + else + { + var targetSize:Int = Std.int(MathUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15)); + + if (force) targetSize = Std.int(HEALTH_ICON_SIZE * this.size.y); + + setGraphicSize(0, targetSize); + } + + this.updateHitbox(); + } + + /* + * Immediately snap the health icon to its target size without lerping. + */ + public function snapToTargetSize():Void + { + if (this.width > this.height) + { + setGraphicSize(Std.int(HEALTH_ICON_SIZE * this.size.x), 0); + } + else + { + setGraphicSize(0, Std.int(HEALTH_ICON_SIZE * this.size.y)); + } + updateHitbox(); + } + + /** + * Update the position (and status) of the health icon. + */ + public function updatePosition():Void + { + // Make sure autoUpdate is false if the health icon is not being used in the PlayState. + if (autoUpdate && PlayState.instance != null) + { + switch (playerId) + { + case 0: // Boyfriend + // Update the animation based on the current state. + updateHealthIcon(PlayState.instance.health); + // Update the position to match the health bar. + this.x = PlayState.instance.healthBar.x + + (PlayState.instance.healthBar.width * (FlxMath.remapToRange(PlayState.instance.healthBar.value, 0, 2, 100, 0) * 0.01) - POSITION_OFFSET); + case 1: // Dad + // Update the animation based on the current state. + updateHealthIcon(MAXIMUM_HEALTH - PlayState.instance.health); + // Update the position to match the health bar. + this.x = PlayState.instance.healthBar.x + + (PlayState.instance.healthBar.width * (FlxMath.remapToRange(PlayState.instance.healthBar.value, 0, 2, 100, 0) * 0.01)) + - (this.width - POSITION_OFFSET); + } + + // Keep the icon centered vertically on the health bar. + this.y = PlayState.instance.healthBar.y - (this.height / 2); // - (PlayState.instance.healthBar.height / 2) + } + } + + /** + * Called on every step. + * @param curStep The current step number. + */ + public function onStepHit(curStep:Int):Void + { + // Make the icons bop. + if (bopEvery != 0 && curStep % bopEvery == 0 && isLegacyStyle) + { + // Make the icon increase in size (the update function causes them to lerp back down). + if (this.width > this.height) + { + setGraphicSize(Std.int(this.width + (HEALTH_ICON_SIZE * this.size.x * BOP_SCALE)), 0); + } + else + { + setGraphicSize(0, Std.int(this.height + (HEALTH_ICON_SIZE * this.size.y * BOP_SCALE))); + } + // Make the icon twist (the update function causes them to lerp back to normal). + this.angle += bopAngle * (playerId == 0 ? 1 : -1); + + this.updateHitbox(); + + // Ensure the icon is positioned correctly after updating the hitbox. + this.updatePosition(); + } + } + + function updateHealthIcon(health:Float):Void + { + // We want to efficiently handle animation playback + + // Here, we use the current animation name to track the current state + // of a simple state machine. Neat! + + switch (getCurrentAnimation()) + { + case Idle: + if (health < LOSING_THRESHOLD) + { + playAnimation(ToLosing, Losing); + } + else if (health > WINNING_THRESHOLD) + { + playAnimation(ToWinning, Winning); + } + else + { + playAnimation(Idle); + } + case Winning: + if (health < WINNING_THRESHOLD) + { + playAnimation(FromWinning, Idle); + } + else + { + playAnimation(Winning, Idle); + } + case Losing: + if (health > LOSING_THRESHOLD) playAnimation(FromLosing, Idle); + else + { + playAnimation(Losing, Idle); + } + case ToLosing: + if (isAnimationFinished()) + { + playAnimation(Losing, Idle); + } + case ToWinning: + if (isAnimationFinished()) + { + playAnimation(Winning, Idle); + } + case FromLosing | FromWinning: + if (isAnimationFinished()) + { + playAnimation(Idle); + } + case '': + playAnimation(Idle); + default: + playAnimation(Idle); + } + } + + /** + * Load health icon animations from a Sparrow XML file (the kind used by characters) + * Note that this is looking for SPECIFIC animation names, so you may need to modify the XML. + * @param charId + */ + function loadAnimationNew():Void + { + this.animation.addByPrefix(Idle, Idle, 24, true); + this.animation.addByPrefix(Winning, Winning, 24, true); + this.animation.addByPrefix(Losing, Losing, 24, true); + this.animation.addByPrefix(ToWinning, ToWinning, 24, false); + this.animation.addByPrefix(ToLosing, ToLosing, 24, false); + this.animation.addByPrefix(FromWinning, FromWinning, 24, false); + this.animation.addByPrefix(FromLosing, FromLosing, 24, false); + } + + /** + * Load health icon animations using the legacy format. + * Simply assumes two icons, the idle and losing icons. + * @param charId + */ + function loadAnimationOld():Void + { + // Don't flip BF's icon here! That's done later. + this.animation.add(Idle, [0], 0, false, false); + this.animation.add(Losing, [1], 0, false, false); + if (animation.numFrames >= 3) + { + this.animation.add(Winning, [2], 0, false, false); + } + } + + function iconExists(charId:String):Bool + { + return Assets.exists(Paths.image('icons/icon-$charId')); + } + + function isNewSpritesheet(charId:String):Bool + { + return Assets.exists(Paths.file('images/icons/icon-$characterId.xml')); + } + + function loadCharacter(charId:Null):Void + { + if (charId == null || !iconExists(charId)) + { + FlxG.log.warn('No icon for character: $charId : using default placeholder face instead!'); + characterId = Constants.DEFAULT_HEALTH_ICON; + charId = characterId; + } + + isLegacyStyle = !isNewSpritesheet(charId); + + trace(' Loading health icon for character: $charId (legacy: $isLegacyStyle)'); + + if (!isLegacyStyle) + { + loadSparrow('icons/icon-$charId'); + + loadAnimationNew(); + } + else + { + loadGraphic(Paths.image('icons/icon-$charId'), true, isPixel ? PIXEL_ICON_SIZE : HEALTH_ICON_SIZE, isPixel ? PIXEL_ICON_SIZE : HEALTH_ICON_SIZE); + + loadAnimationOld(); + } + + this.antialiasing = !isPixel; + } + + /** + * @return Name of the current animation being played by this health icon. + */ + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + /** + * @param id The name of the animation to check for. + * @return Whether this sprite posesses the given animation. + * Only true if the animation was successfully loaded from the XML. + */ + public function hasAnimation(id:String):Bool + { + if (this.animation == null) return false; + + return this.animation.getByName(id) != null; + } + + /** + * @return Whether the current animation is in the finished state. + */ + public function isAnimationFinished():Bool + { + return this.animation.finished; + } + + /** + * Plays the animation with the given name. + * @param name The name of the animation to play. + * @param fallback The fallback animation to play if the given animation is not found. + * @param restart Whether to forcibly restart the animation if it is already playing. + */ + public function playAnimation(name:String, fallback:String = null, restart = false):Void + { + // Attempt to play the animation + if (hasAnimation(name)) + { + this.animation.play(name, restart, false, 0); + return; + } + + // Play the fallback animation if the requested animation was not found + if (fallback != null && hasAnimation(fallback)) + { + this.animation.play(fallback, restart, false, 0); + return; + } + + // If we don't have an animation, we're done. + } +} + +/** + * The current state of the health + */ +enum abstract HealthIconState(String) to String from String +{ + /** + * Indicates the health icon is in the default animation. + * Plays as long as health is between 20% and 80%. + */ + public var Idle = 'idle'; + + /** + * Indicates the health icon is playing the Winning animation. + * Plays as long as health is above 80%. + */ + public var Winning = 'winning'; + + /** + * Indicates the health icon is playing the Losing animation. + * Plays as long as health is below 20%. + */ + public var Losing = 'losing'; + + /** + * Indicates that the health icon is transitioning between `idle` and `winning`. + * The next animation will play once the current animation finishes. + */ + public var ToWinning = 'toWinning'; + + /** + * Indicates that the health icon is transitioning between `idle` and `losing`. + * The next animation will play once the current animation finishes. + */ + public var ToLosing = 'toLosing'; + + /** + * Indicates that the health icon is transitioning between `winning` and `idle`. + * The next animation will play once the current animation finishes. + */ + public var FromWinning = 'fromWinning'; + + /** + * Indicates that the health icon is transitioning between `losing` and `idle`. + * The next animation will play once the current animation finishes. + */ + public var FromLosing = 'fromLosing'; +} diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx new file mode 100644 index 00000000..07318f68 --- /dev/null +++ b/source/funkin/play/components/PopUpStuff.hx @@ -0,0 +1,121 @@ +package funkin.play.components; + +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.tweens.FlxTween; +import funkin.graphics.FunkinSprite; +import funkin.util.EaseUtil; +import funkin.play.notes.notestyle.NoteStyle; + +@:nullSafety +class PopUpStuff extends FlxTypedGroup +{ + /** + * The current note style to use. This determines which graphics to display. + * For example, Week 6 uses the `pixel` note style, and mods can create their own. + */ + var noteStyle:NoteStyle; + + /** + * Offsets that are applied to all elements, independent of the note style. + * Used to allow scripts to reposition the elements. + */ + var offsets:Array = [0, 0]; + + override public function new(noteStyle:NoteStyle) + { + super(); + + this.noteStyle = noteStyle; + } + + public function displayRating(daRating:Null) + { + if (daRating == null) daRating = "good"; + + var rating:Null = noteStyle.buildJudgementSprite(daRating); + if (rating == null) return; + + rating.zIndex = 1000; + + rating.x = (FlxG.width * 0.474); + rating.x -= rating.width / 2; + rating.y = (FlxG.camera.height * 0.45 - 60); + rating.y -= rating.height / 2; + + rating.x += offsets[0]; + rating.y += offsets[1]; + var styleOffsets = noteStyle.getJudgementSpriteOffsets(daRating); + rating.x += styleOffsets[0]; + rating.y += styleOffsets[1]; + + rating.acceleration.y = 550; + rating.velocity.y -= FlxG.random.int(140, 175); + rating.velocity.x -= FlxG.random.int(0, 10); + + add(rating); + + var fadeEase = noteStyle.isJudgementSpritePixel(daRating) ? EaseUtil.stepped(2) : null; + + FlxTween.tween(rating, {alpha: 0}, 0.2, + { + onComplete: function(tween:FlxTween) { + remove(rating, true); + rating.destroy(); + }, + startDelay: Conductor.instance.beatLengthMs * 0.001, + ease: fadeEase + }); + } + + public function displayCombo(combo:Int = 0):Void + { + var seperatedScore:Array = []; + var tempCombo:Int = combo; + + while (tempCombo != 0) + { + seperatedScore.push(tempCombo % 10); + tempCombo = Std.int(tempCombo / 10); + } + while (seperatedScore.length < 3) + seperatedScore.push(0); + + // seperatedScore.reverse(); + + var daLoop:Int = 1; + for (digit in seperatedScore) + { + var numScore:Null = noteStyle.buildComboNumSprite(digit); + if (numScore == null) continue; + + numScore.x = (FlxG.width * 0.507) - (36 * daLoop) - 65; + numScore.y = (FlxG.camera.height * 0.44); + + numScore.x += offsets[0]; + numScore.y += offsets[1]; + var styleOffsets = noteStyle.getComboNumSpriteOffsets(digit); + numScore.x += styleOffsets[0]; + numScore.y += styleOffsets[1]; + + numScore.acceleration.y = FlxG.random.int(250, 300); + numScore.velocity.y -= FlxG.random.int(130, 150); + numScore.velocity.x = FlxG.random.float(-5, 5); + + add(numScore); + + var fadeEase = noteStyle.isComboNumSpritePixel(digit) ? EaseUtil.stepped(2) : null; + + FlxTween.tween(numScore, {alpha: 0}, 0.2, + { + onComplete: function(tween:FlxTween) { + remove(numScore, true); + numScore.destroy(); + }, + startDelay: Conductor.instance.beatLengthMs * 0.002, + ease: fadeEase + }); + + daLoop++; + } + } +} diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx new file mode 100644 index 00000000..417e6136 --- /dev/null +++ b/source/funkin/play/components/TallyCounter.hx @@ -0,0 +1,98 @@ +package funkin.play.components; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.text.FlxText.FlxTextAlign; +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; + public var neededNumber:Int = 0; + + public var flavour:Int = 0xFFFFFFFF; + + public var 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 ?? 0xFFFFFFFF; + + this.neededNumber = neededNumber; + + if (curNumber == neededNumber) drawNumbers(); + } + + var tmr:Float = 0; + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (curNumber < neededNumber) drawNumbers(); + } + + function drawNumbers() + { + var seperatedScore:Array = []; + var tempCombo:Int = Math.round(curNumber); + + var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber)))); + + while (tempCombo != 0) + { + seperatedScore.push(tempCombo % 10); + tempCombo = Math.floor(tempCombo / 10); + } + + if (seperatedScore.length == 0) seperatedScore.push(0); + + seperatedScore.reverse(); + + for (ind => num in seperatedScore) + { + if (ind >= members.length) + { + var xPos = ind * (43 * this.scale.x); + if (this.align == FlxTextAlign.RIGHT) + { + xPos -= (fullNumberDigits * (43 * this.scale.x)); + } + var numb:TallyNumber = new TallyNumber(xPos, 0, num); + numb.scale.set(this.scale.x, this.scale.y); + add(numb); + numb.color = flavour; + } + else + { + members[ind].animation.play(Std.string(num)); + members[ind].color = flavour; + } + } + } +} + +@:nullSafety +class TallyNumber extends FlxSprite +{ + public function new(x:Float, y:Float, digit:Int) + { + super(x, y); + + frames = Paths.getSparrowAtlas("resultScreen/tallieNumber"); + + for (i in 0...10) + animation.addByPrefix(Std.string(i), i + " small", 24, false); + + animation.play(Std.string(digit)); + updateHitbox(); + } +} diff --git a/source/funkin/play/cutscene/VanillaCutscenes.hx b/source/funkin/play/cutscene/VanillaCutscenes.hx new file mode 100644 index 00000000..7a4349e6 --- /dev/null +++ b/source/funkin/play/cutscene/VanillaCutscenes.hx @@ -0,0 +1,58 @@ +package funkin.play.cutscene; + +import flixel.FlxSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import flixel.util.FlxTimer; + +/** + * Static methods for playing cutscenes in the PlayState. + * TODO: Un-hardcode this shit!!!!!1! + */ +class VanillaCutscenes +{ + static var blackScreen:FlxSprite; + + static final TWEEN_DURATION:Float = 2.0; + + /** + * Plays the cutscene that appears at the start of Winter Horrorland. + * TODO: Move this to `winter-horrorland.hxc` + */ + public static function playHorrorStartCutscene():Void + { + PlayState.instance.isInCutscene = true; + PlayState.instance.camHUD.visible = false; + + blackScreen = new FlxSprite(-200, -200).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); + blackScreen.scrollFactor.set(0, 0); + blackScreen.zIndex = 1000000; + PlayState.instance.add(blackScreen); + + new FlxTimer().start(0.1, function(_) { + trace('Playing horrorland cutscene...'); + PlayState.instance.remove(blackScreen); + + // Force set the camera position and zoom. + PlayState.instance.cameraFollowPoint.setPosition(400, -2050); + PlayState.instance.resetCamera(); + FlxG.camera.zoom = 2.5; + + // Play the Sound effect. + FunkinSound.playOnce(Paths.sound('Lights_Turn_On'), function() { + // Fade in the HUD. + trace('SFX done...'); + PlayState.instance.camHUD.visible = true; + PlayState.instance.camHUD.alpha = 0.0; // Use alpha rather than visible to let us fade it in. + FlxTween.tween(PlayState.instance.camHUD, {alpha: 1.0}, TWEEN_DURATION, {ease: FlxEase.quadInOut}); + + // Start the countdown. + trace('Zoom out done...'); + trace('Begin countdown (ends cutscene)'); + PlayState.instance.startCountdown(); + }); + }); + } +} diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx new file mode 100644 index 00000000..ab22119d --- /dev/null +++ b/source/funkin/play/cutscene/VideoCutscene.hx @@ -0,0 +1,351 @@ +package funkin.play.cutscene; + +import funkin.play.PlayState; +import flixel.FlxSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSignal; +#if html5 +import funkin.graphics.video.FlxVideo; +#end +#if hxvlc +import funkin.graphics.video.FunkinVideoSprite; +#end + +/** + * Assumes you are in the PlayState. + */ +class VideoCutscene +{ + static var blackScreen:FlxSprite; + static var cutsceneType:CutsceneType; + + #if html5 + static var vid:FlxVideo; + #end + #if hxvlc + static var vid:FunkinVideoSprite; + #end + + /** + * Called when the video is started. + */ + public static final onVideoStarted:FlxSignal = new FlxSignal(); + + /** + * Called if the video is paused. + */ + public static final onVideoPaused:FlxSignal = new FlxSignal(); + + /** + * Called if the video is resumed. + */ + public static final onVideoResumed:FlxSignal = new FlxSignal(); + + /** + * Called if the video is restarted. onVideoStarted is not called. + */ + public static final onVideoRestarted:FlxSignal = new FlxSignal(); + + /** + * Called when the video is ended or skipped. + */ + public static final onVideoEnded:FlxSignal = new FlxSignal(); + + /** + * Play a video cutscene. + * TODO: Currently this is hardcoded to start the countdown after the video is done. + * @param path The path to the video file. Use Paths.file(path) to get the correct path. + * @param cutseneType The type of cutscene to play, determines what the game does after. Defaults to `CutsceneType.STARTING`. + */ + public static function play(filePath:String, ?cutsceneType:CutsceneType = STARTING):Void + { + if (PlayState.instance == null) return; + + if (!openfl.Assets.exists(filePath)) + { + // Display a popup. + // lime.app.Application.current.window.alert('Video file does not exist: ${filePath}', 'Error playing video'); + // return; + + // TODO: After moving videos to their own library, + // this function ALWAYS FAILS on web, but the video still plays. + // I think that's due to a weird quirk with how OpenFL libraries work. + trace('Video file does not exist: ${filePath}'); + } + + var rawFilePath = Paths.stripLibrary(filePath); + + // Trigger the cutscene. Don't play the song in the background. + PlayState.instance.isInCutscene = true; + PlayState.instance.camHUD.visible = false; + + // Display a black screen to hide the game while the video is playing. + blackScreen = new FlxSprite(-200, -200).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK); + blackScreen.scrollFactor.set(0, 0); + blackScreen.cameras = [PlayState.instance.camCutscene]; + PlayState.instance.add(blackScreen); + + VideoCutscene.cutsceneType = cutsceneType; + + #if html5 + playVideoHTML5(rawFilePath); + #elseif hxvlc + playVideoNative(filePath); + #else + throw "No video support for this platform!"; + #end + } + + public static function isPlaying():Bool + { + #if (html5 || hxvlc) + return vid != null; + #else + return false; + #end + } + + #if html5 + static function playVideoHTML5(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FlxVideo(filePath); + if (vid != null) + { + vid.zIndex = 0; + + vid.finishCallback = finishVideo.bind(0.5); + + vid.cameras = [PlayState.instance.camCutscene]; + + PlayState.instance.add(vid); + + PlayState.instance.refresh(); + + onVideoStarted.dispatch(); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + #if hxvlc + static function playVideoNative(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FunkinVideoSprite(0, 0); + + if (vid != null) + { + vid.zIndex = 0; + vid.bitmap.onEndReached.add(finishVideo.bind(0.5)); + + vid.cameras = [PlayState.instance.camCutscene]; + + PlayState.instance.add(vid); + + PlayState.instance.refresh(); + + if (vid.load(filePath)) vid.play(); + + // Resize videos bigger or smaller than the screen. + vid.bitmap.onFormatSetup.add(function():Void { + if (vid == null) return; + vid.setGraphicSize(FlxG.width, FlxG.height); + vid.updateHitbox(); + vid.x = 0; + vid.y = 0; + // vid.scale.set(0.5, 0.5); + }); + + onVideoStarted.dispatch(); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + public static function restartVideo(resume:Bool = true):Void + { + #if html5 + if (vid != null) + { + vid.restartVideo(); + onVideoRestarted.dispatch(); + } + #end + + #if hxvlc + if (vid != null) + { + // Seek to the start of the video. + vid.bitmap.time = 0; + if (resume) + { + // Resume the video if it was paused. + vid.resume(); + } + + onVideoRestarted.dispatch(); + } + #end + } + + public static function pauseVideo():Void + { + #if html5 + if (vid != null) + { + vid.pauseVideo(); + onVideoPaused.dispatch(); + } + #end + + #if hxvlc + if (vid != null) + { + vid.pause(); + onVideoPaused.dispatch(); + } + #end + } + + public static function hideVideo():Void + { + #if html5 + if (vid != null) + { + vid.visible = false; + blackScreen.visible = false; + } + #end + + #if hxvlc + if (vid != null) + { + vid.visible = false; + blackScreen.visible = false; + } + #end + } + + public static function showVideo():Void + { + #if html5 + if (vid != null) + { + vid.visible = true; + blackScreen.visible = false; + } + #end + + #if hxvlc + if (vid != null) + { + vid.visible = true; + blackScreen.visible = false; + } + #end + } + + public static function resumeVideo():Void + { + #if html5 + if (vid != null) + { + vid.resumeVideo(); + onVideoResumed.dispatch(); + } + #end + + #if hxvlc + if (vid != null) + { + vid.resume(); + onVideoResumed.dispatch(); + } + #end + } + + /** + * Finish the active video cutscene. Done when the video is finished or when the player skips the cutscene. + * @param transitionTime The duration of the transition to the next state. Defaults to 0.5 seconds (this time is always used when cancelling the video). + * @param finishCutscene The callback to call when the transition is finished. + */ + public static function finishVideo(?transitionTime:Float = 0.5):Void + { + trace('ALERT: Finish video cutscene called!'); + + var cutsceneType:CutsceneType = VideoCutscene.cutsceneType; + + #if html5 + if (vid != null) + { + PlayState.instance.remove(vid); + } + #end + + #if hxvlc + if (vid != null) + { + vid.stop(); + PlayState.instance.remove(vid); + } + #end + + #if (html5 || hxvlc) + vid.destroy(); + vid = null; + #end + + PlayState.instance.camHUD.visible = true; + + FlxTween.tween(blackScreen, {alpha: 0}, transitionTime, + { + ease: FlxEase.quadInOut, + onComplete: function(twn:FlxTween) { + PlayState.instance.remove(blackScreen); + blackScreen = null; + } + }); + FlxTween.tween(FlxG.camera, {zoom: PlayState.instance.stageZoom}, transitionTime, + { + ease: FlxEase.quadInOut, + onComplete: function(twn:FlxTween) { + onVideoEnded.dispatch(); + onCutsceneFinish(cutsceneType); + } + }); + } + + /** + * The default callback used when a cutscene is finished. + * You can specify your own callback when calling `VideoCutscene#play()`. + */ + static function onCutsceneFinish(cutsceneType:CutsceneType):Void + { + switch (cutsceneType) + { + case CutsceneType.STARTING: + PlayState.instance.startCountdown(); + case CutsceneType.ENDING: + PlayState.instance.endSong(true); // true = right goddamn now + case CutsceneType.MIDSONG: + // Do nothing. + // throw "Not implemented!"; + } + } +} + +enum CutsceneType +{ + STARTING; // The default cutscene type. Starts the countdown after the video is done. + MIDSONG; // Does nothing. + ENDING; // Ends the song after the video is done. +} diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx new file mode 100644 index 00000000..a94de8f9 --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/Conversation.hx @@ -0,0 +1,680 @@ +package funkin.play.cutscene.dialogue; + +import flixel.group.FlxSpriteGroup; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSort; +import funkin.audio.FunkinSound; +import funkin.data.dialogue.conversation.ConversationData; +import funkin.data.dialogue.conversation.ConversationData.DialogueEntryData; +import funkin.data.dialogue.conversation.ConversationRegistry; +import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry; +import funkin.data.dialogue.speaker.SpeakerRegistry; +import funkin.data.IRegistryEntry; +import funkin.graphics.FunkinSprite; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.modding.IScriptedClass.IDialogueScriptedClass; +import funkin.modding.IScriptedClass.IEventHandler; +import funkin.util.SortUtil; +import funkin.util.EaseUtil; + +/** + * A high-level handler for dialogue. + * + * This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric + */ +@:nullSafety +class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry +{ + /** + * The current state of the conversation. + */ + var state:ConversationState = ConversationState.Start; + + /** + * The current entry in the dialogue. + */ + var currentDialogueEntry:Int = 0; + + var currentDialogueEntryCount(get, never):Int; + + function get_currentDialogueEntryCount():Int + { + return _data?.dialogue?.length ?? 0; + } + + /** + * The current line in the current entry in the dialogue. + * **/ + var currentDialogueLine:Int = 0; + + var currentDialogueLineCount(get, never):Int; + + function get_currentDialogueLineCount():Int + { + return currentDialogueEntryData?.text?.length ?? 0; + } + + var currentDialogueEntryData(get, never):Null; + + function get_currentDialogueEntryData():Null + { + if (_data == null || _data.dialogue == null) return null; + if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null; + + return _data.dialogue[currentDialogueEntry]; + } + + var currentDialogueLineString(get, never):String; + + function get_currentDialogueLineString():String + { + // TODO: Replace "" with some placeholder text? + return currentDialogueEntryData?.text[currentDialogueLine] ?? ""; + } + + /** + * AUDIO + */ + var music:Null; + + /** + * GRAPHICS + */ + var backdrop:Null; + + var currentSpeaker:Null; + + var currentDialogueBox:Null; + + public function new(id:String) + { + super(); + + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse conversation data for id: $id'; + } + } + + public function onCreate(event:ScriptEvent):Void + { + // Reset the progress in the dialogue. + currentDialogueEntry = 0; + currentDialogueLine = 0; + this.state = ConversationState.Start; + + // Start the dialogue. + dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, false)); + } + + function setupMusic():Void + { + if (_data == null) return; + + if (_data.music == null || (_data.music.asset ?? "") == "") return; + + music = FunkinSound.load(Paths.music(_data.music.asset), 0.0, true, true, true); + var fadeTime:Float = _data.music.fadeTime ?? 0.0; + + if (fadeTime > 0.0) + { + FlxTween.tween(music, {volume: 1.0}, fadeTime, {ease: FlxEase.linear}); + } + else + { + if (music != null) + { + music.volume = 1.0; + } + } + } + + public function pauseMusic():Void + { + if (music != null) + { + music.pause(); + } + } + + public function resumeMusic():Void + { + if (music != null) + { + music.resume(); + } + } + + function setupBackdrop():Void + { + if (_data == null) return; + + if (backdrop != null) + { + backdrop.destroy(); + remove(backdrop); + backdrop = null; + } + + backdrop = new FunkinSprite(0, 0); + + if (_data.backdrop == null) return; + + // Play intro + switch (_data.backdrop) + { + case SOLID(backdropData): + var targetColor:Null = FlxColor.fromString(backdropData.color); + backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor); + var fadeTime = backdropData.fadeTime ?? 0.0; + if (fadeTime > 0.0) + { + backdrop.alpha = 0.0; + FlxTween.tween(backdrop, {alpha: 1.0}, fadeTime, {ease: EaseUtil.stepped(10)}); + } + else + { + backdrop.alpha = 1.0; + } + default: + return; + } + + backdrop.zIndex = 10; + add(backdrop); + refresh(); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + dispatchEvent(new UpdateScriptEvent(elapsed)); + } + + function showCurrentSpeaker():Void + { + var nextSpeakerId:String = currentDialogueEntryData?.speaker ?? ""; + + // Skip the next steps if the current speaker is already displayed. + if ((currentSpeaker != null && currentSpeaker.alive) && nextSpeakerId == currentSpeaker.id) return; + + if (currentSpeaker != null) + { + remove(currentSpeaker); + currentSpeaker.kill(); // Kill, don't destroy! We want to revive it later. + currentSpeaker = null; + } + + var nextSpeaker:Null = SpeakerRegistry.instance.fetchEntry(nextSpeakerId); + + if (nextSpeaker == null) + { + if (nextSpeakerId == null) + { + trace('Dialogue entry has no speaker.'); + } + else + { + trace('Speaker could not be retrieved.'); + } + return; + } + if (!nextSpeaker.alive) nextSpeaker.revive(); + + ScriptEventDispatcher.callEvent(nextSpeaker, new ScriptEvent(CREATE, true)); + + currentSpeaker = nextSpeaker; + currentSpeaker.zIndex = 200; + add(currentSpeaker); + refresh(); + } + + function playSpeakerAnimation():Void + { + var nextSpeakerAnimation:Null = currentDialogueEntryData?.speakerAnimation; + + if (nextSpeakerAnimation == null) return; + + if (currentSpeaker != null) currentSpeaker.playAnimation(nextSpeakerAnimation); + } + + public function refresh():Void + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + function showCurrentDialogueBox():Void + { + var nextDialogueBoxId:String = currentDialogueEntryData?.box ?? ""; + + // Skip the next steps if the current dialogue box is already displayed. + if ((currentDialogueBox != null && currentDialogueBox.alive) && nextDialogueBoxId == currentDialogueBox.id) return; + + if (currentDialogueBox != null) + { + remove(currentDialogueBox); + currentDialogueBox.kill(); // Kill, don't destroy! We want to revive it later. + currentDialogueBox = null; + } + + var nextDialogueBox:Null = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId); + + if (nextDialogueBox == null) + { + trace('Dialogue box could not be retrieved.'); + return; + } + if (!nextDialogueBox.alive) nextDialogueBox.revive(); + + ScriptEventDispatcher.callEvent(nextDialogueBox, new ScriptEvent(CREATE, true)); + + currentDialogueBox = nextDialogueBox; + currentDialogueBox.zIndex = 300; + + currentDialogueBox.typingCompleteCallback = this.onTypingComplete; + + add(currentDialogueBox); + refresh(); + } + + function playDialogueBoxAnimation():Void + { + var nextDialogueBoxAnimation:Null = currentDialogueEntryData?.boxAnimation; + + if (nextDialogueBoxAnimation == null) return; + + if (currentDialogueBox != null) currentDialogueBox.playAnimation(nextDialogueBoxAnimation); + } + + function onTypingComplete():Void + { + if (this.state == ConversationState.Speaking) + { + this.state = ConversationState.Idle; + } + else + { + trace('[WARNING] Unexpected state transition from ${this.state}'); + this.state = ConversationState.Idle; + } + } + + public function startConversation():Void + { + dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, true)); + } + + /** + * Dispatch an event to attempt to advance the conversation. + * This is done once at the start of the conversation, and once whenever the user presses CONFIRM to advance the conversation. + * + * The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from actually advancing. + * This is useful if you want to manually play an animation or something. + */ + public function advanceConversation():Void + { + switch (state) + { + case ConversationState.Start: + dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, true)); + case ConversationState.Opening: + dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true)); + case ConversationState.Speaking: + dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true)); + case ConversationState.Idle: + dispatchEvent(new DialogueScriptEvent(DIALOGUE_LINE, this, true)); + case ConversationState.Ending: + // Skip the outro. + endOutro(); + default: + // Do nothing. + } + } + + public function dispatchEvent(event:ScriptEvent):Void + { + var currentState:IEventHandler = cast FlxG.state; + currentState.dispatchEvent(event); + } + + /** + * Reset the conversation back to the start. + */ + public function resetConversation():Void + { + // Reset the progress in the dialogue. + currentDialogueEntry = 0; + this.state = ConversationState.Start; + + if (outroTween != null) + { + outroTween.cancel(); + } + outroTween = null; + + if (this.music != null) + { + this.music.stop(); + this.music = null; + } + + if (currentSpeaker != null) + { + currentSpeaker.kill(); + remove(currentSpeaker); + currentSpeaker = null; + } + + if (currentDialogueBox != null) + { + currentDialogueBox.kill(); + remove(currentDialogueBox); + currentDialogueBox = null; + } + + if (backdrop != null) + { + backdrop.destroy(); + remove(backdrop); + backdrop = null; + } + + startConversation(); + } + + /** + * Dispatch an event to attempt to immediately end the conversation. + * + * The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from being cancelled. + * This is useful if you want to prevent an animation from being skipped or something. + */ + public function skipConversation():Void + { + dispatchEvent(new DialogueScriptEvent(DIALOGUE_SKIP, this, true)); + } + + var outroTween:Null = null; + + public function startOutro():Void + { + switch (_data?.outro) + { + case FADE(outroData): + outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime, + { + type: ONESHOT, // holy shit like the game no way + startDelay: 0, + onComplete: (_) -> endOutro(), + ease: EaseUtil.stepped(8) + }); + + if (this.music != null) FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime); + case NONE(_): + // Immediately clean up. + endOutro(); + default: + // Immediately clean up. + endOutro(); + } + } + + public var completeCallback:NullVoid> = null; + + public function endOutro():Void + { + ScriptEventDispatcher.callEvent(this, new ScriptEvent(DESTROY, false)); + } + + /** + * Performed as the conversation starts. + */ + public function onDialogueStart(event:DialogueScriptEvent):Void + { + propagateEvent(event); + + // Fade in the music and backdrop. + setupMusic(); + setupBackdrop(); + + // Advance the conversation. + state = ConversationState.Opening; + + showCurrentDialogueBox(); + playDialogueBoxAnimation(); + } + + /** + * Display the next line of conversation. + */ + public function onDialogueLine(event:DialogueScriptEvent):Void + { + propagateEvent(event); + if (event.eventCanceled) return; + + // Perform the actual logic to advance the conversation. + currentDialogueLine += 1; + if (currentDialogueLine >= currentDialogueLineCount) + { + // Open the next entry. + currentDialogueLine = 0; + currentDialogueEntry += 1; + + if (currentDialogueEntry >= currentDialogueEntryCount) + { + dispatchEvent(new DialogueScriptEvent(DIALOGUE_END, this, false)); + } + else + { + if (state == Idle) + { + showCurrentDialogueBox(); + playDialogueBoxAnimation(); + + state = Opening; + } + } + } + else + { + // Continue the dialog with more lines. + state = Speaking; + if (currentDialogueBox != null) currentDialogueBox.appendText(currentDialogueLineString); + } + } + + /** + * Skip the scrolling of the next line of conversation. + */ + public function onDialogueCompleteLine(event:DialogueScriptEvent):Void + { + propagateEvent(event); + if (event.eventCanceled) return; + + if (currentDialogueBox != null) currentDialogueBox.skip(); + } + + /** + * Skip to the end of the conversation, immediately triggering the DIALOGUE_END event. + */ + public function onDialogueSkip(event:DialogueScriptEvent):Void + { + propagateEvent(event); + if (event.eventCanceled) return; + + dispatchEvent(new DialogueScriptEvent(DIALOGUE_END, this, false)); + } + + public function onDialogueEnd(event:DialogueScriptEvent):Void + { + propagateEvent(event); + + state = Ending; + } + + // Only used for events/scripts. + + public function onUpdate(event:UpdateScriptEvent):Void + { + propagateEvent(event); + + if (event.eventCanceled) return; + + switch (state) + { + case ConversationState.Start: + // Wait for advance() to be called and DIALOGUE_LINE to be dispatched. + return; + case ConversationState.Opening: + // Backdrop animation should have started. + // Box animations should have started. + if (currentDialogueBox != null + && (currentDialogueBox.isAnimationFinished() + || currentDialogueBox.getCurrentAnimation() != currentDialogueEntryData?.boxAnimation)) + { + // Box animations have finished. + + // Start playing the speaker animation. + state = ConversationState.Speaking; + showCurrentSpeaker(); + playSpeakerAnimation(); + currentDialogueBox.setText(currentDialogueLineString); + } + return; + case ConversationState.Speaking: + // Speaker animation should be playing. + return; + case ConversationState.Idle: + // Waiting for user input via `advanceConversation()`. + return; + case ConversationState.Ending: + if (outroTween == null) startOutro(); + return; + } + } + + public function onDestroy(event:ScriptEvent):Void + { + propagateEvent(event); + + if (outroTween != null) + { + outroTween.cancel(); + } + outroTween = null; + + if (this.music != null) this.music.stop(); + this.music = null; + + if (currentSpeaker != null) + { + currentSpeaker.kill(); + remove(currentSpeaker); + currentSpeaker = null; + } + + if (currentDialogueBox != null) + { + currentDialogueBox.kill(); + remove(currentDialogueBox); + currentDialogueBox = null; + } + + if (backdrop != null) + { + backdrop.destroy(); + remove(backdrop); + backdrop = null; + } + + this.clear(); + + if (completeCallback != null) completeCallback(); + } + + public function onScriptEvent(event:ScriptEvent):Void + { + propagateEvent(event); + } + + /** + * As this event is dispatched to the Conversation, it is also dispatched to the active speaker. + * @param event + */ + function propagateEvent(event:ScriptEvent):Void + { + if (this.currentDialogueBox != null && this.currentDialogueBox.exists) + { + ScriptEventDispatcher.callEvent(this.currentDialogueBox, event); + } + if (this.currentSpeaker != null && this.currentSpeaker.exists) + { + ScriptEventDispatcher.callEvent(this.currentSpeaker, event); + } + } + + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function revive():Void + { + super.revive(); + this.alpha = 1; + this.visible = true; + } + + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + _skipTransformChildren = true; + alive = false; + exists = false; + _skipTransformChildren = false; + if (group != null) group.kill(); + + if (outroTween != null) + { + outroTween.cancel(); + outroTween = null; + } + } +} + +// Managing things with a single enum is a lot easier than a multitude of flags. +enum ConversationState +{ + /** + * State hasn't been initialized yet. + */ + Start; + + /** + * A dialog is animating. If the dialog is static, this may only last for one frame. + */ + Opening; + + /** + * Text is scrolling and audio is playing. Speaker portrait is probably animating too. + */ + Speaking; + + /** + * Text is done scrolling and game is waiting for user to open another dialog. + */ + Idle; + + /** + * Fade out and leave conversation. + */ + Ending; +} diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx new file mode 100644 index 00000000..f4372d71 --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx @@ -0,0 +1,418 @@ +package funkin.play.cutscene.dialogue; + +import flixel.FlxSprite; +import funkin.data.IRegistryEntry; +import flixel.group.FlxSpriteGroup; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.graphics.FunkinSprite; +import flixel.addons.text.FlxTypeText; +import funkin.util.assets.FlxAnimationUtil; +import funkin.modding.events.ScriptEvent; +import funkin.audio.FunkinSound; +import funkin.modding.IScriptedClass.IDialogueScriptedClass; +import flixel.util.FlxColor; +import funkin.data.dialogue.dialoguebox.DialogueBoxData; +import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry; + +class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry +{ + public var dialogueBoxName(get, never):String; + + function get_dialogueBoxName():String + { + return _data.name ?? 'UNKNOWN'; + } + + /** + * Offset the speaker's sprite by this much when playing each animation. + */ + var animationOffsets:Map> = new Map>(); + + /** + * The current animation offset being used. + */ + var animOffsets(default, set):Array = [0, 0]; + + function set_animOffsets(value:Array):Array + { + if (animOffsets == null) animOffsets = [0, 0]; + if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; + + var xDiff:Float = value[0] - animOffsets[0]; + var yDiff:Float = value[1] - animOffsets[1]; + + this.x += xDiff; + this.y += yDiff; + + return animOffsets = value; + } + + /** + * The offset of the speaker overall. + */ + public var globalOffsets(default, set):Array = [0, 0]; + + function set_globalOffsets(value:Array):Array + { + if (globalOffsets == null) globalOffsets = [0, 0]; + if (globalOffsets == value) return value; + + var xDiff:Float = value[0] - globalOffsets[0]; + var yDiff:Float = value[1] - globalOffsets[1]; + + this.x += xDiff; + this.y += yDiff; + return globalOffsets = value; + } + + var boxSprite:FlxSprite; + var textDisplay:FlxTypeText; + + var text(default, set):String; + + function set_text(value:String):String + { + this.text = value; + + textDisplay.resetText(this.text); + textDisplay.start(); + + return this.text; + } + + public var speed(default, set):Float; + + function set_speed(value:Float):Float + { + this.speed = value; + textDisplay.delay = this.speed * 0.05; // 1.0 x 0.05 + return this.speed; + } + + public function new(id:String) + { + super(); + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse dialogue box data for id: $id'; + } + } + + public function onCreate(event:ScriptEvent):Void + { + this.globalOffsets = [0, 0]; + this.x = 0; + this.y = 0; + this.alpha = 1; + + loadSpritesheet(); + loadAnimations(); + + loadText(); + } + + function loadSpritesheet():Void + { + if (this.boxSprite != null) + { + remove(this.boxSprite); + this.boxSprite = null; + } + + this.boxSprite = new FunkinSprite(0, 0); + + trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}'); + + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); + if (tex == null) + { + trace('Could not load Sparrow sprite: ${_data.assetPath}'); + return; + } + + this.boxSprite.frames = tex; + + if (_data.isPixel) + { + this.boxSprite.antialiasing = false; + } + else + { + this.boxSprite.antialiasing = true; + } + + this.flipX = _data.flipX; + this.flipY = _data.flipY; + this.globalOffsets = _data.offsets; + this.setScale(_data.scale); + + add(this.boxSprite); + } + + public function setText(newText:String):Void + { + textDisplay.prefix = ''; + textDisplay.resetText(newText); + textDisplay.start(); + } + + public function appendText(newText:String):Void + { + textDisplay.prefix = this.textDisplay.text; + textDisplay.resetText(newText); + textDisplay.start(); + } + + public function skip():Void + { + textDisplay.skip(); + } + + /** + * Reassign this to set a callback. + */ + function onTypingComplete():Void + { + // No save navigation? :( + if (typingCompleteCallback != null) typingCompleteCallback(); + } + + public var typingCompleteCallback:() -> Void; + + /** + * Set the sprite scale to the appropriate value. + * @param scale + */ + public function setScale(scale:Null):Void + { + if (scale == null) scale = 1.0; + this.boxSprite.scale.x = scale; + this.boxSprite.scale.y = scale; + this.boxSprite.updateHitbox(); + } + + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + super.kill(); + if (this.boxSprite != null) + { + this.boxSprite.kill(); + this.boxSprite = null; + } + if (this.textDisplay != null) + { + this.textDisplay.kill(); + this.textDisplay = null; + } + this.clear(); + } + + public override function revive():Void + { + super.revive(); + + this.visible = true; + this.alpha = 1.0; + } + + function loadAnimations():Void + { + trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}'); + + FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations); + + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames:Array = this.boxSprite?.animation?.getNameList() ?? []; + trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}'); + + boxSprite.animation.onFrameChange.add(this.onAnimationFrame); + boxSprite.animation.onFinish.add(this.onAnimationFinished); + } + + /** + * Called when an animation finishes. + * @param name The name of the animation that just finished. + */ + function onAnimationFinished(name:String):Void {} + + /** + * Called when the current animation's frame changes. + * @param name The name of the current animation. + * @param frameNumber The number of the current frame. + * @param frameIndex The index of the current frame. + * + * For example, if an animation was defined as having the indexes [3, 0, 1, 2], + * then the first callback would have frameNumber = 0 and frameIndex = 3. + */ + function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1):Void + { + // Do nothing by default. + // This can be overridden by, for example, scripts, + // or by calling `animationFrame.add()`. + + // Try not to do anything expensive here, it runs many times a second. + } + + function loadText():Void + { + textDisplay = new FlxTypeText(0, 0, 300, '', 32); + textDisplay.fieldWidth = _data.text.width; + textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW, + FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false); + textDisplay.borderSize = _data.text.shadowWidth ?? 2; + // TODO: Add an option to configure this. + textDisplay.sounds = [FunkinSound.load(Paths.sound('pixelText'), 0.6)]; + + textDisplay.completeCallback = onTypingComplete; + + textDisplay.x += _data.text.offsets[0]; + textDisplay.y += _data.text.offsets[1]; + + add(textDisplay); + } + + /** + * @param name The name of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + * @param reversed If true, play the animation backwards, from the last frame to the first. + */ + public function playAnimation(name:String, restart:Bool = false, reversed:Bool = false):Void + { + var correctName:String = correctAnimationName(name); + if (correctName == null) return; + + this.boxSprite.animation.play(correctName, restart, false, 0); + + applyAnimationOffsets(correctName); + } + + /** + * Ensure that a given animation exists before playing it. + * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. + * @param name + */ + function correctAnimationName(name:String):String + { + // If the animation exists, we're good. + if (hasAnimation(name)) return name; + + trace('[DIALOGUE BOX] Animation "$name" does not exist!'); + + // Attempt to strip a `-alt` suffix, if it exists. + if (name.lastIndexOf('-') != -1) + { + var correctName = name.substring(0, name.lastIndexOf('-')); + trace('[DIALOGUE BOX] Attempting to fallback to "$correctName"'); + return correctAnimationName(correctName); + } + else + { + if (name != 'idle') + { + trace('[DIALOGUE BOX] Attempting to fallback to "idle"'); + return correctAnimationName('idle'); + } + else + { + trace('[DIALOGUE BOX] Failing animation playback.'); + return null; + } + } + } + + public function hasAnimation(id:String):Bool + { + if (this.boxSprite.animation == null) return false; + + return this.boxSprite.animation.getByName(id) != null; + } + + /** + * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the character is BROKEN!), + * returns an empty string to prevent NPEs. + */ + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + /** + * Define the animation offsets for a specific animation. + */ + public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void + { + animationOffsets.set(name, [xOffset, yOffset]); + } + + /** + * Retrieve an apply the animation offsets for a specific animation. + */ + function applyAnimationOffsets(name:String):Void + { + var offsets:Array = animationOffsets.get(name); + if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0)) + { + this.animOffsets = offsets; + } + else + { + this.animOffsets = [0, 0]; + } + } + + public function isAnimationFinished():Bool + { + return this.boxSprite?.animation?.finished ?? false; + } + + public function onDialogueStart(event:DialogueScriptEvent):Void {} + + public function onDialogueCompleteLine(event:DialogueScriptEvent):Void {} + + public function onDialogueLine(event:DialogueScriptEvent):Void {} + + public function onDialogueSkip(event:DialogueScriptEvent):Void {} + + public function onDialogueEnd(event:DialogueScriptEvent):Void {} + + public function onUpdate(event:UpdateScriptEvent):Void {} + + public function onDestroy(event:ScriptEvent):Void + { + if (boxSprite != null) remove(boxSprite); + boxSprite = null; + if (textDisplay != null) remove(textDisplay); + textDisplay = null; + + this.clear(); + + this.x = 0; + this.y = 0; + this.globalOffsets = [0, 0]; + this.alpha = 0; + + this.kill(); + } + + public function onScriptEvent(event:ScriptEvent):Void {} +} diff --git a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx new file mode 100644 index 00000000..cb734427 --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx @@ -0,0 +1,10 @@ +package funkin.play.cutscene.dialogue; + +/** + * A script that can be tied to a Conversation. + * Create a scripted class that extends Conversation to use this. + * This allows you to customize how a specific conversation appears and behaves. + * Someone clever could use this to add branching dialogue I think. + */ +@:hscriptClass +class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx new file mode 100644 index 00000000..7689fc0d --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx @@ -0,0 +1,9 @@ +package funkin.play.cutscene.dialogue; + +/** + * A script that can be tied to a DialogueBox. + * Create a scripted class that extends DialogueBox to use this. + * This allows you to customize how a specific dialogue box appears. + */ +@:hscriptClass +class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx new file mode 100644 index 00000000..a244ff6f --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx @@ -0,0 +1,9 @@ +package funkin.play.cutscene.dialogue; + +/** + * A script that can be tied to a Speaker. + * Create a scripted class that extends Speaker to use this. + * This allows you to customize how a specific conversation speaker appears. + */ +@:hscriptClass +class ScriptedSpeaker extends funkin.play.cutscene.dialogue.Speaker implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx new file mode 100644 index 00000000..02efcb99 --- /dev/null +++ b/source/funkin/play/cutscene/dialogue/Speaker.hx @@ -0,0 +1,291 @@ +package funkin.play.cutscene.dialogue; + +import flixel.FlxSprite; +import funkin.data.IRegistryEntry; +import funkin.modding.events.ScriptEvent; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.util.assets.FlxAnimationUtil; +import funkin.modding.IScriptedClass.IDialogueScriptedClass; +import funkin.data.dialogue.speaker.SpeakerData; +import funkin.data.dialogue.speaker.SpeakerRegistry; + +/** + * The character sprite which displays during dialogue. + * + * Most conversations have two speakers, with one being flipped. + */ +class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRegistryEntry +{ + /** + * A readable name for this speaker. + */ + public var speakerName(get, never):String; + + function get_speakerName():String + { + return _data.name; + } + + /** + * Offset the speaker's sprite by this much when playing each animation. + */ + var animationOffsets:Map> = new Map>(); + + /** + * The current animation offset being used. + */ + var animOffsets(default, set):Array = [0, 0]; + + function set_animOffsets(value:Array):Array + { + if (animOffsets == null) animOffsets = [0, 0]; + if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; + + var xDiff:Float = value[0] - animOffsets[0]; + var yDiff:Float = value[1] - animOffsets[1]; + + this.x += xDiff; + this.y += yDiff; + + return animOffsets = value; + } + + /** + * The offset of the speaker overall. + */ + public var globalOffsets(default, set):Array = [0, 0]; + + function set_globalOffsets(value:Array):Array + { + if (globalOffsets == null) globalOffsets = [0, 0]; + if (globalOffsets == value) return value; + + var xDiff:Float = value[0] - globalOffsets[0]; + var yDiff:Float = value[1] - globalOffsets[1]; + + this.x += xDiff; + this.y += yDiff; + return globalOffsets = value; + } + + public function new(id:String) + { + super(); + + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse speaker data for id: $id'; + } + } + + /** + * Called when speaker is being created. + * @param event The script event. + */ + public function onCreate(event:ScriptEvent):Void + { + this.globalOffsets = [0, 0]; + this.x = 0; + this.y = 0; + this.alpha = 1; + + loadSpritesheet(); + loadAnimations(); + } + + /** + * Calls `kill()` on the group's members and then on the group itself. + * You can revive this group later via `revive()` after this. + */ + public override function kill():Void + { + super.kill(); + } + + public override function revive():Void + { + super.revive(); + + this.visible = true; + this.alpha = 1.0; + + loadSpritesheet(); + loadAnimations(); + } + + function loadSpritesheet():Void + { + trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}'); + + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath); + if (tex == null) + { + trace('Could not load Sparrow sprite: ${_data.assetPath}'); + return; + } + + this.frames = tex; + + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + + this.flipX = _data.flipX; + this.flipY = _data.flipY; + this.globalOffsets = _data.offsets; + this.setScale(_data.scale); + } + + /** + * Set the sprite scale to the appropriate value. + * @param scale + */ + public function setScale(scale:Null):Void + { + if (scale == null) scale = 1.0; + this.scale.x = scale; + this.scale.y = scale; + this.updateHitbox(); + } + + function loadAnimations():Void + { + trace('[SPEAKER] Loading ${_data.animations.length} animations for ${id}'); + + FlxAnimationUtil.addAtlasAnimations(this, _data.animations); + + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames:Array = this.animation.getNameList(); + trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${id}'); + } + + /** + * @param name The name of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + */ + public function playAnimation(name:String, restart:Bool = false):Void + { + var correctName:String = correctAnimationName(name); + if (correctName == null) return; + + this.animation.play(correctName, restart, false, 0); + + applyAnimationOffsets(correctName); + } + + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + /** + * Ensure that a given animation exists before playing it. + * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. + * @param name + */ + function correctAnimationName(name:String):String + { + // If the animation exists, we're good. + if (hasAnimation(name)) return name; + + FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...'); + + // Attempt to strip a `-alt` suffix, if it exists. + if (name.lastIndexOf('-') != -1) + { + var correctName = name.substring(0, name.lastIndexOf('-')); + FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...'); + return correctAnimationName(correctName); + } + else + { + if (name != 'idle') + { + FlxG.log.warn('Speaker tried to play animation "$name" that does not exist, fallback to idle...'); + return correctAnimationName('idle'); + } + else + { + FlxG.log.error('Speaker tried to play animation "idle" that does not exist! This is bad!'); + return null; + } + } + } + + public function hasAnimation(id:String):Bool + { + if (this.animation == null) return false; + + return this.animation.getByName(id) != null; + } + + /** + * Define the animation offsets for a specific animation. + */ + public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void + { + animationOffsets.set(name, [xOffset, yOffset]); + } + + /** + * Retrieve an apply the animation offsets for a specific animation. + */ + function applyAnimationOffsets(name:String):Void + { + var offsets:Array = animationOffsets.get(name); + if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0)) + { + this.animOffsets = offsets; + } + else + { + this.animOffsets = [0, 0]; + } + } + + public function onDialogueStart(event:DialogueScriptEvent):Void {} + + public function onDialogueCompleteLine(event:DialogueScriptEvent):Void {} + + public function onDialogueLine(event:DialogueScriptEvent):Void {} + + public function onDialogueSkip(event:DialogueScriptEvent):Void {} + + public function onDialogueEnd(event:DialogueScriptEvent):Void {} + + public function onUpdate(event:UpdateScriptEvent):Void {} + + public function onDestroy(event:ScriptEvent):Void + { + frames = null; + + this.x = 0; + this.y = 0; + this.globalOffsets = [0, 0]; + this.alpha = 0; + + this.kill(); + } + + public function onScriptEvent(event:ScriptEvent):Void {} +} diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx new file mode 100644 index 00000000..3198e079 --- /dev/null +++ b/source/funkin/play/event/FocusCameraSongEvent.hx @@ -0,0 +1,230 @@ +package funkin.play.event; + +import flixel.tweens.FlxEase; +// Data from the chart +import funkin.data.song.SongData.SongEventData; +// Data from the event schema +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; + +/** + * This class represents a handler for a type of song event. + * It is used by the ScriptedSongEvent class to handle user-defined events. + * + * Example: Focus on Boyfriend: + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": 0, + * } + * } + * ``` + * + * Example: Focus on 10px above Girlfriend: + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": 2, + * "y": -10, + * } + * } + * ``` + * + * Example: Focus on (100, 100): + * ``` + * { + * "e": "FocusCamera", + * "v": { + * "char": -1, + * "x": 100, + * "y": 100, + * } + * } + * ``` + */ +class FocusCameraSongEvent extends SongEvent +{ + public function new() + { + super('FocusCamera'); + } + + public override function handleEvent(data:SongEventData):Void + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null || PlayState.instance.currentStage == null) return; + + // Does nothing if we are minimal mode. + if (PlayState.instance.isMinimalMode) return; + + var posX:Null = data.getFloat('x'); + if (posX == null) posX = 0.0; + var posY:Null = data.getFloat('y'); + if (posY == null) posY = 0.0; + + var char:Null = data.getInt('char'); + + if (char == null) char = cast data.value; + + var duration:Null = data.getFloat('duration'); + if (duration == null) duration = 4.0; + var ease:Null = data.getString('ease'); + if (ease == null) ease = 'CLASSIC'; + + var currentStage = PlayState.instance.currentStage; + + // Get target position based on char. + var targetX:Float = posX; + var targetY:Float = posY; + + switch (char) + { + case -1: // Position ("focus" on origin) + trace('Focusing camera on static position.'); + + case 0: // Boyfriend (focus on player) + if (currentStage.getBoyfriend() == null) + { + trace('No BF to focus on.'); + return; + } + trace('Focusing camera on player.'); + var bfPoint = currentStage.getBoyfriend().cameraFocusPoint; + targetX += bfPoint.x; + targetY += bfPoint.y; + + case 1: // Dad (focus on opponent) + if (currentStage.getDad() == null) + { + trace('No dad to focus on.'); + return; + } + trace('Focusing camera on opponent.'); + var dadPoint = currentStage.getDad().cameraFocusPoint; + targetX += dadPoint.x; + targetY += dadPoint.y; + + case 2: // Girlfriend (focus on girlfriend) + if (currentStage.getGirlfriend() == null) + { + trace('No GF to focus on.'); + return; + } + trace('Focusing camera on girlfriend.'); + var gfPoint = currentStage.getGirlfriend().cameraFocusPoint; + targetX += gfPoint.x; + targetY += gfPoint.y; + + default: + trace('Unknown camera focus: ' + data); + } + + // Apply tween based on ease. + switch (ease) + { + case 'CLASSIC': // Old-school. No ease. Just set follow point. + PlayState.instance.resetCamera(false, false, false); + PlayState.instance.cancelCameraFollowTween(); + PlayState.instance.cameraFollowPoint.setPosition(targetX, targetY); + case 'INSTANT': // Instant ease. Duration is automatically 0. + PlayState.instance.tweenCameraToPosition(targetX, targetY, 0); + default: + var durSeconds = Conductor.instance.stepLengthMs * duration / 1000; + var easeFunction:NullFloat> = Reflect.field(FlxEase, ease); + if (easeFunction == null) + { + trace('Invalid ease function: $ease'); + return; + } + PlayState.instance.tweenCameraToPosition(targetX, targetY, durSeconds, easeFunction); + } + } + + public override function getTitle():String + { + return 'Focus Camera'; + } + + /** + * ``` + * { + * "char": ENUM, // Which character to point to + * "x": FLOAT, // Optional x offset + * "y": FLOAT, // Optional y offset + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: "char", + title: "Target", + defaultValue: 0, + type: SongEventFieldType.ENUM, + keys: ["Position" => -1, "Player" => 0, "Opponent" => 1, "Girlfriend" => 2] + }, + { + name: "x", + title: "X Position", + defaultValue: 0, + step: 10.0, + type: SongEventFieldType.FLOAT, + units: "px" + }, + { + name: "y", + title: "Y Position", + defaultValue: 0, + step: 10.0, + type: SongEventFieldType.FLOAT, + units: "px" + }, + { + name: 'duration', + title: 'Duration', + defaultValue: 4.0, + step: 0.5, + type: SongEventFieldType.FLOAT, + units: 'steps' + }, + { + name: 'ease', + title: 'Easing Type', + defaultValue: 'linear', + type: SongEventFieldType.ENUM, + keys: [ + 'Linear' => 'linear', + 'Sine In' => 'sineIn', + 'Sine Out' => 'sineOut', + 'Sine In/Out' => 'sineInOut', + 'Quad In' => 'quadIn', + 'Quad Out' => 'quadOut', + 'Quad In/Out' => 'quadInOut', + 'Cube In' => 'cubeIn', + 'Cube Out' => 'cubeOut', + 'Cube In/Out' => 'cubeInOut', + 'Quart In' => 'quartIn', + 'Quart Out' => 'quartOut', + 'Quart In/Out' => 'quartInOut', + 'Quint In' => 'quintIn', + 'Quint Out' => 'quintOut', + 'Quint In/Out' => 'quintInOut', + 'Expo In' => 'expoIn', + 'Expo Out' => 'expoOut', + 'Expo In/Out' => 'expoInOut', + 'Smooth Step In' => 'smoothStepIn', + 'Smooth Step Out' => 'smoothStepOut', + 'Smooth Step In/Out' => 'smoothStepInOut', + 'Elastic In' => 'elasticIn', + 'Elastic Out' => 'elasticOut', + 'Elastic In/Out' => 'elasticInOut', + 'Instant (Ignores duration)' => 'INSTANT', + 'Classic (Ignores duration)' => 'CLASSIC' + ] + } + ]); + } +} diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx new file mode 100644 index 00000000..cfcaf3c3 --- /dev/null +++ b/source/funkin/play/event/PlayAnimationSongEvent.hx @@ -0,0 +1,99 @@ +package funkin.play.event; + +import flixel.FlxSprite; +import funkin.play.character.BaseCharacter; +// Data from the chart +import funkin.data.song.SongData.SongEventData; +// Data from the event schema +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; + +class PlayAnimationSongEvent extends SongEvent +{ + public function new() + { + super('PlayAnimation'); + } + + public override function handleEvent(data:SongEventData) + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null || PlayState.instance.currentStage == null) return; + + var targetName = data.getString('target'); + var anim = data.getString('anim'); + var force = data.getBool('force'); + if (force == null) force = false; + + var target:FlxSprite = null; + + switch (targetName) + { + case 'boyfriend' | 'bf' | 'player': + trace('Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); + case 'dad' | 'opponent': + trace('Playing animation $anim on dad.'); + target = PlayState.instance.currentStage.getDad(); + case 'girlfriend' | 'gf': + trace('Playing animation $anim on girlfriend.'); + target = PlayState.instance.currentStage.getGirlfriend(); + default: + target = PlayState.instance.currentStage.getNamedProp(targetName); + if (target == null) trace('Unknown animation target: $targetName'); + else + trace('Fetched animation target $targetName from stage.'); + } + + if (target != null) + { + if (Std.isOfType(target, BaseCharacter)) + { + var targetChar:BaseCharacter = cast target; + targetChar.playAnimation(anim, force, force); + } + else + { + target.animation.play(anim, force); + } + } + } + + public override function getTitle():String + { + return "Play Animation"; + } + + /** + * ``` + * { + * "target": STRING, // Name of character or prop to point to. + * "anim": STRING, // Name of animation to play. + * "force": BOOL, // Whether to force the animation to play. + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: 'target', + title: 'Target', + type: SongEventFieldType.STRING, + defaultValue: 'boyfriend', + }, + { + name: 'anim', + title: 'Animation', + type: SongEventFieldType.STRING, + defaultValue: 'idle', + }, + { + name: 'force', + title: 'Force', + type: SongEventFieldType.BOOL, + defaultValue: false + } + ]); + } +} diff --git a/source/funkin/play/event/ScriptedSongEvent.hx b/source/funkin/play/event/ScriptedSongEvent.hx new file mode 100644 index 00000000..493e3534 --- /dev/null +++ b/source/funkin/play/event/ScriptedSongEvent.hx @@ -0,0 +1,15 @@ +package funkin.play.event; + +import polymod.hscript.HScriptedClass; + +/** + * A script that can be tied to a SongEvent. + * Create a scripted class that extends SongEvent, + * then call `super('SongEventType')` to use this. + * + * - Remember to override `handleEvent(data:SongEventData)` to perform your actions when the event is hit. + * - Remember to override `getTitle()` to return an event name that will be displayed in the editor. + * - Remember to override `getEventSchema()` to return a schema for the event data, used to build a form in the chart editor. + */ +@:hscriptClass +class ScriptedSongEvent extends SongEvent implements HScriptedClass {} diff --git a/source/funkin/play/event/ScrollSpeedEvent.hx b/source/funkin/play/event/ScrollSpeedEvent.hx new file mode 100644 index 00000000..bc0e99ba --- /dev/null +++ b/source/funkin/play/event/ScrollSpeedEvent.hx @@ -0,0 +1,172 @@ +package funkin.play.event; + +import flixel.tweens.FlxEase; +// Data from the chart +import funkin.data.song.SongData.SongEventData; +// Data from the event schema +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; + +/** + * This class represents a handler for scroll speed events. + * + * Example: Scroll speed change of both strums from 1x to 1.3x: + * ``` + * { + * 'e': 'ScrollSpeed', + * "v": { + * "scroll": "1.3", + * "duration": "4", + * "ease": "linear", + * "strumline": "both", + * "absolute": false + * } + * } + * ``` + */ +class ScrollSpeedEvent extends SongEvent +{ + public function new() + { + super('ScrollSpeed'); + } + + static final DEFAULT_SCROLL:Float = 1; + static final DEFAULT_DURATION:Float = 4.0; + static final DEFAULT_EASE:String = 'linear'; + static final DEFAULT_ABSOLUTE:Bool = false; + static final DEFAULT_STRUMLINE:String = 'both'; // my special little trick + + public override function handleEvent(data:SongEventData):Void + { + // Does nothing if there is no PlayState. + if (PlayState.instance == null) return; + + var scroll:Float = data.getFloat('scroll') ?? DEFAULT_SCROLL; + + var duration:Float = data.getFloat('duration') ?? DEFAULT_DURATION; + + var ease:String = data.getString('ease') ?? DEFAULT_EASE; + + var strumline:String = data.getString('strumline') ?? DEFAULT_STRUMLINE; + + var absolute:Bool = data.getBool('absolute') ?? DEFAULT_ABSOLUTE; + + var strumlineNames:Array = []; + + if (!absolute) + { + // If absolute is set to false, do the awesome multiplicative thing + scroll = scroll * (PlayState.instance?.currentChart?.scrollSpeed ?? 1.0); + } + + switch (strumline) + { + case 'both': + strumlineNames = ['playerStrumline', 'opponentStrumline']; + default: + strumlineNames = [strumline + 'Strumline']; + } + // If it's a string, check the value. + switch (ease) + { + case 'INSTANT': + PlayState.instance.tweenScrollSpeed(scroll, 0, null, strumlineNames); + default: + var durSeconds = Conductor.instance.stepLengthMs * duration / 1000; + var easeFunction:NullFloat> = Reflect.field(FlxEase, ease); + if (easeFunction == null) + { + trace('Invalid ease function: $ease'); + return; + } + + PlayState.instance.tweenScrollSpeed(scroll, durSeconds, easeFunction, strumlineNames); + } + } + + public override function getTitle():String + { + return 'Scroll Speed'; + } + + /** + * ``` + * { + * 'scroll': FLOAT, // Target scroll level. + * 'duration': FLOAT, // Duration in steps. + * 'ease': ENUM, // Easing function. + * 'strumline': ENUM, // Which strumline to change + * 'absolute': BOOL, // True to set the scroll speed to the target level, false to set the scroll speed to (target level x base scroll speed) + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: 'scroll', + title: 'Target Value', + defaultValue: 1.0, + step: 0.1, + type: SongEventFieldType.FLOAT, + units: 'x' + }, + { + name: 'duration', + title: 'Duration', + defaultValue: 4.0, + step: 0.5, + type: SongEventFieldType.FLOAT, + units: 'steps' + }, + { + name: 'ease', + title: 'Easing Type', + defaultValue: 'linear', + type: SongEventFieldType.ENUM, + keys: [ + 'Linear' => 'linear', + 'Instant (Ignores Duration)' => 'INSTANT', + 'Sine In' => 'sineIn', + 'Sine Out' => 'sineOut', + 'Sine In/Out' => 'sineInOut', + 'Quad In' => 'quadIn', + 'Quad Out' => 'quadOut', + 'Quad In/Out' => 'quadInOut', + 'Cube In' => 'cubeIn', + 'Cube Out' => 'cubeOut', + 'Cube In/Out' => 'cubeInOut', + 'Quart In' => 'quartIn', + 'Quart Out' => 'quartOut', + 'Quart In/Out' => 'quartInOut', + 'Quint In' => 'quintIn', + 'Quint Out' => 'quintOut', + 'Quint In/Out' => 'quintInOut', + 'Expo In' => 'expoIn', + 'Expo Out' => 'expoOut', + 'Expo In/Out' => 'expoInOut', + 'Smooth Step In' => 'smoothStepIn', + 'Smooth Step Out' => 'smoothStepOut', + 'Smooth Step In/Out' => 'smoothStepInOut', + 'Elastic In' => 'elasticIn', + 'Elastic Out' => 'elasticOut', + 'Elastic In/Out' => 'elasticInOut' + ] + }, + { + name: 'strumline', + title: 'Target Strumline', + defaultValue: 'both', + type: SongEventFieldType.ENUM, + keys: ['Both' => 'both', 'Player' => 'player', 'Opponent' => 'opponent'] + }, + { + name: 'absolute', + title: 'Absolute', + defaultValue: false, + type: SongEventFieldType.BOOL, + } + ]); + } +} diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx new file mode 100644 index 00000000..5f5694ce --- /dev/null +++ b/source/funkin/play/event/SetCameraBopSongEvent.hx @@ -0,0 +1,89 @@ +package funkin.play.event; + +// Data from the chart +import funkin.data.song.SongData.SongEventData; +// Data from the event schema +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; + +/** + * This class represents a handler for configuring camera bop intensity and rate. + * + * Example: Bop the camera twice as hard, once per beat (rather than once every four beats). + * ``` + * { + * 'e': 'SetCameraBop', + * 'v': { + * 'intensity': 2.0, + * 'rate': 1, + * } + * } + * ``` + * + * Example: Reset the camera bop to default values. + * ``` + * { + * 'e': 'SetCameraBop', + * 'v': {} + * } + * ``` + */ +class SetCameraBopSongEvent extends SongEvent +{ + public function new() + { + super('SetCameraBop'); + } + + public override function handleEvent(data:SongEventData):Void + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null) return; + + var rate:Null = data.getInt('rate'); + if (rate == null) rate = Constants.DEFAULT_ZOOM_RATE; + var intensity:Null = data.getFloat('intensity'); + if (intensity == null) intensity = 1.0; + + PlayState.instance.cameraBopIntensity = (Constants.DEFAULT_BOP_INTENSITY - 1.0) * intensity + 1.0; + PlayState.instance.hudCameraZoomIntensity = (Constants.DEFAULT_BOP_INTENSITY - 1.0) * intensity * 2.0; + PlayState.instance.cameraZoomRate = rate; + trace('Set camera zoom rate to ${PlayState.instance.cameraZoomRate}'); + } + + public override function getTitle():String + { + return 'Set Camera Bop'; + } + + /** + * ``` + * { + * 'intensity': FLOAT, // Zoom amount + * 'rate': INT, // Zoom rate (beats/zoom) + * } + * ``` + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: 'intensity', + title: 'Intensity', + defaultValue: 1.0, + step: 0.1, + type: SongEventFieldType.FLOAT, + units: 'x' + }, + { + name: 'rate', + title: 'Rate', + defaultValue: 4, + step: 1, + type: SongEventFieldType.INTEGER, + units: 'beats/zoom' + } + ]); + } +} diff --git a/source/funkin/play/event/SetCharacterSongEvent.hx b/source/funkin/play/event/SetCharacterSongEvent.hx new file mode 100644 index 00000000..ec7ae7fe --- /dev/null +++ b/source/funkin/play/event/SetCharacterSongEvent.hx @@ -0,0 +1,3 @@ +package funkin.play.event; + +// TODO: Add a song event which switches characters. diff --git a/source/funkin/play/event/SetHealthIconSongEvent.hx b/source/funkin/play/event/SetHealthIconSongEvent.hx new file mode 100644 index 00000000..a2110260 --- /dev/null +++ b/source/funkin/play/event/SetHealthIconSongEvent.hx @@ -0,0 +1,121 @@ +package funkin.play.event; + +import funkin.data.event.SongEventSchema; +import funkin.play.character.CharacterData.HealthIconData; +import funkin.data.song.SongData; +import funkin.data.song.SongData.SongEventData; + +/** + * This class represents a handler for scroll speed events. + * + * Example: Set the health icon of Boyfriend to "bf-pixel": + * ``` + * { + * 'e': 'SetHealthIcon', + * "v": { + * "char": 0, + * "id": "bf-pixel", + * + * // Optional params: + * "scale": 1.0, + * "flipX": false, + * "isPixel": false, + * "offsetX": 0.0, + * "offsetY": 0.0 + * } + * } + * ``` + */ +class SetHealthIconSongEvent extends SongEvent +{ + public function new() + { + super('SetHealthIcon'); + } + + public override function handleEvent(data:SongEventData):Void + { + // Does nothing if there is no PlayState. + if (PlayState.instance == null) return; + + // Works even if we are minimal mode. + // if (PlayState.instance.isMinimalMode) return; + + var offsets:Array = [data.value.offsetX ?? 0.0, data.value.offsetY ?? 0.0]; + + var healthIconData:HealthIconData = + { + id: data.value.id ?? "bf", + scale: data.value.scale ?? 1.0, + flipX: data.value.flipX ?? false, + isPixel: data.value.isPixel ?? false, + offsets: offsets, + }; + + switch (data?.value?.char ?? 0) + { + case 0: + trace('Applying Player health icon via song event: ${healthIconData.id}'); + PlayState.instance.iconP1.configure(healthIconData); + case 1: + trace('Applying Opponent health icon via song event: ${healthIconData.id}'); + PlayState.instance.iconP2.configure(healthIconData); + default: + trace('[WARN] Unknown character index: ' + data.value.char); + } + } + + public override function getTitle():String + { + return 'Set Health Icon'; + } + + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: 'char', + title: 'Character', + defaultValue: 0, + type: SongEventFieldType.ENUM, + keys: ['Player' => 0, 'Opponent' => 1], + }, + { + name: 'id', + title: 'Health Icon ID', + defaultValue: 'bf', + type: SongEventFieldType.STRING, + }, + { + name: 'scale', + title: 'Scale', + defaultValue: 1.0, + type: SongEventFieldType.FLOAT, + }, + { + name: 'flipX', + title: 'Flip X?', + defaultValue: false, + type: SongEventFieldType.BOOL, + }, + { + name: 'isPixel', + title: 'Is Pixel?', + defaultValue: false, + type: SongEventFieldType.BOOL, + }, + { + name: 'offsetX', + title: 'X Offset', + defaultValue: 0, + type: SongEventFieldType.FLOAT, + }, + { + name: 'offsetY', + title: 'Y Offset', + defaultValue: 0, + type: SongEventFieldType.FLOAT, + } + ]); + } +} diff --git a/source/funkin/play/event/SetStageSongEvent.hx b/source/funkin/play/event/SetStageSongEvent.hx new file mode 100644 index 00000000..9536c541 --- /dev/null +++ b/source/funkin/play/event/SetStageSongEvent.hx @@ -0,0 +1,3 @@ +package funkin.play.event; + +// TODO: Add a song event which switches stages. diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx new file mode 100644 index 00000000..29b394c0 --- /dev/null +++ b/source/funkin/play/event/SongEvent.hx @@ -0,0 +1,63 @@ +package funkin.play.event; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.event.SongEventSchema; + +/** + * This class represents a handler for a type of song event. + * It is used by the ScriptedSongEvent class to handle user-defined events. + */ +class SongEvent +{ + /** + * The internal song event ID that this handler is responsible for. + */ + public var id:String; + + public function new(id:String) + { + this.id = id; + } + + /** + * Handles a song event that matches this handler's ID. + * @param data The data associated with the event. + */ + public function handleEvent(data:SongEventData):Void + { + throw 'SongEvent.handleEvent() must be overridden!'; + } + + /** + * Retrieves the chart editor schema for this song event type. + * @return The schema, or null if this event type does not have a schema. + */ + public function getEventSchema():SongEventSchema + { + return null; + } + + /** + * Retrieves the asset path to the icon this event type should use in the chart editor. + * To customize this, override getIconPath(). + */ + public function getIconPath():String + { + return 'ui/chart-editor/events/default'; + } + + /** + * Retrieves the human readable title of this song event type. + * Used for the chart editor. + * @return The title. + */ + public function getTitle():String + { + return this.id.toTitleCase(); + } + + public function toString():String + { + return 'SongEvent(${this.id})'; + } +} diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx new file mode 100644 index 00000000..0c34028c --- /dev/null +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -0,0 +1,168 @@ +package funkin.play.event; + +import flixel.tweens.FlxEase; +// Data from the chart +import funkin.data.song.SongData.SongEventData; +// Data from the event schema +import funkin.data.event.SongEventSchema; +import funkin.data.event.SongEventSchema.SongEventFieldType; + +/** + * This class represents a handler for camera zoom events. + * + * Example: Zoom to 1.3x: + * ``` + * { + * 'e': 'ZoomCamera', + * 'v': 1.3 + * } + * ``` + * + * Example: Zoom to 1.3x + * ``` + * { + * 'e': 'FocusCamera', + * 'v': { + * 'char': 2, + * 'y': -10, + * } + * } + * ``` + * + * Example: Focus on (100, 100): + * ``` + * { + * 'e': 'FocusCamera', + * 'v': { + * 'char': -1, + * 'x': 100, + * 'y': 100, + * } + * } + * ``` + */ +class ZoomCameraSongEvent extends SongEvent +{ + public function new() + { + super('ZoomCamera'); + } + + static final DEFAULT_ZOOM:Float = 1.0; + static final DEFAULT_DURATION:Float = 4.0; + static final DEFAULT_MODE:String = 'direct'; + static final DEFAULT_EASE:String = 'linear'; + + public override function handleEvent(data:SongEventData):Void + { + // Does nothing if there is no PlayState camera or stage. + if (PlayState.instance == null || PlayState.instance.currentStage == null) return; + + // Does nothing if we are minimal mode. + if (PlayState.instance.isMinimalMode) return; + + var zoom:Float = data.getFloat('zoom') ?? DEFAULT_ZOOM; + + var duration:Float = data.getFloat('duration') ?? DEFAULT_DURATION; + + var mode:String = data.getString('mode') ?? DEFAULT_MODE; + var isDirectMode:Bool = mode == 'direct'; + + var ease:String = data.getString('ease') ?? DEFAULT_EASE; + + // If it's a string, check the value. + switch (ease) + { + case 'INSTANT': + PlayState.instance.tweenCameraZoom(zoom, 0, isDirectMode); + default: + var durSeconds = Conductor.instance.stepLengthMs * duration / 1000; + var easeFunction:NullFloat> = Reflect.field(FlxEase, ease); + if (easeFunction == null) + { + trace('Invalid ease function: $ease'); + return; + } + + PlayState.instance.tweenCameraZoom(zoom, durSeconds, isDirectMode, easeFunction); + } + } + + public override function getTitle():String + { + return 'Zoom Camera'; + } + + /** + * ``` + * { + * 'zoom': FLOAT, // Target zoom level. + * 'duration': FLOAT, // Duration in steps. + * 'mode': ENUM, // Whether zoom is relative to the stage or absolute zoom. + * 'ease': ENUM, // Easing function. + * } + * @return SongEventSchema + */ + public override function getEventSchema():SongEventSchema + { + return new SongEventSchema([ + { + name: 'zoom', + title: 'Zoom Level', + defaultValue: 1.0, + step: 0.05, + type: SongEventFieldType.FLOAT, + units: 'x' + }, + { + name: 'duration', + title: 'Duration', + defaultValue: 4.0, + step: 0.5, + type: SongEventFieldType.FLOAT, + units: 'steps' + }, + { + name: 'mode', + title: 'Mode', + defaultValue: 'stage', + type: SongEventFieldType.ENUM, + keys: ['Stage zoom' => 'stage', 'Absolute zoom' => 'direct'] + }, + { + name: 'ease', + title: 'Easing Type', + defaultValue: 'linear', + type: SongEventFieldType.ENUM, + keys: [ + 'Linear' => 'linear', + 'Instant' => 'INSTANT', + 'Sine In' => 'sineIn', + 'Sine Out' => 'sineOut', + 'Sine In/Out' => 'sineInOut', + 'Quad In' => 'quadIn', + 'Quad Out' => 'quadOut', + 'Quad In/Out' => 'quadInOut', + 'Cube In' => 'cubeIn', + 'Cube Out' => 'cubeOut', + 'Cube In/Out' => 'cubeInOut', + 'Quart In' => 'quartIn', + 'Quart Out' => 'quartOut', + 'Quart In/Out' => 'quartInOut', + 'Quint In' => 'quintIn', + 'Quint Out' => 'quintOut', + 'Quint In/Out' => 'quintInOut', + 'Expo In' => 'expoIn', + 'Expo Out' => 'expoOut', + 'Expo In/Out' => 'expoInOut', + 'Smooth Step In' => 'smoothStepIn', + 'Smooth Step Out' => 'smoothStepOut', + 'Smooth Step In/Out' => 'smoothStepInOut', + 'Elastic In' => 'elasticIn', + 'Elastic Out' => 'elasticOut', + 'Elastic In/Out' => 'elasticInOut' + ] + } + ]); + } +} diff --git a/source/funkin/play/notes/NoteDirection.hx b/source/funkin/play/notes/NoteDirection.hx new file mode 100644 index 00000000..c937916f --- /dev/null +++ b/source/funkin/play/notes/NoteDirection.hx @@ -0,0 +1,81 @@ +package funkin.play.notes; + +import flixel.util.FlxColor; + +/** + * The direction of a note. + * This has implicit casting set up, so you can use this as an integer. + */ +enum abstract NoteDirection(Int) from Int to Int +{ + var LEFT = 0; + var DOWN = 1; + var UP = 2; + var RIGHT = 3; + public var name(get, never):String; + public var nameUpper(get, never):String; + public var color(get, never):FlxColor; + public var colorName(get, never):String; + + @:from + public static function fromInt(value:Int):NoteDirection + { + return switch (value % 4) + { + case 0: LEFT; + case 1: DOWN; + case 2: UP; + case 3: RIGHT; + default: LEFT; + } + } + + function get_name():String + { + return switch (abstract) + { + case LEFT: + 'left'; + case DOWN: + 'down'; + case UP: + 'up'; + case RIGHT: + 'right'; + default: + 'unknown'; + } + } + + function get_nameUpper():String + { + return abstract.name.toUpperCase(); + } + + function get_color():FlxColor + { + return Constants.COLOR_NOTES[this]; + } + + function get_colorName():String + { + return switch (abstract) + { + case LEFT: + 'purple'; + case DOWN: + 'blue'; + case UP: + 'green'; + case RIGHT: + 'red'; + default: + 'unknown'; + } + } + + public function toString():String + { + return abstract.name; + } +} diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx new file mode 100644 index 00000000..478b18b5 --- /dev/null +++ b/source/funkin/play/notes/NoteHoldCover.hx @@ -0,0 +1,102 @@ +package funkin.play.notes; + +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.util.assets.FlxAnimationUtil; +import flixel.FlxSprite; +import funkin.play.notes.notestyle.NoteStyle; + +class NoteHoldCover extends FlxTypedSpriteGroup +{ + static final FRAMERATE_DEFAULT:Int = 24; + + public var holdNote:SustainTrail; + + public var glow:FlxSprite; + + var sparks:FlxSprite; + + public function new(noteStyle:NoteStyle) + { + super(0, 0); + + setupHoldNoteCover(noteStyle); + } + + /** + * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times. + */ + function setupHoldNoteCover(noteStyle:NoteStyle):Void + { + glow = new FlxSprite(); + add(glow); + + // TODO: null check here like how NoteSplash does + noteStyle.buildHoldCoverSprite(this); + + glow.animation.onFinish.add(this.onAnimationFinished); + + if (glow.animation.getAnimationList().length < 3 * 4) + { + trace('WARNING: NoteHoldCover failed to initialize all animations.'); + } + } + + public override function update(elapsed):Void + { + super.update(elapsed); + } + + public function playStart():Void + { + var direction:NoteDirection = holdNote.noteDirection; + glow.animation.play('holdCoverStart${direction.colorName.toTitleCase()}'); + } + + public function playContinue():Void + { + var direction:NoteDirection = holdNote.noteDirection; + glow.animation.play('holdCover${direction.colorName.toTitleCase()}'); + } + + public function playEnd():Void + { + var direction:NoteDirection = holdNote.noteDirection; + glow.animation.play('holdCoverEnd${direction.colorName.toTitleCase()}'); + } + + public override function kill():Void + { + super.kill(); + + this.visible = false; + + if (glow != null) glow.visible = false; + if (sparks != null) sparks.visible = false; + } + + public override function revive():Void + { + super.revive(); + + this.visible = true; + this.alpha = 1.0; + + if (glow != null) glow.visible = true; + if (sparks != null) sparks.visible = true; + } + + public function onAnimationFinished(animationName:String):Void + { + if (animationName.startsWith('holdCoverStart')) + { + playContinue(); + } + if (animationName.startsWith('holdCoverEnd')) + { + // *lightning* *zap* *crackle* + this.visible = false; + this.kill(); + } + } +} diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx new file mode 100644 index 00000000..744ea068 --- /dev/null +++ b/source/funkin/play/notes/NoteSplash.hx @@ -0,0 +1,69 @@ +package funkin.play.notes; + +import funkin.play.notes.notestyle.NoteStyle; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.FlxG; +import flixel.FlxSprite; + +class NoteSplash extends FlxSprite +{ + public var splashFramerate:Int = 24; + public var splashFramerateVariance:Int = 2; + + static var frameCollection:FlxFramesCollection; + + public function new(noteStyle:NoteStyle) + { + super(0, 0); + + setupSplashGraphic(noteStyle); + + this.animation.onFinish.add(this.onAnimationFinished); + } + + /** + * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times. + */ + function setupSplashGraphic(noteStyle:NoteStyle):Void + { + if (frames == null) noteStyle.buildSplashSprite(this); + + if (this.animation.getAnimationList().length < 8) + { + trace('WARNING: NoteSplash failed to initialize all animations.'); + } + } + + public function playAnimation(name:String, force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void + { + this.animation.play(name, force, reversed, startFrame); + } + + public function play(direction:NoteDirection, variant:Int = null):Void + { + if (variant == null) + { + var animationAmount:Int = this.animation.getAnimationList().filter(function(anim) return anim.name.startsWith('splash${direction.nameUpper}')).length + - 1; + variant = FlxG.random.int(0, animationAmount); + } + + // splashUP0, splashUP1, splashRIGHT0, etc. + // the animations are processed via `NoteStyle.fetchSplashAnimationData()` in this format + this.playAnimation('splash${direction.nameUpper}${variant}'); + + if (animation.curAnim == null) return; + + // Vary the speed of the animation a bit. + animation.curAnim.frameRate = splashFramerate + FlxG.random.int(-splashFramerateVariance, splashFramerateVariance); + + // Center the animation on the note splash. + offset.set(width * 0.3, height * 0.3); + } + + public function onAnimationFinished(animationName:String):Void + { + // *lightning* *zap* *crackle* + this.kill(); + } +} diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx new file mode 100644 index 00000000..509bfc3e --- /dev/null +++ b/source/funkin/play/notes/NoteSprite.hx @@ -0,0 +1,256 @@ +package funkin.play.notes; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.NoteParamData; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.graphics.FunkinSprite; +import funkin.graphics.shaders.HSVShader; + +class NoteSprite extends FunkinSprite +{ + static final DIRECTION_COLORS:Array = ['purple', 'blue', 'green', 'red']; + + public var holdNoteSprite:SustainTrail; + + var hsvShader:HSVShader; + + /** + * The strum time at which the note should be hit, in milliseconds. + */ + public var strumTime(get, set):Float; + + function get_strumTime():Float + { + return this.noteData?.time ?? 0.0; + } + + function set_strumTime(value:Float):Float + { + if (this.noteData == null) return value; + return this.noteData.time = value; + } + + /** + * The length for which the note should be held, in milliseconds. + * Defaults to 0 for single notes. + */ + public var length(get, set):Float; + + function get_length():Float + { + return this.noteData?.length ?? 0.0; + } + + function set_length(value:Float):Float + { + if (this.noteData == null) return value; + return this.noteData.length = value; + } + + /** + * An extra attribute for the note. + * For example, whether the note is an "alt" note, or whether it has custom behavior on hit. + */ + public var kind(get, set):Null; + + function get_kind():Null + { + return this.noteData?.kind; + } + + function set_kind(value:String):String + { + if (this.noteData == null) return value; + return this.noteData.kind = value; + } + + /** + * An array of custom parameters for this note + */ + public var params(get, set):Array; + + function get_params():Array + { + return this.noteData?.params ?? []; + } + + function set_params(value:Array):Array + { + if (this.noteData == null) return value; + return this.noteData.params = value; + } + + /** + * The data of the note (i.e. the direction.) + */ + public var direction(default, set):NoteDirection; + + function set_direction(value:Int):Int + { + if (frames == null) return value; + + playNoteAnimation(value); + + this.direction = value; + return this.direction; + } + + public var noteData:SongNoteData; + + public var isHoldNote(get, never):Bool; + + function get_isHoldNote():Bool + { + 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. + */ + public var hasBeenHit:Bool = false; + + /** + * Register this note as hit only after any other notes + */ + public var lowPriority:Bool = false; + + /** + * This is true if the note is later than 10 frames within the strumline, + * and thus can't be hit by the player. + * It will be destroyed after it moves offscreen. + * Managed by PlayState. + */ + public var hasMissed:Bool; + + /** + * This is true if the note is earlier than 10 frames within the strumline. + * and thus can't be hit by the player. + * Managed by PlayState. + */ + public var tooEarly:Bool; + + /** + * This is true if the note is within 10 frames of the strumline, + * and thus may be hit by the player. + * Managed by PlayState. + */ + public var mayHit:Bool; + + /** + * This is true if the PlayState has performed the logic for missing this note. + * Subtracting score, subtracting health, etc. + */ + public var handledMiss:Bool; + + public function new(noteStyle:NoteStyle, direction:Int = 0) + { + super(0, -9999); + this.direction = direction; + + this.hsvShader = new HSVShader(); + + setupNoteGraphic(noteStyle); + } + + /** + * Creates frames and animations + * @param noteStyle The `NoteStyle` instance + */ + public function setupNoteGraphic(noteStyle:NoteStyle):Void + { + noteStyle.buildNoteSprite(this); + + this.shader = hsvShader; + + // `false` disables the update() function for performance. + this.active = noteStyle.isNoteAnimated(); + } + + /** + * Retrieve the value of the param with the given name + * @param name Name of the param + * @return Null + */ + public function getParam(name:String):Null + { + for (param in params) + { + if (param.name == name) + { + return param.value; + } + } + return null; + } + + #if FLX_DEBUG + /** + * Call this to override how debug bounding boxes are drawn for this sprite. + */ + public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) return; + + var gfx = beginDrawDebug(camera); + + var rect = getBoundingBox(camera); + trace('note sprite bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height); + + gfx.lineStyle(2, 0xFFFF66FF, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y, rect.width, rect.height); + + gfx.lineStyle(2, 0xFFFFFF66, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y + rect.height / 2, rect.width, 1); + + endDrawDebug(camera); + } + #end + + function playNoteAnimation(value:Int):Void + { + animation.play(DIRECTION_COLORS[value] + 'Scroll'); + } + + public function desaturate():Void + { + this.hsvShader.saturation = 0.2; + } + + public function setHue(hue:Float):Void + { + this.hsvShader.hue = hue; + } + + public override function revive():Void + { + super.revive(); + this.visible = true; + this.alpha = 1.0; + this.active = false; + this.tooEarly = false; + this.hasBeenHit = false; + this.mayHit = false; + this.hasMissed = false; + + this.hsvShader.hue = 1.0; + this.hsvShader.saturation = 1.0; + this.hsvShader.value = 1.0; + } + + public override function kill():Void + { + super.kill(); + } + + public override function destroy():Void + { + // This function should ONLY get called as you leave PlayState entirely. + // Otherwise, we want the game to keep reusing note sprites to save memory. + super.destroy(); + } +} diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx new file mode 100644 index 00000000..50c85b73 --- /dev/null +++ b/source/funkin/play/notes/Strumline.hx @@ -0,0 +1,1289 @@ +package funkin.play.notes; + +import flixel.math.FlxPoint; +import flixel.util.FlxSignal.FlxTypedSignal; +import flixel.FlxG; +import funkin.play.notes.notestyle.NoteStyle; +import flixel.group.FlxSpriteGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxSort; +import funkin.graphics.FunkinSprite; +import funkin.data.song.SongData.SongNoteData; +import funkin.util.SortUtil; +import funkin.play.notes.notekind.NoteKindManager; + +/** + * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player. + */ +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:Float = -0.275 * STRUMLINE_SIZE; + static final NUDGE:Float = 2.0; + + static final KEY_COUNT:Int = 4; + static final NOTE_SPLASH_CAP:Int = 6; + + var renderDistanceMs(get, never):Float; + + /** + * The custom render distance for the strumline. + * This should be in miliseconds only! Not pixels. + */ + 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 var useCustomRenderDistance:Bool = false; + + function get_renderDistanceMs():Float + { + if (useCustomRenderDistance) return customRenderDistanceMs; + return FlxG.height / Constants.PIXELS_PER_MS / scrollSpeed; + } + + /** + * Whether to play note splashes or not + * TODO: Make this a setting! + * IE: Settings.noSplash + */ + public var showNotesplash:Bool = true; + + /** + * Whether this strumline is controlled by the player's inputs. + * False means it's controlled by the opponent or Bot Play. + */ + public var isPlayer:Bool; + + /** + * Usually you want to keep this as is, but if you are using a Strumline and + * playing a sound that has it's own conductor, set this (LatencyState for example) + */ + public var conductorInUse(get, set):Conductor; + + // 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; + } + + var _conductorInUse:Null; + + function get_conductorInUse():Conductor + { + if (_conductorInUse == null) return Conductor.instance; + return _conductorInUse; + } + + function set_conductorInUse(value:Conductor):Conductor + { + return _conductorInUse = value; + } + + /** + * Whether the game should auto position notes. + */ + public var customPositionData:Bool = false; + + /** + * The notes currently being rendered on the strumline. + * This group iterates over this every frame to update note positions. + * The PlayState also iterates over this to calculate user inputs. + */ + 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; + + var strumlineNotes:FlxTypedSpriteGroup; + var noteSplashes:FlxTypedSpriteGroup; + var noteHoldCovers:FlxTypedSpriteGroup; + + var notesVwoosh:FlxTypedSpriteGroup; + var holdNotesVwoosh:FlxTypedSpriteGroup; + + final noteStyle:NoteStyle; + + #if FEATURE_GHOST_TAPPING + var ghostTapTimer:Float = 0.0; + #end + + /** + * The note data for the song. Should NOT be altered after the song starts, + * so we can easily rewind. + */ + var noteData:Array = []; + + var nextNoteIndex:Int = -1; + + var heldKeys:Array = []; + + static final BACKGROUND_PAD:Int = 16; + + public function new(noteStyle:NoteStyle, isPlayer:Bool) + { + super(); + + this.isPlayer = isPlayer; + this.noteStyle = noteStyle; + + this.strumlineNotes = new FlxTypedSpriteGroup(); + this.strumlineNotes.zIndex = 10; + this.add(this.strumlineNotes); + + // Hold notes are added first so they render behind regular notes. + this.holdNotes = new FlxTypedSpriteGroup(); + this.holdNotes.zIndex = 20; + this.add(this.holdNotes); + + this.holdNotesVwoosh = new FlxTypedSpriteGroup(); + this.holdNotesVwoosh.zIndex = 21; + this.add(this.holdNotesVwoosh); + + this.notes = new FlxTypedSpriteGroup(); + this.notes.zIndex = 30; + this.add(this.notes); + + this.notesVwoosh = new FlxTypedSpriteGroup(); + this.notesVwoosh.zIndex = 31; + this.add(this.notesVwoosh); + + this.noteHoldCovers = new FlxTypedSpriteGroup(0, 0, 4); + this.noteHoldCovers.zIndex = 40; + this.add(this.noteHoldCovers); + + this.noteSplashes = new FlxTypedSpriteGroup(0, 0, NOTE_SPLASH_CAP); + this.noteSplashes.zIndex = 50; + this.add(this.noteSplashes); + + this.background = new FunkinSprite(0, 0).makeSolidColor(Std.int(this.width + BACKGROUND_PAD * 2), FlxG.height, 0xFF000000); + // Convert the percent to a number between 0 and 1. + this.background.alpha = Preferences.strumlineBackgroundOpacity / 100.0; + this.background.scrollFactor.set(0, 0); + this.background.x = -BACKGROUND_PAD; + this.add(this.background); + + this.refresh(); + + this.onNoteIncoming = new FlxTypedSignalVoid>(); + resetScrollSpeed(); + + for (i in 0...KEY_COUNT) + { + var child:StrumlineNote = new StrumlineNote(noteStyle, isPlayer, DIRECTIONS[i]); + child.x = getXPos(DIRECTIONS[i]); + child.x += INITIAL_OFFSET; + child.y = 0; + noteStyle.applyStrumlineOffsets(child); + this.strumlineNotes.add(child); + } + + for (i in 0...KEY_COUNT) + { + heldKeys.push(false); + } + + // This MUST be true for children to update! + this.active = true; + } + + override function set_y(value:Float):Float + { + super.set_y(value); + + // Keep the background on the screen. + if (this.background != null) this.background.y = 0; + + return value; + } + + override function set_alpha(value:Float):Float + { + super.set_alpha(value); + + this.background.alpha = Preferences.strumlineBackgroundOpacity / 100.0 * alpha; + + return value; + } + + /** + * Refresh the strumline, sorting its children by z-index. + */ + public function refresh():Void + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + override function get_width():Float + { + return KEY_COUNT * Strumline.NOTE_SPACING; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + updateNotes(); + + #if FEATURE_GHOST_TAPPING + updateGhostTapTimer(elapsed); + #end + } + + #if FEATURE_GHOST_TAPPING + /** + * @return `true` if no notes are in range of the strumline and the player can spam without penalty. + */ + public function mayGhostTap():Bool + { + // Any notes in range of the strumline. + if (getNotesMayHit().length > 0) + { + return false; + } + // Any hold notes in range of the strumline. + if (getHoldNotesHitOrMissed().length > 0) + { + return false; + } + + // Note has been hit recently. + if (ghostTapTimer > 0.0) return false; + + // **yippee** + return true; + } + #end + + /** + * Return notes that are within `Constants.HIT_WINDOW` ms of the strumline. + * @return An array of `NoteSprite` objects. + */ + public function getNotesMayHit():Array + { + return notes.members.filter(function(note:NoteSprite) { + return note != null && note.alive && !note.hasBeenHit && note.mayHit; + }); + } + + /** + * Return hold notes that are within `Constants.HIT_WINDOW` ms of the strumline. + * @return An array of `SustainTrail` objects. + */ + public function getHoldNotesHitOrMissed():Array + { + return holdNotes.members.filter(function(holdNote:SustainTrail) { + return holdNote != null && holdNote.alive && (holdNote.hitNote || holdNote.missedNote); + }); + } + + /** + * 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 (target == null) return null; + + for (note in notes.members) + { + if (note == null) continue; + if (note.alive) continue; + + if (note.noteData == target) return note; + } + + return null; + } + + /** + * 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 (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 == target) return holdNote; + } + + return null; + } + + /** + * Call this when resetting the playstate. + */ + public function vwooshNotes():Void + { + var vwooshTime:Float = 0.5; + + for (note in notes.members) + { + if (note == null) continue; + if (!note.alive) continue; + + notes.remove(note); + notesVwoosh.add(note); + + var targetY:Float = FlxG.height + note.y; + if (Preferences.downscroll) targetY = 0 - note.height; + FlxTween.tween(note, {y: targetY}, vwooshTime, + { + ease: FlxEase.expoIn, + onComplete: function(twn) { + note.kill(); + notesVwoosh.remove(note, true); + note.destroy(); + } + }); + } + + for (holdNote in holdNotes.members) + { + if (holdNote == null) continue; + if (!holdNote.alive) continue; + + holdNotes.remove(holdNote); + holdNotesVwoosh.add(holdNote); + + var targetY:Float = FlxG.height + holdNote.y; + if (Preferences.downscroll) targetY = 0 - holdNote.height; + FlxTween.tween(holdNote, {y: targetY}, vwooshTime, + { + ease: FlxEase.expoIn, + onComplete: function(twn) { + holdNote.kill(); + holdNotesVwoosh.remove(holdNote, true); + holdNote.destroy(); + } + }); + } + } + + /** + * Reverse of vwooshNotes, we bring the notes IN (by their offsets) + */ + public function vwooshInNotes():Void + { + var vwooshTime:Float = 0.5; + + 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; + } + }); + } + } + + + /** + * 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; + + // Ensure note data gets reset if the song happens to loop. + // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times. + // I don't remember what bug I was trying to fix by adding this. + // if (conductorInUse.currentStep == 0) nextNoteIndex = 0; + + var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0; + var hitWindowStart:Float = conductorInUse.songPosition - Constants.HIT_WINDOW_MS; + var renderWindowStart:Float = conductorInUse.songPosition + renderDistanceMs; + + for (noteIndex in nextNoteIndex...noteData.length) + { + var note:Null = noteData[noteIndex]; + + if (note == null) continue; // Note is blank + if (note.time < songStart || note.time < hitWindowStart) + { + // Note is in the past, skip it. + nextNoteIndex = noteIndex + 1; + continue; + } + if (note.time > renderWindowStart) break; // Note is too far ahead to render + + var noteSprite:NoteSprite = buildNoteSprite(note); + + if (note.length > 0) + { + noteSprite.holdNoteSprite = buildHoldNoteSprite(note); + } + + nextNoteIndex = noteIndex + 1; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. + + onNoteIncoming.dispatch(noteSprite); + } + + // Update rendering of notes. + for (note in notes.members) + { + if (note == null || !note.alive) continue; + + // Set the note's position. + if (!customPositionData) note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime) + note.yOffset; + + // If the note is miss + var isOffscreen:Bool = Preferences.downscroll ? note.y > FlxG.height : note.y < -note.height; + if (note.handledMiss && isOffscreen) + { + killNote(note); + } + } + + // Update rendering of hold notes. + for (holdNote in holdNotes.members) + { + if (holdNote == null || !holdNote.alive) continue; + + if (conductorInUse.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote) + { + if (isPlayer && !isKeyHeld(holdNote.noteDirection)) + { + // Stopped pressing the hold note. + playStatic(holdNote.noteDirection); + holdNote.missedNote = true; + holdNote.visible = true; + holdNote.alpha = 0.0; // Completely hide the dropped hold note. + } + } + + final magicNumberIGuess:Float = 8; + var renderWindowEnd:Float = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + (renderDistanceMs / magicNumberIGuess); + + if (holdNote.missedNote && conductorInUse.songPosition >= renderWindowEnd) + { + // Hold note is offscreen, kill it. + holdNote.visible = false; + holdNote.kill(); // Do not destroy! Recycling is faster. + } + else if (holdNote.hitNote && holdNote.sustainLength <= 0) + { + // Hold note is completed, kill it. + if (isKeyHeld(holdNote.noteDirection)) + { + playPress(holdNote.noteDirection); + } + else + { + playStatic(holdNote.noteDirection); + } + + if (holdNote.cover != null && isPlayer) + { + holdNote.cover.playEnd(); + } + else if (holdNote.cover != null) + { + // *lightning* *zap* *crackle* + holdNote.cover.visible = false; + holdNote.cover.kill(); + } + + holdNote.visible = false; + holdNote.kill(); + } + else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength)) + { + // Hold note was dropped before completing, keep it in its clipped state. + holdNote.visible = true; + + var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Constants.PIXELS_PER_MS; + + if (!customPositionData) + { + if (Preferences.downscroll) + { + 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) + yOffset + STRUMLINE_SIZE / 2 + holdNote.yOffset; + } + } + + // Clean up the cover. + if (holdNote.cover != null) + { + holdNote.cover.visible = false; + holdNote.cover.kill(); + } + } + else if (conductorInUse.songPosition > holdNote.strumTime && holdNote.hitNote) + { + // Hold note is currently being hit, clip it off. + holdConfirm(holdNote.noteDirection); + holdNote.visible = true; + + holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - conductorInUse.songPosition; + + if (holdNote.sustainLength <= 10) + { + holdNote.visible = false; + } + + if (!customPositionData) + { + if (Preferences.downscroll) + { + holdNote.y = this.y - INITIAL_OFFSET - holdNote.height + STRUMLINE_SIZE / 2; + } + else + { + holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2; + } + } + } + else + { + // Hold note is new, render it normally. + holdNote.visible = true; + + if (!customPositionData) + { + if (Preferences.downscroll) + { + 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) + STRUMLINE_SIZE / 2 + holdNote.yOffset; + } + } + } + } + + // Update rendering of pressed keys. + for (dir in DIRECTIONS) + { + if (isKeyHeld(dir) && getByDirection(dir).getCurrentAnimation() == "static") + { + playPress(dir); + } + } + } + + /** + * Return notes that are within, or way after, `Constants.HIT_WINDOW` ms of the strumline. + * @return An array of `NoteSprite` objects. + */ + public function getNotesOnScreen():Array + { + return notes.members.filter(function(note:NoteSprite) { + return note != null && note.alive && !note.hasBeenHit; + }); + } + + #if FEATURE_GHOST_TAPPING + function updateGhostTapTimer(elapsed:Float):Void + { + // If it's still our turn, don't update the ghost tap timer. + if (getNotesOnScreen().length > 0) return; + + ghostTapTimer -= elapsed; + + if (ghostTapTimer <= 0) + { + ghostTapTimer = 0; + } + } + #end + + /** + * Called when the PlayState skips a large amount of time forward or backward. + */ + public function handleSkippedNotes():Void + { + // By calling clean(), we remove all existing notes so they can be re-added. + clean(); + // By setting noteIndex to 0, the next update will skip past all the notes that are in the past. + 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)); + + 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]; + } + + /** + * Called when the song is reset. + * Removes any special animations and the like. + * Doesn't reset the notes from the chart, that's handled by the PlayState. + */ + public function clean():Void + { + for (note in notes.members) + { + if (note == null) continue; + killNote(note); + } + + for (holdNote in holdNotes.members) + { + if (holdNote == null) continue; + holdNote.kill(); + } + + for (splash in noteSplashes) + { + if (splash == null) continue; + splash.kill(); + } + + for (cover in noteHoldCovers) + { + if (cover == null) continue; + cover.kill(); + } + + heldKeys = [false, false, false, false]; + + for (dir in DIRECTIONS) + { + playStatic(dir); + } + resetScrollSpeed(); + + #if FEATURE_GHOST_TAPPING + ghostTapTimer = 0; + #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(); + + this.noteData = data.copy(); + this.nextNoteIndex = 0; + + // Sort the notes by strumtime. + this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING)); + } + + /** + * 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. + */ + public function hitNote(note:NoteSprite, removeNote:Bool = true):Void + { + playConfirm(note.direction); + note.hasBeenHit = true; + + if (removeNote) + { + killNote(note); + } + else + { + note.alpha = 0.5; + note.desaturate(); + } + + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.hitNote = true; + note.holdNoteSprite.missedNote = false; + + note.holdNoteSprite.sustainLength = (note.holdNoteSprite.strumTime + note.holdNoteSprite.fullSustainLength) - conductorInUse.songPosition; + } + + #if FEATURE_GHOST_TAPPING + ghostTapTimer = Constants.GHOST_TAP_DELAY; + #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; + note.visible = false; + notes.remove(note, false); + note.kill(); + + if (note.holdNoteSprite != null) + { + note.holdNoteSprite.missedNote = true; + note.holdNoteSprite.visible = false; + } + } + + /** + * 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(); + } + + /** + * 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; + if (!noteStyle.isNoteSplashEnabled()) return; + + var splash:NoteSplash = this.constructNoteSplash(); + + if (splash != null) + { + splash.play(direction); + + splash.x = this.x; + splash.x += getXPos(direction); + splash.x += INITIAL_OFFSET; + splash.x += noteStyle.getSplashOffsets()[0] * splash.scale.x; + + splash.y = this.y; + splash.y -= INITIAL_OFFSET; + splash.y += noteStyle.getSplashOffsets()[1] * splash.scale.y; + } + } + + /** + * 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; + if (!noteStyle.isHoldNoteCoverEnabled()) return; + + var cover:NoteHoldCover = this.constructNoteHoldCover(); + + if (cover != null) + { + cover.holdNote = holdNote; + holdNote.cover = cover; + cover.visible = true; + + cover.playStart(); + + cover.x = this.x; + cover.x += getXPos(holdNote.noteDirection); + cover.x += STRUMLINE_SIZE / 2; + cover.x -= cover.width / 2; + cover.x += noteStyle.getHoldCoverOffsets()[0] * cover.scale.x; + cover.x += -12; // hardcoded adjustment, because we are evil. + + cover.y = this.y; + cover.y += INITIAL_OFFSET; + cover.y += STRUMLINE_SIZE / 2; + cover.y += noteStyle.getHoldCoverOffsets()[1] * cover.scale.y; + cover.y += -96; // hardcoded adjustment, because we are evil. + } + } + + /** + * 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(); + + if (noteSprite != null) + { + var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id) ?? this.noteStyle; + noteSprite.setupNoteGraphic(noteKindStyle); + + noteSprite.direction = note.getDirection(); + noteSprite.noteData = note; + + noteSprite.x = this.x; + noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); + noteSprite.x -= (noteSprite.width - Strumline.STRUMLINE_SIZE) / 2; // Center it + noteSprite.x -= NUDGE; + 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(); + + if (holdNoteSprite != null) + { + var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id); + if (noteKindStyle == null) noteKindStyle = NoteKindManager.getNoteStyle(note.kind, null); + if (noteKindStyle == null) noteKindStyle = this.noteStyle; + + holdNoteSprite.setupHoldNoteGraphic(noteKindStyle); + + holdNoteSprite.parentStrumline = this; + holdNoteSprite.noteData = note; + holdNoteSprite.strumTime = note.time; + holdNoteSprite.noteDirection = note.getDirection(); + holdNoteSprite.fullSustainLength = note.length; + holdNoteSprite.sustainLength = note.length; + holdNoteSprite.missedNote = false; + holdNoteSprite.hitNote = false; + holdNoteSprite.visible = true; + holdNoteSprite.alpha = 1.0; + + holdNoteSprite.x = this.x; + holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); + holdNoteSprite.x += STRUMLINE_SIZE / 2; + holdNoteSprite.x -= holdNoteSprite.width / 2; + holdNoteSprite.y = -9999; + } + + return holdNoteSprite; + } + + /** + * Custom recycling behavior for note splashes. + */ + function constructNoteSplash():NoteSplash + { + var result:NoteSplash = null; + + // If we haven't filled the pool yet... + if (noteSplashes.length < noteSplashes.maxSize) + { + // Create a new note splash. + result = new NoteSplash(noteStyle); + this.noteSplashes.add(result); + } + else + { + // Else, find a note splash which is inactive so we can revive it. + result = this.noteSplashes.getFirstAvailable(); + + if (result != null) + { + result.revive(); + } + else + { + // The note splash pool is full and all note splashes are active, + // so we just pick one at random to destroy and restart. + result = FlxG.random.getObject(this.noteSplashes.members); + } + } + + return result; + } + + /** + * Custom recycling behavior for note hold covers. + */ + function constructNoteHoldCover():NoteHoldCover + { + var result:NoteHoldCover = null; + + // If we haven't filled the pool yet... + if (noteHoldCovers.length < noteHoldCovers.maxSize) + { + // Create a new note hold cover. + result = new NoteHoldCover(noteStyle); + this.noteHoldCovers.add(result); + } + else + { + // Else, find a note splash which is inactive so we can revive it. + result = this.noteHoldCovers.getFirstAvailable(); + + if (result != null) + { + result.revive(); + } + else + { + // The note hold cover pool is full and all note hold covers are active, + // so we just pick one at random to destroy and restart. + result = FlxG.random.getObject(this.noteHoldCovers.members); + } + } + + return result; + } + + /** + * Custom recycling behavior for note sprites. + */ + function constructNoteSprite():NoteSprite + { + var result:NoteSprite = null; + + // Else, find a note which is inactive so we can revive it. + result = this.notes.getFirstAvailable(); + + if (result != null) + { + // Revive and reuse the note. + result.revive(); + } + else + { + // The note sprite pool is full and all note splashes are active. + // We have to create a new note. + result = new NoteSprite(noteStyle); + this.notes.add(result); + } + + return result; + } + + /** + * Custom recycling behavior for hold note sprites. + */ + function constructHoldNoteSprite():SustainTrail + { + var result:SustainTrail = null; + + // Else, find a note which is inactive so we can revive it. + result = this.holdNotes.getFirstAvailable(); + + if (result != null) + { + // Revive and reuse the note. + result.revive(); + } + else + { + // The note sprite pool is full and all note splashes are active. + // We have to create a new note. + result = new SustainTrail(0, 0, noteStyle); + this.holdNotes.add(result); + } + + return result; + } + + function getXPos(direction:NoteDirection):Float + { + return switch (direction) + { + case NoteDirection.LEFT: 0; + case NoteDirection.DOWN: 0 + (1 * Strumline.NOTE_SPACING); + case NoteDirection.UP: 0 + (2 * Strumline.NOTE_SPACING); + case NoteDirection.RIGHT: 0 + (3 * Strumline.NOTE_SPACING); + default: 0; + } + } + + /** + * Apply a small animation which moves the arrow down and fades it in. + * Only plays at the start of Free Play songs. + * + * Note that modifying the offset of the whole strumline won't have the + * @param arrow The arrow to animate. + * @param index The index of the arrow in the strumline. + */ + function fadeInArrow(index:Int, arrow:StrumlineNote):Void + { + arrow.y -= 10; + arrow.alpha = 0.0; + 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()) + { + fadeInArrow(index, arrow); + } + } + + /** + * 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); + } + + /** + * 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:Float = Math.POSITIVE_INFINITY; + for (member in group.members) + { + if (member == null) continue; + // SKIP THE BACKGROUND + if (member == this.background) continue; + + var minY:Float; + if (member.flixelType == SPRITEGROUP) + { + minY = (cast member : FlxSpriteGroup).findMinY(); + } + else + { + minY = member.y; + } + + if (minY < value) value = minY; + } + return value; + } + + /** + * 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:Float = Math.NEGATIVE_INFINITY; + for (member in group.members) + { + if (member == null) continue; + // SKIP THE BACKGROUND + if (member == this.background) continue; + + var maxY:Float; + if (member.flixelType == SPRITEGROUP) + { + maxY = (cast member : FlxSpriteGroup).findMaxY(); + } + else + { + maxY = member.y + member.height; + } + + if (maxY > value) value = maxY; + } + return value; + } +} diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx new file mode 100644 index 00000000..324234cc --- /dev/null +++ b/source/funkin/play/notes/StrumlineNote.hx @@ -0,0 +1,213 @@ +package funkin.play.notes; + +import funkin.play.notes.notestyle.NoteStyle; +import flixel.graphics.frames.FlxAtlasFrames; +import funkin.graphics.FunkinSprite; +import funkin.play.notes.NoteSprite; + +/** + * The actual receptor that you see on screen. + */ +class StrumlineNote extends FunkinSprite +{ + /** + * Whether this strumline note is on the player's side or the opponent's side. + */ + public var isPlayer(default, null):Bool; + + /** + * The direction which this strumline note is facing. + */ + public var direction(default, set):NoteDirection; + + function set_direction(value:NoteDirection):NoteDirection + { + this.direction = value; + 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`. + */ + public var forceActive:Bool = false; + + /** + * How long to continue the hold note animation after a note is pressed. + */ + static final CONFIRM_HOLD_TIME:Float = 0.1; + + /** + * How long the hold note animation has been playing after a note is pressed. + */ + var confirmHoldTimer:Float = -1; + + public function new(noteStyle:NoteStyle, isPlayer:Bool, direction:NoteDirection) + { + super(0, 0); + + this.isPlayer = isPlayer; + + this.direction = direction; + + setup(noteStyle); + + this.animation.onFrameChange.add(onAnimationFrame); + this.animation.onFinish.add(onAnimationFinished); + + // Must be true for animations to play. + this.active = true; + } + + function onAnimationFrame(name:String, frameNumber:Int, frameIndex:Int):Void + { + // Do nothing. + } + + function onAnimationFinished(name:String):Void + { + // Run a timer before we stop playing the confirm animation. + // On opponent, this prevent issues with hold notes. + // On player, this allows holding the confirm key to fall back to press. + if (name == 'confirm') + { + confirmHoldTimer = 0; + } + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + centerOrigin(); + + if (confirmHoldTimer >= 0) + { + confirmHoldTimer += elapsed; + + // Ensure the opponent stops holding the key after a certain amount of time. + if (confirmHoldTimer >= CONFIRM_HOLD_TIME) + { + confirmHoldTimer = -1; + playStatic(); + } + } + } + + function setup(noteStyle:NoteStyle):Void + { + if (noteStyle == null) + { + // If you get an exception on this line, check the debug console. + // You probably have a parsing error in your note style's JSON file. + throw "FATAL ERROR: Attempted to initialize PlayState with an invalid NoteStyle."; + } + + noteStyle.applyStrumlineFrames(this); + noteStyle.applyStrumlineAnimations(this, this.direction); + + var scale = noteStyle.getStrumlineScale(); + this.scale.set(scale, scale); + this.updateHitbox(); + noteStyle.applyStrumlineOffsets(this); + + this.playStatic(); + } + + public function playAnimation(name:String = 'static', force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void + { + this.animation.play(name, force, reversed, startFrame); + + centerOffsets(); + centerOrigin(); + } + + public function playStatic():Void + { + this.active = (forceActive || isAnimationDynamic('static')); + this.playAnimation('static', true); + } + + public function playPress():Void + { + this.active = (forceActive || isAnimationDynamic('press')); + this.playAnimation('press', true); + } + + public function playConfirm():Void + { + this.active = (forceActive || isAnimationDynamic('confirm')); + this.playAnimation('confirm', true); + } + + public function isConfirm():Bool + { + return getCurrentAnimation().startsWith('confirm'); + } + + public function holdConfirm():Void + { + this.active = true; + + if (getCurrentAnimation() == "confirm-hold") + { + return; + } + else if (getCurrentAnimation() == "confirm") + { + if (isAnimationFinished()) + { + this.confirmHoldTimer = -1; + this.playAnimation('confirm-hold', false, false); + } + } + else + { + this.playAnimation('confirm', false, false); + } + } + + /** + * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the sprite is BROKEN!), + * returns an empty string to prevent NPEs. + */ + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + public function isAnimationFinished():Bool + { + return this.animation.finished; + } + + static final DEFAULT_OFFSET:Int = 13; + + /** + * Adjusts the position of the sprite's graphic relative to the hitbox. + */ + function fixOffsets():Void + { + // Automatically center the bounding box within the graphic. + this.centerOffsets(); + + if (getCurrentAnimation() == "confirm") + { + // Move the graphic down and to the right to compensate for + // the "glow" effect on the strumline note. + this.offset.x -= DEFAULT_OFFSET; + this.offset.y -= DEFAULT_OFFSET; + } + else + { + this.centerOrigin(); + } + } +} diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx new file mode 100644 index 00000000..36dc3bfa --- /dev/null +++ b/source/funkin/play/notes/SustainTrail.hx @@ -0,0 +1,454 @@ +package funkin.play.notes; + +import funkin.play.notes.notestyle.NoteStyle; +import funkin.data.song.SongData.SongNoteData; +import flixel.FlxSprite; +import flixel.graphics.FlxGraphic; +import flixel.graphics.tile.FlxDrawTrianglesItem.DrawData; +import flixel.math.FlxMath; + +/** + * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note + * trail at a certain time. + * The whole `FlxGraphic` is used as a texture map. See the `NOTE_hold_assets.fla` file for specifics + * on how it should be constructed. + * + * @author MtH + */ +class SustainTrail extends FlxSprite +{ + /** + * The triangles corresponding to the hold, followed by the endcap. + * `top left, top right, bottom left` + * `top left, bottom left, bottom right` + */ + static final TRIANGLE_VERTEX_INDICES:Array = [0, 1, 2, 1, 2, 3, 4, 5, 6, 5, 6, 7]; + + public var strumTime:Float = 0; // millis + public var noteDirection:NoteDirection = 0; + public var sustainLength(default, set):Float = 0; // millis + public var fullSustainLength:Float = 0; + public var noteData:Null; + public var parentStrumline:Strumline; + + 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. + */ + public var hitNote:Bool = false; + + /** + * Set to `true` if the user missed the note or released the sustain. + * Should make the trail transparent. + */ + public var missedNote:Bool = false; + + /** + * Set to `true` after handling additional logic for missing notes. + */ + public var handledMiss:Bool = false; + + // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support! + + /** + * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair). + */ + public var vertices:DrawData = new DrawData(); + + /** + * A `Vector` of integers or indexes, where every three indexes define a triangle. + */ + public var indices:DrawData = new DrawData(); + + /** + * A `Vector` of normalized coordinates used to apply texture mapping. + */ + public var uvtData:DrawData = new DrawData(); + + private var processedGraphic:FlxGraphic; + + private var zoom:Float = 1; + + /** + * What part of the trail's end actually represents the end of the note. + * This can be used to have a little bit sticking out. + */ + public var endOffset:Float = 0.5; // 0.73 is roughly the bottom of the sprite in the normal graphic! + + /** + * At what point the bottom for the trail's end should be clipped off. + * Used in cases where there's an extra bit of the graphic on the bottom to avoid antialiasing issues with overflow. + */ + public var bottomClip:Float = 0.9; + + /** + * Whether the note will recieve custom vertex data + */ + public var customVertexData:Bool = false; + + public var isPixel:Bool; + public var noteStyleOffsets:Array; + + var graphicWidth:Float = 0; + var graphicHeight:Float = 0; + + /** + * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?) + * @param NoteData + * @param SustainLength Length in milliseconds. + * @param fileName + */ + public function new(noteDirection:NoteDirection, sustainLength:Float, noteStyle:NoteStyle) + { + super(0, 0); + + // BASIC SETUP + this.sustainLength = sustainLength; + this.fullSustainLength = sustainLength; + this.noteDirection = noteDirection; + + setupHoldNoteGraphic(noteStyle); + noteStyleOffsets = noteStyle.getHoldNoteOffsets(); + + setIndices(TRIANGLE_VERTEX_INDICES); + + this.active = true; // This NEEDS to be true for the note to be drawn! + } + + /** + * Sets the indices for the triangles. + * @param indices The indices to set. + */ + public function setIndices(indices:Array):Void + { + if (this.indices.length == indices.length) + { + for (i in 0...indices.length) + { + this.indices[i] = indices[i]; + } + } + else + { + this.indices = new DrawData(indices.length, false, indices); + } + } + + /** + * Sets the vertices for the triangles. + * @param vertices The vertices to set. + */ + public function setVertices(vertices:Array):Void + { + if (this.vertices.length == vertices.length) + { + for (i in 0...vertices.length) + { + this.vertices[i] = vertices[i]; + } + } + else + { + this.vertices = new DrawData(vertices.length, false, vertices); + } + } + + /** + * Sets the UV data for the triangles. + * @param uvtData The UV data to set. + */ + public function setUVTData(uvtData:Array):Void + { + if (this.uvtData.length == uvtData.length) + { + for (i in 0...uvtData.length) + { + this.uvtData[i] = uvtData[i]; + } + } + else + { + this.uvtData = new DrawData(uvtData.length, false, uvtData); + } + } + + /** + * Creates hold note graphic and applies correct zooming + * @param noteStyle The note style + */ + public function setupHoldNoteGraphic(noteStyle:NoteStyle):Void + { + loadGraphic(noteStyle.getHoldNoteAssetPath()); + + antialiasing = true; + + this.isPixel = noteStyle.isHoldNotePixel(); + if (isPixel) + { + endOffset = bottomClip = 1; + antialiasing = false; + } + else + { + endOffset = 0.5; + bottomClip = 0.9; + } + + zoom = 1.0; + zoom *= noteStyle.fetchHoldNoteScale(); + + // CALCULATE SIZE + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + graphicHeight = sustainHeight(sustainLength, parentStrumline?.scrollSpeed ?? 1.0); + // instead of scrollSpeed, PlayState.SONG.speed + + flipY = Preferences.downscroll; + + // alpha = 0.6; + alpha = 1.0; + // calls updateColorTransform(), which initializes processedGraphic! + updateColorTransform(); + + updateClipping(); + } + + function getBaseScrollSpeed() + { + return (PlayState.instance?.currentChart?.scrollSpeed ?? 1.0); + } + + var previousScrollSpeed:Float = 1; + + override function update(elapsed) + { + super.update(elapsed); + if (previousScrollSpeed != (parentStrumline?.scrollSpeed ?? 1.0)) + { + triggerRedraw(); + } + previousScrollSpeed = parentStrumline?.scrollSpeed ?? 1.0; + } + + /** + * Calculates height of a sustain note for a given length (milliseconds) and scroll speed. + * @param susLength The length of the sustain note in milliseconds. + * @param scroll The current scroll speed. + */ + public static inline function sustainHeight(susLength:Float, scroll:Float) + { + return (susLength * Constants.PIXELS_PER_MS * scroll); + } + + function set_sustainLength(s:Float):Float + { + if (s < 0.0) s = 0.0; + + if (sustainLength == s) return s; + this.sustainLength = s; + triggerRedraw(); + return this.sustainLength; + } + + function triggerRedraw() + { + graphicHeight = sustainHeight(sustainLength, parentStrumline?.scrollSpeed ?? 1.0); + updateClipping(); + updateHitbox(); + } + + public override function updateHitbox():Void + { + width = graphicWidth; + height = graphicHeight; + offset.set(noteStyleOffsets[0], noteStyleOffsets[1]); + origin.set(width * 0.5, height * 0.5); + } + + /** + * Sets up new vertex and UV data to clip the trail. + * If flipY is true, top and bottom bounds swap places. + * @param songTime The time to clip the note at, in milliseconds. + */ + public function updateClipping(songTime:Float = 0):Void + { + if (graphic == null || customVertexData) + { + return; + } + + var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), parentStrumline?.scrollSpeed ?? 1.0), 0, graphicHeight); + if (clipHeight <= 0.1) + { + visible = false; + return; + } + else + { + visible = true; + } + + var bottomHeight:Float = graphic.height * zoom * endOffset; + var partHeight:Float = clipHeight - bottomHeight; + + // ===HOLD VERTICES== + // Top left + vertices[0 * 2] = 0.0; // Inline with left side + vertices[0 * 2 + 1] = flipY ? clipHeight : graphicHeight - clipHeight; + + // Top right + vertices[1 * 2] = graphicWidth; + vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex + + // Bottom left + vertices[2 * 2] = 0.0; // Inline with left side + vertices[2 * 2 + 1] = if (partHeight > 0) + { + // flipY makes the sustain render upside down. + flipY ? 0.0 + bottomHeight : vertices[1] + partHeight; + } + else + { + vertices[0 * 2 + 1]; // Inline with top left vertex (no partHeight available) + } + + // Bottom right + vertices[3 * 2] = graphicWidth; + vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex + + // ===HOLD UVs=== + + // The UVs are a bit more complicated. + // UV coordinates are normalized, so they range from 0 to 1. + // We are expecting an image containing 8 horizontal segments, each representing a different colored hold note followed by its end cap. + + uvtData[0 * 2] = 1 / 4 * (noteDirection % 4); // 0%/25%/50%/75% of the way through the image + uvtData[0 * 2 + 1] = (-partHeight) / graphic.height / zoom; // top bound + // Top left + + // Top right + uvtData[1 * 2] = uvtData[0 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left) + uvtData[1 * 2 + 1] = uvtData[0 * 2 + 1]; // top bound + + // Bottom left + uvtData[2 * 2] = uvtData[0 * 2]; // 0%/25%/50%/75% of the way through the image + uvtData[2 * 2 + 1] = 0.0; // bottom bound + + // Bottom right + uvtData[3 * 2] = uvtData[1 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left) + uvtData[3 * 2 + 1] = uvtData[2 * 2 + 1]; // bottom bound + + // === END CAP VERTICES === + // Top left + vertices[4 * 2] = vertices[2 * 2]; // Inline with bottom left vertex of hold + vertices[4 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex of hold + + // Top right + vertices[5 * 2] = vertices[3 * 2]; // Inline with bottom right vertex of hold + vertices[5 * 2 + 1] = vertices[3 * 2 + 1]; // Inline with bottom right vertex of hold + + // Bottom left + vertices[6 * 2] = vertices[2 * 2]; // Inline with left side + vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (graphicHeight + graphic.height * (bottomClip - endOffset) * zoom); + + // Bottom right + vertices[7 * 2] = vertices[3 * 2]; // Inline with right side + vertices[7 * 2 + 1] = vertices[6 * 2 + 1]; // Inline with bottom of end cap + + // === END CAP UVs === + // Top left + uvtData[4 * 2] = uvtData[2 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold) + uvtData[4 * 2 + 1] = if (partHeight > 0) + { + 0; + } + else + { + (bottomHeight - clipHeight) / zoom / graphic.height; + }; + + // Top right + uvtData[5 * 2] = uvtData[4 * 2] + 1 / 8; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap) + uvtData[5 * 2 + 1] = uvtData[4 * 2 + 1]; // top bound + + // Bottom left + uvtData[6 * 2] = uvtData[4 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold) + uvtData[6 * 2 + 1] = bottomClip; // bottom bound + + // Bottom right + uvtData[7 * 2] = uvtData[5 * 2]; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap) + uvtData[7 * 2 + 1] = uvtData[6 * 2 + 1]; // bottom bound + } + + @:access(flixel.FlxCamera) + override public function draw():Void + { + if (alpha == 0 || graphic == null || vertices == null) return; + + for (camera in cameras) + { + if (!camera.visible || !camera.exists) continue; + // if (!isOnScreen(camera)) continue; // TODO: Update this code to make it work properly. + + getScreenPosition(_point, camera).subtractPoint(offset); + camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing); + } + + #if FLX_DEBUG + if (FlxG.debugger.drawDebug) drawDebug(); + #end + } + + public override function kill():Void + { + super.kill(); + + strumTime = 0; + noteDirection = 0; + sustainLength = 0; + fullSustainLength = 0; + noteData = null; + + hitNote = false; + missedNote = false; + } + + public override function revive():Void + { + super.revive(); + + strumTime = 0; + noteDirection = 0; + sustainLength = 0; + fullSustainLength = 0; + noteData = null; + + hitNote = false; + missedNote = false; + handledMiss = false; + } + + override public function destroy():Void + { + vertices = null; + indices = null; + uvtData = null; + processedGraphic.destroy(); + + super.destroy(); + } + + override function updateColorTransform():Void + { + super.updateColorTransform(); + if (processedGraphic != null) processedGraphic.destroy(); + processedGraphic = FlxGraphic.fromGraphic(graphic, true); + processedGraphic.bitmap.colorTransform(processedGraphic.bitmap.rect, colorTransform); + } +} diff --git a/source/funkin/play/notes/notekind/NoteKind.hx b/source/funkin/play/notes/notekind/NoteKind.hx new file mode 100644 index 00000000..9cc125a3 --- /dev/null +++ b/source/funkin/play/notes/notekind/NoteKind.hx @@ -0,0 +1,120 @@ +package funkin.play.notes.notekind; + +import funkin.modding.IScriptedClass.INoteScriptedClass; +import funkin.modding.events.ScriptEvent; + +/** + * Class for note scripts + */ +class NoteKind implements INoteScriptedClass +{ + /** + * The name of the note kind + */ + public var noteKind:String; + + /** + * Description used in chart editor + */ + public var description:String; + + /** + * Custom note style + */ + public var noteStyleId:Null; + + /** + * Custom parameters for the chart editor + */ + public var params:Array; + + public function new(noteKind:String, description:String = "", ?noteStyleId:String, ?params:Array) + { + this.noteKind = noteKind; + this.description = description; + this.noteStyleId = noteStyleId; + this.params = params ?? []; + } + + public function toString():String + { + return noteKind; + } + + /** + * Retrieve all notes of this kind + * @return Array + */ + function getNotes():Array + { + var allNotes:Array = PlayState.instance.playerStrumline.notes.members.concat(PlayState.instance.opponentStrumline.notes.members); + return allNotes.filter(function(note:NoteSprite) { + return note != null && note.noteData.kind == this.noteKind; + }); + } + + public function onScriptEvent(event:ScriptEvent):Void {} + + public function onCreate(event:ScriptEvent):Void {} + + public function onDestroy(event:ScriptEvent):Void {} + + public function onUpdate(event:UpdateScriptEvent):Void {} + + public function onNoteIncoming(event:NoteScriptEvent):Void {} + + public function onNoteHit(event:HitNoteScriptEvent):Void {} + + public function onNoteMiss(event:NoteScriptEvent):Void {} + + public function onNoteHoldDrop(event:HoldNoteScriptEvent) {} +} + +/** + * Abstract for setting the type of the `NoteKindParam` + * This was supposed to be an enum but polymod kept being annoying + */ +abstract NoteKindParamType(String) from String to String +{ + public static final STRING:String = 'String'; + + public static final INT:String = 'Int'; + + public static final FLOAT:String = 'Float'; +} + +typedef NoteKindParamData = +{ + /** + * If `min` is null, there is no minimum + */ + ?min:Null, + + /** + * If `max` is null, there is no maximum + */ + ?max:Null, + + /** + * If `step` is null, it will use 1.0 + */ + ?step:Null, + + /** + * If `precision` is null, there will be 0 decimal places + */ + ?precision:Null, + + ?defaultValue:Dynamic +} + +/** + * Typedef for creating custom parameters in the chart editor + */ +typedef NoteKindParam = +{ + name:String, + description:String, + type:NoteKindParamType, + ?data:NoteKindParamData +} diff --git a/source/funkin/play/notes/notekind/NoteKindManager.hx b/source/funkin/play/notes/notekind/NoteKindManager.hx new file mode 100644 index 00000000..f1f0c11f --- /dev/null +++ b/source/funkin/play/notes/notekind/NoteKindManager.hx @@ -0,0 +1,132 @@ +package funkin.play.notes.notekind; + +import funkin.data.song.SongData.SongNoteData; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.modding.events.ScriptEvent; +import funkin.ui.debug.charting.util.ChartEditorDropdowns; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.notes.notekind.ScriptedNoteKind; +import funkin.play.notes.notekind.NoteKind.NoteKindParam; + +class NoteKindManager +{ + static var noteKinds:Map = []; + + public static function loadScripts():Void + { + var scriptedClassName:Array = ScriptedNoteKind.listScriptClasses(); + if (scriptedClassName.length > 0) + { + trace('Instantiating ${scriptedClassName.length} scripted note kind(s)...'); + for (scriptedClass in scriptedClassName) + { + try + { + var script:NoteKind = ScriptedNoteKind.init(scriptedClass, "unknown"); + trace(' Initialized scripted note kind: ${script.noteKind}'); + noteKinds.set(script.noteKind, script); + ChartEditorDropdowns.NOTE_KINDS.set(script.noteKind, script.description); + } + catch (e) + { + trace(' FAILED to instantiate scripted note kind: ${scriptedClass}'); + trace(e); + } + } + } + } + + /** + * Calls the given event for note kind scripts + * @param event The event + */ + public static function callEvent(event:ScriptEvent):Void + { + // if it is a note script event, + // then only call the event for the specific note kind script + if (Std.isOfType(event, NoteScriptEvent)) + { + var noteEvent:NoteScriptEvent = cast(event, NoteScriptEvent); + + var noteKind:NoteKind = noteKinds.get(noteEvent?.note?.kind); + + if (noteKind != null) + { + ScriptEventDispatcher.callEvent(noteKind, event); + } + } + else // call the event for all note kind scripts + { + for (noteKind in noteKinds.iterator()) + { + ScriptEventDispatcher.callEvent(noteKind, event); + } + } + } + + /** + * Retrieve the note style from the given note kind + * @param noteKind note kind name + * @param suffix Used for song note styles + * @return NoteStyle + */ + public static function getNoteStyle(noteKind:String, ?suffix:String):Null + { + var noteStyleId:Null = getNoteStyleId(noteKind, suffix); + + if (noteStyleId == null) + { + return null; + } + + return NoteStyleRegistry.instance.fetchEntry(noteStyleId); + } + + public static function listNoteStyles(songNoteDatas:Array):Array + { + var results:Array = []; + for (songNoteData in songNoteDatas) + { + var noteStyle:NoteStyle = getNoteStyle(songNoteData.kind, null); + } + return results; + } + + /** + * Retrieve the note style id from the given note kind + * @param noteKind Note kind name + * @param suffix Used for song note styles + * @return Null + */ + public static function getNoteStyleId(noteKind:String, ?suffix:String):Null + { + if (suffix == '') + { + suffix = null; + } + + var noteStyleId:Null = noteKinds.get(noteKind)?.noteStyleId; + if (noteStyleId != null && suffix != null) + { + noteStyleId = NoteStyleRegistry.instance.hasEntry('$noteStyleId-$suffix') ? '$noteStyleId-$suffix' : noteStyleId; + } + + return noteStyleId; + } + + /** + * Retrive custom params of the given note kind + * @param noteKind Name of the note kind + * @return Array + */ + public static function getParams(noteKind:Null):Array + { + if (noteKind == null) + { + return []; + } + + return noteKinds.get(noteKind)?.params ?? []; + } +} diff --git a/source/funkin/play/notes/notekind/ScriptedNoteKind.hx b/source/funkin/play/notes/notekind/ScriptedNoteKind.hx new file mode 100644 index 00000000..cd178139 --- /dev/null +++ b/source/funkin/play/notes/notekind/ScriptedNoteKind.hx @@ -0,0 +1,9 @@ +package funkin.play.notes.notekind; + +/** + * A script that can be tied to a NoteKind. + * Create a scripted class that extends NoteKind, + * then call `super('noteKind')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedNoteKind extends NoteKind implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx new file mode 100644 index 00000000..bce2bcd4 --- /dev/null +++ b/source/funkin/play/notes/notestyle/NoteStyle.hx @@ -0,0 +1,1196 @@ +package funkin.play.notes.notestyle; + +import funkin.play.Countdown; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.data.animation.AnimationData; +import funkin.data.IRegistryEntry; +import funkin.graphics.FunkinSprite; +import funkin.data.notestyle.NoteStyleData; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.util.assets.FlxAnimationUtil; + +using funkin.data.animation.AnimationData.AnimationDataUtil; + +/** + * Holds the data for what assets to use for a note style, + * and provides convenience methods for building sprites based on them. + */ +@:nullSafety +class NoteStyle implements IRegistryEntry +{ + /** + * Note style data as parsed from the JSON file. + */ + public final _data:NoteStyleData; + + /** + * The note style to use if this one doesn't have a certain asset. + * This can be recursive, ehe. + */ + var fallback(get, never):Null; + + function get_fallback():Null + { + if (_data == null || _data.fallback == null) return null; + return NoteStyleRegistry.instance.fetchEntry(_data.fallback); + } + + /** + * @param id The ID of the JSON file to parse. + */ + public function new(id:String) + { + this.id = id; + _data = _fetchData(id); + } + + /** + * Get the readable name of the note style. + * @return String + */ + public function getName():String + { + return _data.name; + } + + /** + * Get the author of the note style. + * @return String + */ + public function getAuthor():String + { + return _data.author; + } + + /** + * Get the note style ID of the parent note style. + * @return The string ID, or `null` if there is no parent. + */ + public function getFallbackID():Null + { + return _data.fallback; + } + + public function buildNoteSprite(target:NoteSprite):Void + { + // Apply the note sprite frames. + var atlas:Null = buildNoteFrames(false); + + if (atlas == null) + { + throw 'Could not load spritesheet for note style: $id'; + } + + target.frames = atlas; + + target.antialiasing = !(_data.assets?.note?.isPixel ?? false); + + // Apply the animations. + buildNoteAnimations(target); + + // Set the scale. + var scale = getNoteScale(); + target.scale.set(scale, scale); + target.updateHitbox(); + } + + function getNoteAssetLibrary():Null + { + // library:path + var parts = getNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return null; + return parts[0]; + } + + var noteFrames:Null = null; + + function buildNoteFrames(force:Bool = false):Null + { + var noteAssetPath = getNoteAssetPath(); + if (noteAssetPath == null) + { + FlxG.log.warn('Note asset path not found: ${id}'); + return null; + } + + if (!FunkinSprite.isTextureCached(Paths.image(noteAssetPath))) + { + FlxG.log.warn('Note texture is not cached: ${noteAssetPath}'); + } + + // Purge the note frames if the cached atlas is invalid. + @:nullSafety(Off) + { + if (noteFrames?.parent?.isDestroyed ?? true) noteFrames = null; + } + + if (noteFrames != null && !force) return noteFrames; + + noteFrames = Paths.getSparrowAtlas(noteAssetPath, getAssetLibrary(getNoteAssetPath(true))); + + if (noteFrames == null) + { + throw 'Could not load note frames for note style: $id'; + } + + return noteFrames; + } + + function getNoteAssetPath(raw:Bool = false):Null + { + if (raw) + { + var rawPath:Null = _data?.assets?.note?.assetPath; + if (rawPath == null) return fallback?.getNoteAssetPath(true); + return rawPath; + } + + // library:path + var parts = getNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return getNoteAssetPath(true); + return parts[1]; + } + + function buildNoteAnimations(target:NoteSprite):Void + { + var leftData:Null = fetchNoteAnimationData(LEFT); + if (leftData != null) target.animation.addByPrefix('purpleScroll', leftData.prefix ?? '', leftData.frameRate ?? 24, leftData.looped ?? false, + leftData.flipX, leftData.flipY); + var downData:Null = fetchNoteAnimationData(DOWN); + if (downData != null) target.animation.addByPrefix('blueScroll', downData.prefix ?? '', downData.frameRate ?? 24, downData.looped ?? false, + downData.flipX, downData.flipY); + var upData:Null = fetchNoteAnimationData(UP); + if (upData != null) target.animation.addByPrefix('greenScroll', upData.prefix ?? '', upData.frameRate ?? 24, upData.looped ?? false, upData.flipX, + upData.flipY); + var rightData:Null = fetchNoteAnimationData(RIGHT); + if (rightData != null) target.animation.addByPrefix('redScroll', rightData.prefix ?? '', rightData.frameRate ?? 24, rightData.looped ?? false, + rightData.flipX, rightData.flipY); + } + + public function isNoteAnimated():Bool + { + return _data.assets?.note?.animated ?? fallback?.isNoteAnimated() ?? false; + } + + public function getNoteScale():Float + { + return _data.assets?.note?.scale ?? fallback?.getNoteScale() ?? 1.0; + } + + function fetchNoteAnimationData(dir:NoteDirection):Null + { + var result:Null = switch (dir) + { + case LEFT: _data.assets?.note?.data?.left?.toNamed(); + case DOWN: _data.assets?.note?.data?.down?.toNamed(); + case UP: _data.assets?.note?.data?.up?.toNamed(); + case RIGHT: _data.assets?.note?.data?.right?.toNamed(); + }; + + return result ?? fallback?.fetchNoteAnimationData(dir); + } + + public function getHoldNoteAssetPath(raw:Bool = false):Null + { + if (raw) + { + var rawPath:Null = _data?.assets?.holdNote?.assetPath; + return rawPath ?? fallback?.getHoldNoteAssetPath(true); + } + + // library:path + var parts = getHoldNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return Paths.image(parts[0]); + return Paths.image(parts[1], parts[0]); + } + + public function isHoldNotePixel():Bool + { + return _data?.assets?.holdNote?.isPixel ?? fallback?.isHoldNotePixel() ?? false; + } + + public function fetchHoldNoteScale():Float + { + return _data?.assets?.holdNote?.scale ?? fallback?.fetchHoldNoteScale() ?? 1.0; + } + + public function getHoldNoteOffsets():Array + { + return _data?.assets?.holdNote?.offsets ?? fallback?.getHoldNoteOffsets() ?? [0.0, 0.0]; + } + + public function applyStrumlineFrames(target:StrumlineNote):Void + { + // TODO: Add support for multi-Sparrow. + // Will be less annoying after this is merged: https://github.com/HaxeFlixel/flixel/pull/2772 + + var atlas:FlxAtlasFrames = Paths.getSparrowAtlas(getStrumlineAssetPath() ?? '', getAssetLibrary(getStrumlineAssetPath(true))); + + if (atlas == null) + { + throw 'Could not load spritesheet for note style: $id'; + } + + target.frames = atlas; + + target.scale.set(_data.assets.noteStrumline?.scale ?? 1.0); + target.antialiasing = !(_data.assets.noteStrumline?.isPixel ?? false); + } + + function getStrumlineAssetPath(raw:Bool = false):Null + { + if (raw) + { + return _data?.assets?.noteStrumline?.assetPath ?? fallback?.getStrumlineAssetPath(true); + } + + // library:path + var parts = getStrumlineAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return getStrumlineAssetPath(true); + return parts[1]; + } + + public function applyStrumlineAnimations(target:StrumlineNote, dir:NoteDirection):Void + { + FlxAnimationUtil.addAtlasAnimations(target, getStrumlineAnimationData(dir)); + } + + /** + * Fetch the animation data for the strumline. + * NOTE: This function only queries the fallback note style if all the animations are missing for a given direction. + * + * @param dir The direction to fetch the animation data for. + * @return The animation data for the strumline in that direction. + */ + function getStrumlineAnimationData(dir:NoteDirection):Array + { + var result:Array> = switch (dir) + { + case NoteDirection.LEFT: + [ + _data.assets.noteStrumline?.data?.leftStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.leftPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.leftConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.leftConfirmHold?.toNamed('confirm-hold'), + ]; + case NoteDirection.DOWN: [ + _data.assets.noteStrumline?.data?.downStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.downPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.downConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.downConfirmHold?.toNamed('confirm-hold'), + ]; + case NoteDirection.UP: [ + _data.assets.noteStrumline?.data?.upStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.upPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.upConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.upConfirmHold?.toNamed('confirm-hold'), + ]; + case NoteDirection.RIGHT: [ + _data.assets.noteStrumline?.data?.rightStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.rightPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.rightConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.rightConfirmHold?.toNamed('confirm-hold'), + ]; + default: []; + }; + + // New variable so we can change the type. + var filteredResult:Array = thx.Arrays.filterNull(result); + + if (filteredResult.length == 0) return fallback?.getStrumlineAnimationData(dir) ?? []; + + return filteredResult; + } + + public function getStrumlineOffsets():Array + { + return _data?.assets?.noteStrumline?.offsets ?? fallback?.getStrumlineOffsets() ?? [0.0, 0.0]; + } + + public function applyStrumlineOffsets(target:StrumlineNote):Void + { + var offsets = getStrumlineOffsets(); + target.x += offsets[0]; + target.y += offsets[1]; + } + + public function getStrumlineScale():Float + { + return _data?.assets?.noteStrumline?.scale ?? fallback?.getStrumlineScale() ?? 1.0; + } + + public function isNoteSplashEnabled():Bool + { + return _data?.assets?.noteSplash?.data?.enabled ?? fallback?.isNoteSplashEnabled() ?? false; + } + + public function isHoldNoteCoverEnabled():Bool + { + return _data?.assets?.holdNoteCover?.data?.enabled ?? fallback?.isHoldNoteCoverEnabled() ?? false; + } + + /** + * Build a sprite for the given step of the countdown. + * @param step + * @return A `FunkinSprite`, or `null` if no graphic is available for this step. + */ + public function buildCountdownSprite(step:Countdown.CountdownStep):Null + { + var result = new FunkinSprite(); + + switch (step) + { + case THREE: + if (_data.assets.countdownThree == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownThree?.scale ?? 1.0; + result.scale.y = _data.assets.countdownThree?.scale ?? 1.0; + case TWO: + if (_data.assets.countdownTwo == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownTwo?.scale ?? 1.0; + result.scale.y = _data.assets.countdownTwo?.scale ?? 1.0; + case ONE: + if (_data.assets.countdownOne == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownOne?.scale ?? 1.0; + result.scale.y = _data.assets.countdownOne?.scale ?? 1.0; + case GO: + if (_data.assets.countdownGo == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownGo?.scale ?? 1.0; + result.scale.y = _data.assets.countdownGo?.scale ?? 1.0; + default: + // TODO: Do something here? + return null; + } + + result.scrollFactor.set(0, 0); + result.antialiasing = !isCountdownSpritePixel(step); + result.updateHitbox(); + + return result; + } + + function buildCountdownSpritePath(step:Countdown.CountdownStep):Null + { + var basePath:Null = null; + switch (step) + { + case THREE: + basePath = _data.assets.countdownThree?.assetPath; + case TWO: + basePath = _data.assets.countdownTwo?.assetPath; + case ONE: + basePath = _data.assets.countdownOne?.assetPath; + case GO: + basePath = _data.assets.countdownGo?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildCountdownSpritePath(step); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + function buildCountdownSpriteLibrary(step:Countdown.CountdownStep):Null + { + var basePath:Null = null; + switch (step) + { + case THREE: + basePath = _data.assets.countdownThree?.assetPath; + case TWO: + basePath = _data.assets.countdownTwo?.assetPath; + case ONE: + basePath = _data.assets.countdownOne?.assetPath; + case GO: + basePath = _data.assets.countdownGo?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildCountdownSpriteLibrary(step); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return null; + + return parts[0]; + } + + public function isCountdownSpritePixel(step:Countdown.CountdownStep):Bool + { + switch (step) + { + case THREE: + var result = _data.assets.countdownThree?.isPixel; + if (result == null) result = fallback?.isCountdownSpritePixel(step) ?? false; + return result; + case TWO: + var result = _data.assets.countdownTwo?.isPixel; + if (result == null) result = fallback?.isCountdownSpritePixel(step) ?? false; + return result; + case ONE: + var result = _data.assets.countdownOne?.isPixel; + if (result == null) result = fallback?.isCountdownSpritePixel(step) ?? false; + return result; + case GO: + var result = _data.assets.countdownGo?.isPixel; + if (result == null) result = fallback?.isCountdownSpritePixel(step) ?? false; + return result; + default: + return false; + } + } + + public function getCountdownSpriteOffsets(step:Countdown.CountdownStep):Array + { + switch (step) + { + case THREE: + var result = _data.assets.countdownThree?.offsets; + if (result == null) result = fallback?.getCountdownSpriteOffsets(step) ?? [0, 0]; + return result; + case TWO: + var result = _data.assets.countdownTwo?.offsets; + if (result == null) result = fallback?.getCountdownSpriteOffsets(step) ?? [0, 0]; + return result; + case ONE: + var result = _data.assets.countdownOne?.offsets; + if (result == null) result = fallback?.getCountdownSpriteOffsets(step) ?? [0, 0]; + return result; + case GO: + var result = _data.assets.countdownGo?.offsets; + if (result == null) result = fallback?.getCountdownSpriteOffsets(step) ?? [0, 0]; + return result; + default: + return [0, 0]; + } + } + + public function getCountdownSoundPath(step:Countdown.CountdownStep, raw:Bool = false):Null + { + if (raw) + { + // TODO: figure out why ?. didn't work here + var rawPath:Null = switch (step) + { + case Countdown.CountdownStep.THREE: + _data.assets.countdownThree?.data?.audioPath; + case Countdown.CountdownStep.TWO: + _data.assets.countdownTwo?.data?.audioPath; + case Countdown.CountdownStep.ONE: + _data.assets.countdownOne?.data?.audioPath; + case Countdown.CountdownStep.GO: + _data.assets.countdownGo?.data?.audioPath; + default: + null; + } + + return (rawPath == null) ? fallback?.getCountdownSoundPath(step, true) : rawPath; + } + + // library:path + var parts = getCountdownSoundPath(step, true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return Paths.image(parts[0]); + return Paths.sound(parts[1], parts[0]); + } + + public function buildJudgementSprite(rating:String):Null + { + var result = new FunkinSprite(); + + switch (rating) + { + case "sick": + if (_data.assets.judgementSick == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementSick?.scale ?? 1.0; + result.scale.y = _data.assets.judgementSick?.scale ?? 1.0; + case "good": + if (_data.assets.judgementGood == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementGood?.scale ?? 1.0; + result.scale.y = _data.assets.judgementGood?.scale ?? 1.0; + case "bad": + if (_data.assets.judgementBad == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementBad?.scale ?? 1.0; + result.scale.y = _data.assets.judgementBad?.scale ?? 1.0; + case "shit": + if (_data.assets.judgementShit == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementShit?.scale ?? 1.0; + result.scale.y = _data.assets.judgementShit?.scale ?? 1.0; + default: + return null; + } + + result.scrollFactor.set(0.2, 0.2); + var isPixel = isJudgementSpritePixel(rating); + result.antialiasing = !isPixel; + result.pixelPerfectRender = isPixel; + result.pixelPerfectPosition = isPixel; + result.updateHitbox(); + + return result; + } + + public function isJudgementSpritePixel(rating:String):Bool + { + switch (rating) + { + case "sick": + var result = _data.assets.judgementSick?.isPixel; + if (result == null) result = fallback?.isJudgementSpritePixel(rating) ?? false; + return result; + case "good": + var result = _data.assets.judgementGood?.isPixel; + if (result == null) result = fallback?.isJudgementSpritePixel(rating) ?? false; + return result; + case "bad": + var result = _data.assets.judgementBad?.isPixel; + if (result == null) result = fallback?.isJudgementSpritePixel(rating) ?? false; + return result; + case "shit": + var result = _data.assets.judgementShit?.isPixel; + if (result == null) result = fallback?.isJudgementSpritePixel(rating) ?? false; + return result; + default: + return false; + } + } + + function buildJudgementSpritePath(rating:String):Null + { + var basePath:Null = null; + switch (rating) + { + case "sick": + basePath = _data.assets.judgementSick?.assetPath; + case "good": + basePath = _data.assets.judgementGood?.assetPath; + case "bad": + basePath = _data.assets.judgementBad?.assetPath; + case "shit": + basePath = _data.assets.judgementShit?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildJudgementSpritePath(rating); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + public function getJudgementSpriteOffsets(rating:String):Array + { + switch (rating) + { + case "sick": + var result = _data.assets.judgementSick?.offsets; + if (result == null) result = fallback?.getJudgementSpriteOffsets(rating) ?? [0, 0]; + return result; + case "good": + var result = _data.assets.judgementGood?.offsets; + if (result == null) result = fallback?.getJudgementSpriteOffsets(rating) ?? [0, 0]; + return result; + case "bad": + var result = _data.assets.judgementBad?.offsets; + if (result == null) result = fallback?.getJudgementSpriteOffsets(rating) ?? [0, 0]; + return result; + case "shit": + var result = _data.assets.judgementShit?.offsets; + if (result == null) result = fallback?.getJudgementSpriteOffsets(rating) ?? [0, 0]; + return result; + default: + return [0, 0]; + } + } + + public function buildComboNumSprite(digit:Int):Null + { + var result = new FunkinSprite(); + + switch (digit) + { + case 0: + if (_data.assets.comboNumber0 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber0?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber0?.scale ?? 1.0; + case 1: + if (_data.assets.comboNumber1 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber1?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber1?.scale ?? 1.0; + case 2: + if (_data.assets.comboNumber2 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber2?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber2?.scale ?? 1.0; + case 3: + if (_data.assets.comboNumber3 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber3?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber3?.scale ?? 1.0; + case 4: + if (_data.assets.comboNumber4 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber4?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber4?.scale ?? 1.0; + case 5: + if (_data.assets.comboNumber5 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber5?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber5?.scale ?? 1.0; + case 6: + if (_data.assets.comboNumber6 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber6?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber6?.scale ?? 1.0; + case 7: + if (_data.assets.comboNumber7 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber7?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber7?.scale ?? 1.0; + case 8: + if (_data.assets.comboNumber8 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber8?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber8?.scale ?? 1.0; + case 9: + if (_data.assets.comboNumber9 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber9?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber9?.scale ?? 1.0; + default: + return null; + } + + var isPixel = isComboNumSpritePixel(digit); + result.antialiasing = !isPixel; + result.pixelPerfectRender = isPixel; + result.pixelPerfectPosition = isPixel; + result.updateHitbox(); + + return result; + } + + public function isComboNumSpritePixel(digit:Int):Bool + { + switch (digit) + { + case 0: + var result = _data.assets.comboNumber0?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 1: + var result = _data.assets.comboNumber1?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 2: + var result = _data.assets.comboNumber2?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 3: + var result = _data.assets.comboNumber3?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 4: + var result = _data.assets.comboNumber4?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 5: + var result = _data.assets.comboNumber5?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 6: + var result = _data.assets.comboNumber6?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 7: + var result = _data.assets.comboNumber7?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 8: + var result = _data.assets.comboNumber8?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + case 9: + var result = _data.assets.comboNumber9?.isPixel; + if (result == null) result = fallback?.isComboNumSpritePixel(digit) ?? false; + return result; + default: + return false; + } + } + + function buildComboNumSpritePath(digit:Int):Null + { + var basePath:Null = null; + switch (digit) + { + case 0: + basePath = _data.assets.comboNumber0?.assetPath; + case 1: + basePath = _data.assets.comboNumber1?.assetPath; + case 2: + basePath = _data.assets.comboNumber2?.assetPath; + case 3: + basePath = _data.assets.comboNumber3?.assetPath; + case 4: + basePath = _data.assets.comboNumber4?.assetPath; + case 5: + basePath = _data.assets.comboNumber5?.assetPath; + case 6: + basePath = _data.assets.comboNumber6?.assetPath; + case 7: + basePath = _data.assets.comboNumber7?.assetPath; + case 8: + basePath = _data.assets.comboNumber8?.assetPath; + case 9: + basePath = _data.assets.comboNumber9?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildComboNumSpritePath(digit); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + public function getComboNumSpriteOffsets(digit:Int):Array + { + switch (digit) + { + case 0: + var result = _data.assets.comboNumber0?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 1: + var result = _data.assets.comboNumber1?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 2: + var result = _data.assets.comboNumber2?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 3: + var result = _data.assets.comboNumber3?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 4: + var result = _data.assets.comboNumber4?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 5: + var result = _data.assets.comboNumber5?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 6: + var result = _data.assets.comboNumber6?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 7: + var result = _data.assets.comboNumber7?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 8: + var result = _data.assets.comboNumber8?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + case 9: + var result = _data.assets.comboNumber9?.offsets; + if (result == null) result = fallback?.getComboNumSpriteOffsets(digit) ?? [0, 0]; + return result; + default: + return [0, 0]; + } + } + + public function buildSplashSprite(target:NoteSplash):Void + { + var atlas:Null = buildSplashFrames(false); + + if (atlas == null) + { + throw 'Could not load spritesheet for note style: $id'; + } + target.frames = atlas; + target.antialiasing = !(_data.assets.noteSplash?.isPixel ?? false); + + buildSplashAnimations(target); + + target.splashFramerate = getSplashFramerate(); + target.splashFramerateVariance = getSplashFramerateVariance(); + target.alpha = _data.assets.noteSplash?.alpha ?? 1.0; + target.blend = _data.assets.noteSplash?.data?.blendMode ?? "normal"; + + var scale = getSplashScale(); + target.scale.set(scale, scale); + target.updateHitbox(); + } + + var splashFrames:Null = null; + + function buildSplashFrames(force:Bool = false):Null + { + var splashAssetPath:Null = getSplashAssetPath(); + if (splashAssetPath == null) + { + FlxG.log.warn('Note Splash asset path not found: ${id}'); + return null; + } + + if (!FunkinSprite.isTextureCached(Paths.image(splashAssetPath))) + { + FlxG.log.warn('Note Splash texture not cached: ${splashAssetPath}'); + } + + @:nullSafety(Off) + { + if (splashFrames?.parent?.isDestroyed ?? true) splashFrames = null; + } + + if (splashFrames != null && !force) return splashFrames; + + splashFrames = Paths.getSparrowAtlas(splashAssetPath, getAssetLibrary(getSplashAssetPath(true))); + splashFrames.parent.persist = true; + if (splashFrames == null) + { + throw 'Could not load notesplash frames for note style: $id'; + } + return splashFrames; + } + + function getSplashAssetPath(raw:Bool = false):Null + { + if (raw) + { + var rawPath:Null = _data?.assets?.noteSplash?.assetPath; + if (rawPath == null) return fallback?.getSplashAssetPath(true); + return rawPath; + } + + var parts = getSplashAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return getSplashAssetPath(true); + return parts[1]; + } + + function buildSplashAnimations(target:NoteSplash):Void + { + final addSplashAnim:NoteDirection->Void = function(dir:NoteDirection) { + var animData:Null> = fetchSplashAnimationData(dir); + if (animData != null) + { + for (anim in animData) + FlxAnimationUtil.addAtlasAnimation(target, anim); + } + }; + + addSplashAnim(LEFT); + addSplashAnim(RIGHT); + addSplashAnim(UP); + addSplashAnim(DOWN); + } + + public function isSplashAnimated():Bool + { + return _data?.assets?.noteSplash?.animated ?? fallback?.isSplashAnimated() ?? false; + } + + public function getSplashScale():Float + { + return _data?.assets?.noteSplash?.scale ?? fallback?.getSplashScale() ?? 1.0; + } + + function fetchSplashAnimationData(dir:NoteDirection):Null> + { + var result:Null> = switch (dir) + { + case LEFT: _data.assets?.noteSplash?.data?.leftSplashes?.toNamedArray("splashLEFT"); + case DOWN: _data.assets?.noteSplash?.data?.downSplashes?.toNamedArray("splashDOWN"); + case UP: _data.assets?.noteSplash?.data?.upSplashes?.toNamedArray("splashUP"); + case RIGHT: _data.assets?.noteSplash?.data?.rightSplashes?.toNamedArray("splashRIGHT"); + }; + return result ?? fallback?.fetchSplashAnimationData(dir); + } + + public function getSplashOffsets():Array + { + return _data?.assets?.noteSplash?.offsets ?? fallback?.getSplashOffsets() ?? [0.0, 0.0]; + } + + public function getSplashFramerate():Int + { + return _data?.assets?.noteSplash?.data?.framerateDefault ?? fallback?.getSplashFramerate() ?? 24; + } + + public function getSplashFramerateVariance():Int + { + return _data?.assets?.noteSplash?.data?.framerateVariance ?? fallback?.getSplashFramerateVariance() ?? 2; + } + + public function buildHoldCoverSprite(target:NoteHoldCover):Void + { + // NoteHoldCover has "glow" and "sparks". Right now it only implements "glow" + // but "sparks" I believe is meant to be used for the ending of the hold note + var glowAtlas:Null = buildHoldCoverFrames(false); + if (glowAtlas == null) + { + throw 'Could not load spritesheet for note style: $id'; + } + target.glow.frames = glowAtlas; + + target.antialiasing = !(_data.assets.holdNoteCover?.isPixel ?? false); + target.glow.antialiasing = !(_data.assets.holdNoteCover?.isPixel ?? false); + target.scale.set(_data.assets.holdNoteCover?.scale ?? 1.0, _data.assets.holdNoteCover?.scale ?? 1.0); + target.updateHitbox(); + target.glow.updateHitbox(); + + buildHoldCoverAnimations(target); + } + + // we use FlxFramesCollection here so we can combine em into one atlas later + var holdCoverFrames:Null = null; + + function buildHoldCoverFrames(force:Bool = false):Null + { + // If the hold cover frames got unloaded, ensure they get reloaded. + if (holdCoverFrames?.parent?.isDestroyed ?? true) holdCoverFrames = null; + + // If the hold cover frames are already loaded, just use those. + if (holdCoverFrames != null && !force) return holdCoverFrames; + + for (direction in Strumline.DIRECTIONS) + { + // We make a FlxFramesCollection here so we can combine em into one atlas later + var atlas:Null = buildHoldCoverFrameForDirection(direction); + if (holdCoverFrames == null) holdCoverFrames = atlas; + else if (atlas != null) holdCoverFrames = FlxAnimationUtil.combineFramesCollections(holdCoverFrames, atlas); + } + return holdCoverFrames; + } + + function buildHoldCoverFrameForDirection(direction:NoteDirection):Null + { + var directionData = switch (direction) + { + case LEFT: _data.assets?.holdNoteCover?.data?.left; + case DOWN: _data.assets?.holdNoteCover?.data?.down; + case UP: _data.assets?.holdNoteCover?.data?.up; + case RIGHT: _data.assets?.holdNoteCover?.data?.right; + }; + + if (directionData == null) return null; + + var holdCoverAssetPath:Null = getHoldCoverDirectionAssetPath(direction) ?? fallback?.getHoldCoverDirectionAssetPath(direction); + if (holdCoverAssetPath == null) + { + FlxG.log.warn('Hold Note Cover asset path not found: ${id}'); + return null; + } + + if (!FunkinSprite.isTextureCached(Paths.image(holdCoverAssetPath))) + { + FlxG.log.warn('Hold Note Cover texture not cached: ${holdCoverAssetPath}'); + } + + var atlas:FlxFramesCollection = Paths.getSparrowAtlas(holdCoverAssetPath, getAssetLibrary(getHoldCoverDirectionAssetPath(direction, true))); + if (atlas == null) return null; + + atlas.parent.persist = true; + + return atlas; + } + + function buildHoldCoverAnimations(target:NoteHoldCover):Void + { + for (direction in Strumline.DIRECTIONS) + { + var animData:Null> = fetchHoldCoverAnimationData(direction); + if (animData != null) + { + // this entry is the base animation that plays ("holdCover$noteColor" animation), which we want to loop + animData[1].looped = true; + FlxAnimationUtil.addAtlasAnimations(target.glow, animData); + } + } + } + + function fetchHoldCoverAnimationData(dir:NoteDirection):Null> + { + var noteColor:String = dir.colorName.toTitleCase(); + var result:Array> = switch (dir) + { + case LEFT: [ + _data.assets?.holdNoteCover?.data?.left?.start?.toNamed('holdCoverStart$noteColor'), + _data.assets?.holdNoteCover?.data?.left?.hold?.toNamed('holdCover$noteColor'), + _data.assets?.holdNoteCover?.data?.left?.end?.toNamed('holdCoverEnd$noteColor'), + ]; + case DOWN: [ + _data.assets?.holdNoteCover?.data?.down?.start?.toNamed('holdCoverStart$noteColor'), + _data.assets?.holdNoteCover?.data?.down?.hold?.toNamed('holdCover$noteColor'), + _data.assets?.holdNoteCover?.data?.down?.end?.toNamed('holdCoverEnd$noteColor'), + ]; + case UP: [ + _data.assets?.holdNoteCover?.data?.up?.start?.toNamed('holdCoverStart$noteColor'), + _data.assets?.holdNoteCover?.data?.up?.hold?.toNamed('holdCover$noteColor'), + _data.assets?.holdNoteCover?.data?.up?.end?.toNamed('holdCoverEnd$noteColor'), + ]; + case RIGHT: [ + _data.assets?.holdNoteCover?.data?.right?.start?.toNamed('holdCoverStart$noteColor'), + _data.assets?.holdNoteCover?.data?.right?.hold?.toNamed('holdCover$noteColor'), + _data.assets?.holdNoteCover?.data?.right?.end?.toNamed('holdCoverEnd$noteColor'), + ]; + default: []; + }; + + // New variable so we can change the type. + var filteredResult:Array = thx.Arrays.filterNull(result); + + if (filteredResult.length == 0) return fallback?.fetchHoldCoverAnimationData(dir) ?? []; + + return filteredResult; + } + + // fallbacks: + // assetPath for a direction -> root assetpath -> fallback assetpath for direction -> fallback root assetpath + // `direction` here is required, however it only is used for fallback purposes + function getHoldCoverRootAssetPath(direction:NoteDirection, raw:Bool = false):Null + { + if (raw) + { + var rawPath:Null = _data?.assets?.holdNoteCover?.assetPath; + // remember, if we need a fallback for our *root* asset path, + // we fallback and look for the *direction* asset path first + if (rawPath == null) return fallback?.getHoldCoverDirectionAssetPath(direction, true); + return rawPath; + } + + var parts:Array = getHoldCoverRootAssetPath(direction, true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return getHoldCoverRootAssetPath(direction, true); + return parts[1]; + } + + function getHoldCoverDirectionAssetPath(direction:NoteDirection, raw:Bool = false):Null + { + if (raw) + { + var rawPath:Null = switch (direction) + { + case LEFT: _data?.assets?.holdNoteCover?.data?.left?.assetPath; + case DOWN: _data?.assets?.holdNoteCover?.data?.down?.assetPath; + case UP: _data?.assets?.holdNoteCover?.data?.up?.assetPath; + case RIGHT: _data?.assets?.holdNoteCover?.data?.right?.assetPath; + }; + + // we don't want to fallback directly just yet, we want to check our root assetpath first + if (rawPath == null) return getHoldCoverRootAssetPath(direction, true); + return rawPath; + } + + var parts:Array = getHoldCoverDirectionAssetPath(direction, true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return getHoldCoverDirectionAssetPath(direction, true); + return parts[1]; + } + + public function getHoldCoverOffsets():Array + { + return _data?.assets?.holdNoteCover?.offsets ?? fallback?.getHoldCoverOffsets() ?? [0.0, 0.0]; + } + + public function destroy():Void {} + + /** + * Returns a string of the library name for the given asset id + * `default:assets/images/awesome.png` returns `default` + * If you pass a asset path with no library name (no `:` aka LIBRARY_SEPARATOR) it will return null + * @param id The asset id to get the library name from + * @return Null The library name, or null if no library name is present + */ + function getAssetLibrary(?id:String):Null + { + var parts:Array = id?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return null; + + return parts[0]; + } + + public function toString():String + { + return 'NoteStyle($id)'; + } + + static function _fetchData(id:String):NoteStyleData + { + var result = NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id)); + + if (result == null) + { + throw 'Could not parse note style data for id: $id'; + } + else + { + return result; + } + } +} diff --git a/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx b/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx new file mode 100644 index 00000000..cae0e60e --- /dev/null +++ b/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx @@ -0,0 +1,9 @@ +package funkin.play.notes.notestyle; + +/** + * A script that can be tied to a NoteStyle. + * Create a scripted class that extends NoteStyle to use this. + * This allows you to customize how a specific note style appears. + */ +@:hscriptClass +class ScriptedNoteStyle extends funkin.play.notes.notestyle.NoteStyle implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx new file mode 100644 index 00000000..f0c4ebd4 --- /dev/null +++ b/source/funkin/play/scoring/Scoring.hx @@ -0,0 +1,663 @@ +package funkin.play.scoring; + +import funkin.save.Save.SaveScoreData; + +/** + * Which system to use when scoring and judging notes. + */ +enum abstract ScoringSystem(String) +{ + /** + * The scoring system used in versions of the game Week 6 and older. + * Scores the player based on judgement, represented by a step function. + */ + var LEGACY; + + /** + * The scoring system used in Week 7. It has tighter scoring windows than Legacy. + * Scores the player based on judgement, represented by a step function. + */ + var WEEK7; + + /** + * Points Based On Timing scoring system, version 1 + * Scores the player based on the offset based on timing, represented by a sigmoid function. + */ + var PBOT1; +} + +/** + * A static class which holds any functions related to scoring. + */ +class Scoring +{ + /** + * Determine the score a note receives under a given scoring system. + * @param msTiming The difference between the note's time and when it was hit. + * @param scoringSystem The scoring system to use. + * @return The score the note receives. + */ + public static function scoreNote(msTiming:Float, scoringSystem:ScoringSystem = PBOT1):Int + { + return switch (scoringSystem) + { + case LEGACY: scoreNoteLEGACY(msTiming); + case WEEK7: scoreNoteWEEK7(msTiming); + case PBOT1: scoreNotePBOT1(msTiming); + default: + FlxG.log.error('Unknown scoring system: ${scoringSystem}'); + 0; + } + } + + /** + * Determine the judgement a note receives under a given scoring system. + * @param msTiming The difference between the note's time and when it was hit. + * @param scoringSystem The scoring system to use. + * @return The judgement the note receives. + */ + public static function judgeNote(msTiming:Float, scoringSystem:ScoringSystem = PBOT1):String + { + return switch (scoringSystem) + { + case LEGACY: judgeNoteLEGACY(msTiming); + case WEEK7: judgeNoteWEEK7(msTiming); + case PBOT1: judgeNotePBOT1(msTiming); + default: + FlxG.log.error('Unknown scoring system: ${scoringSystem}'); + 'miss'; + } + } + + public static function getMissScore(scoringSystem:ScoringSystem = PBOT1):Int + { + return switch (scoringSystem) + { + case LEGACY: LEGACY_MISS_SCORE; + case WEEK7: WEEK7_MISS_SCORE; + case PBOT1: PBOT1_MISS_SCORE; + default: + FlxG.log.error('Unknown scoring system: ${scoringSystem}'); + 0; + } + } + + /** + * The maximum score a note can receive. + */ + public static final PBOT1_MAX_SCORE:Int = 500; + + /** + * The offset of the sigmoid curve for the scoring function. + */ + public static final PBOT1_SCORING_OFFSET:Float = 54.99; + + /** + * The slope of the sigmoid curve for the scoring function. + */ + public static final PBOT1_SCORING_SLOPE:Float = 0.080; + + /** + * The minimum score a note can receive while still being considered a hit. + */ + public static final PBOT1_MIN_SCORE:Float = 9.0; + + /** + * The score a note receives when it is missed. + */ + public static final PBOT1_MISS_SCORE:Int = -100; + + /** + * The threshold at which a note hit is considered perfect and always given the max score. + */ + public static final PBOT1_PERFECT_THRESHOLD:Float = 5.0; // 5ms + + /** + * The threshold at which a note hit is considered missed. + * `160ms` + */ + public static final PBOT1_MISS_THRESHOLD:Float = 160.0; + + /** + * The time within which a note is considered to have been hit with the Killer judgement. + * `~7.5% of the hit window, or 12.5ms` + */ + public static final PBOT1_KILLER_THRESHOLD:Float = 12.5; + + /** + * The time within which a note is considered to have been hit with the Sick judgement. + * `~25% of the hit window, or 45ms` + */ + public static final PBOT1_SICK_THRESHOLD:Float = 45.0; + + /** + * The time within which a note is considered to have been hit with the Good judgement. + * `~55% of the hit window, or 90ms` + */ + public static final PBOT1_GOOD_THRESHOLD:Float = 90.0; + + /** + * The time within which a note is considered to have been hit with the Bad judgement. + * `~85% of the hit window, or 135ms` + */ + public static final PBOT1_BAD_THRESHOLD:Float = 135.0; + + /** + * The time within which a note is considered to have been hit with the Shit judgement. + * `100% of the hit window, or 160ms` + */ + public static final PBOT1_SHIT_THRESHOLD:Float = 160.0; + + static function scoreNotePBOT1(msTiming:Float):Int + { + // Absolute value because otherwise late hits are always given the max score. + var absTiming:Float = Math.abs(msTiming); + + return switch (absTiming) + { + case(_ > PBOT1_MISS_THRESHOLD) => true: + PBOT1_MISS_SCORE; + case(_ < PBOT1_PERFECT_THRESHOLD) => true: + PBOT1_MAX_SCORE; + default: + // Fancy equation. + var factor:Float = 1.0 - (1.0 / (1.0 + Math.exp(-PBOT1_SCORING_SLOPE * (absTiming - PBOT1_SCORING_OFFSET)))); + + var score:Int = Std.int(PBOT1_MAX_SCORE * factor + PBOT1_MIN_SCORE); + + score; + } + } + + static function judgeNotePBOT1(msTiming:Float):String + { + var absTiming:Float = Math.abs(msTiming); + + return switch (absTiming) + { + // case(_ < PBOT1_KILLER_THRESHOLD) => true: + // 'killer'; + case(_ < PBOT1_SICK_THRESHOLD) => true: + 'sick'; + case(_ < PBOT1_GOOD_THRESHOLD) => true: + 'good'; + case(_ < PBOT1_BAD_THRESHOLD) => true: + 'bad'; + case(_ < PBOT1_SHIT_THRESHOLD) => true: + 'shit'; + default: + FlxG.log.warn('Missed note: Bad timing ($absTiming < $PBOT1_SHIT_THRESHOLD)'); + 'miss'; + } + } + + /** + * The window of time in which a note is considered to be hit, on the Funkin Legacy scoring system. + * Currently equal to 10 frames at 60fps, or ~166ms. + */ + public static final LEGACY_HIT_WINDOW:Float = (10 / 60) * 1000; // 166.67 ms hit window (10 frames at 60fps) + + /** + * The threshold at which a note is considered a "Sick" hit rather than another judgement. + * Represented as a percentage of the total hit window. + */ + public static final LEGACY_SICK_THRESHOLD:Float = 0.2; + + /** + * The threshold at which a note is considered a "Good" hit rather than another judgement. + * Represented as a percentage of the total hit window. + */ + public static final LEGACY_GOOD_THRESHOLD:Float = 0.75; + + /** + * The threshold at which a note is considered a "Bad" hit rather than another judgement. + * Represented as a percentage of the total hit window. + */ + public static final LEGACY_BAD_THRESHOLD:Float = 0.9; + + /** + * The score a note receives when hit within the Shit threshold, rather than a miss. + * Represented as a percentage of the total hit window. + */ + public static final LEGACY_SHIT_THRESHOLD:Float = 1.0; + + /** + * The score a note receives when hit within the Sick threshold. + */ + public static final LEGACY_SICK_SCORE:Int = 350; + + /** + * The score a note receives when hit within the Good threshold. + */ + public static final LEGACY_GOOD_SCORE:Int = 200; + + /** + * The score a note receives when hit within the Bad threshold. + */ + public static final LEGACY_BAD_SCORE:Int = 100; + + /** + * The score a note receives when hit within the Shit threshold. + */ + public static final LEGACY_SHIT_SCORE:Int = 50; + + /** + * The score a note receives when missed. + */ + public static final LEGACY_MISS_SCORE:Int = -10; + + static function scoreNoteLEGACY(msTiming:Float):Int + { + var absTiming:Float = Math.abs(msTiming); + + return switch (absTiming) + { + case(_ < LEGACY_HIT_WINDOW * LEGACY_SICK_THRESHOLD) => true: + LEGACY_SICK_SCORE; + case(_ < LEGACY_HIT_WINDOW * LEGACY_GOOD_THRESHOLD) => true: + LEGACY_GOOD_SCORE; + case(_ < LEGACY_HIT_WINDOW * LEGACY_BAD_THRESHOLD) => true: + LEGACY_BAD_SCORE; + case(_ < LEGACY_HIT_WINDOW * LEGACY_SHIT_THRESHOLD) => true: + LEGACY_SHIT_SCORE; + default: + 0; + } + } + + static function judgeNoteLEGACY(msTiming:Float):String + { + var absTiming:Float = Math.abs(msTiming); + + return switch (absTiming) + { + case(_ < LEGACY_HIT_WINDOW * LEGACY_SICK_THRESHOLD) => true: + 'sick'; + case(_ < LEGACY_HIT_WINDOW * LEGACY_GOOD_THRESHOLD) => true: + 'good'; + case(_ < LEGACY_HIT_WINDOW * LEGACY_BAD_THRESHOLD) => true: + 'bad'; + case(_ < LEGACY_HIT_WINDOW * LEGACY_SHIT_THRESHOLD) => true: + 'shit'; + default: + FlxG.log.warn('Missed note: Bad timing ($absTiming < $LEGACY_SHIT_THRESHOLD)'); + 'miss'; + } + } + + /** + * The window of time in which a note is considered to be hit, on the Funkin Classic scoring system. + * Same as L 10 frames at 60fps, or ~166ms. + */ + public static final WEEK7_HIT_WINDOW:Float = LEGACY_HIT_WINDOW; + + public static final WEEK7_BAD_THRESHOLD:Float = 0.8; // 80% of the hit window, or ~125ms + public static final WEEK7_GOOD_THRESHOLD:Float = 0.55; // 55% of the hit window, or ~91ms + public static final WEEK7_SICK_THRESHOLD:Float = 0.2; // 20% of the hit window, or ~33ms + public static final WEEK7_MISS_SCORE:Int = -10; + public static final WEEK7_SHIT_SCORE:Int = 50; + public static final WEEK7_BAD_SCORE:Int = 100; + public static final WEEK7_GOOD_SCORE:Int = 200; + public static final WEEK7_SICK_SCORE:Int = 350; + + static function scoreNoteWEEK7(msTiming:Float):Int + { + var absTiming:Float = Math.abs(msTiming); + + return switch (absTiming) + { + case(_ < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) => true: + LEGACY_SICK_SCORE; + case(_ < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) => true: + LEGACY_GOOD_SCORE; + case(_ < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) => true: + LEGACY_BAD_SCORE; + case(_ < WEEK7_HIT_WINDOW) => true: + LEGACY_SHIT_SCORE; + default: + 0; + } + + if (absTiming < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) + { + return WEEK7_SICK_SCORE; + } + else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) + { + return WEEK7_GOOD_SCORE; + } + else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) + { + return WEEK7_BAD_SCORE; + } + else if (absTiming < WEEK7_HIT_WINDOW) + { + return WEEK7_SHIT_SCORE; + } + else + { + return 0; + } + } + + static function judgeNoteWEEK7(msTiming:Float):String + { + var absTiming = Math.abs(msTiming); + if (absTiming < WEEK7_HIT_WINDOW * WEEK7_SICK_THRESHOLD) + { + return 'sick'; + } + else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_GOOD_THRESHOLD) + { + return 'good'; + } + else if (absTiming < WEEK7_HIT_WINDOW * WEEK7_BAD_THRESHOLD) + { + return 'bad'; + } + else if (absTiming < WEEK7_HIT_WINDOW) + { + return 'shit'; + } + else + { + FlxG.log.warn('Missed note: Bad timing ($absTiming < $WEEK7_HIT_WINDOW)'); + return 'miss'; + } + } + + public static function calculateRank(scoreData:Null):Null + { + if (scoreData?.tallies.totalNotes == 0 || scoreData == null) return null; + + // we can return null here, meaning that the player hasn't actually played and finished the song (thus has no data) + if (scoreData.tallies.totalNotes == 0) return null; + + // Perfect (Platinum) is a Sick Full Clear + var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes; + if (isPerfectGold) + { + return ScoringRank.PERFECT_GOLD; + } + + // Else, use the standard grades + + // Final Grade = (Sick + Good - Miss) / (Total Notes) + + var grade = (scoreData.tallies.sick + scoreData.tallies.good - scoreData.tallies.missed) / scoreData.tallies.totalNotes; + + if (grade == Constants.RANK_PERFECT_THRESHOLD) + { + return ScoringRank.PERFECT; + } + else if (grade >= Constants.RANK_EXCELLENT_THRESHOLD) + { + return ScoringRank.EXCELLENT; + } + else if (grade >= Constants.RANK_GREAT_THRESHOLD) + { + return ScoringRank.GREAT; + } + else if (grade >= Constants.RANK_GOOD_THRESHOLD) + { + return ScoringRank.GOOD; + } + else + { + return ScoringRank.SHIT; + } + } +} + +enum abstract ScoringRank(String) +{ + var PERFECT_GOLD; + var PERFECT; + var EXCELLENT; + var GREAT; + var GOOD; + var SHIT; + + /** + * Converts ScoringRank to an integer value for comparison. + * Better ranks should be tied to a higher value. + */ + static function getValue(rank:Null):Int + { + if (rank == null) return -1; + switch (rank) + { + case PERFECT_GOLD: + return 5; + case PERFECT: + return 4; + case EXCELLENT: + return 3; + case GREAT: + return 2; + case GOOD: + return 1; + case SHIT: + return 0; + default: + return -1; + } + } + + // Yes, we really need a different function for each comparison operator. + @:op(A > B) static function compareGT(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 > temp2; + } + + @:op(A >= B) static function compareGTEQ(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 >= temp2; + } + + @:op(A < B) static function compareLT(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 < temp2; + } + + @:op(A <= B) static function compareLTEQ(a:Null, b:Null):Bool + { + if (a != null && b == null) return true; + if (a == null || b == null) return false; + + var temp1:Int = getValue(a); + var temp2:Int = getValue(b); + + return temp1 <= temp2; + } + + // @:op(A == B) isn't necessary! + + /** + * Delay in seconds + */ + public function getMusicDelay():Float + { + switch (abstract) + { + case PERFECT_GOLD | PERFECT: + // return 2.5; + return 95 / 24; + case EXCELLENT: + return 0; + case GREAT: + return 5 / 24; + case GOOD: + return 3 / 24; + case SHIT: + return 2 / 24; + default: + return 3.5; + } + } + + public function getBFDelay():Float + { + switch (abstract) + { + case PERFECT_GOLD | PERFECT: + // return 2.5; + return 95 / 24; + case EXCELLENT: + return 97 / 24; + case GREAT: + return 95 / 24; + case GOOD: + return 95 / 24; + case SHIT: + return 95 / 24; + default: + return 3.5; + } + } + + public function getFlashDelay():Float + { + switch (abstract) + { + case PERFECT_GOLD | PERFECT: + // return 2.5; + return 129 / 24; + case EXCELLENT: + return 122 / 24; + case GREAT: + return 109 / 24; + case GOOD: + return 107 / 24; + case SHIT: + return 186 / 24; + default: + return 3.5; + } + } + + public function getHighscoreDelay():Float + { + switch (abstract) + { + case PERFECT_GOLD | PERFECT: + // return 2.5; + return 140 / 24; + case EXCELLENT: + return 140 / 24; + case GREAT: + return 129 / 24; + case GOOD: + return 127 / 24; + case SHIT: + return 207 / 24; + default: + return 3.5; + } + } + + public function getFreeplayRankIconAsset():String + { + switch (abstract) + { + case PERFECT_GOLD: + return 'PERFECTSICK'; + case PERFECT: + return 'PERFECT'; + case EXCELLENT: + return 'EXCELLENT'; + case GREAT: + return 'GREAT'; + case GOOD: + return 'GOOD'; + case SHIT: + return 'LOSS'; + default: + return 'LOSS'; + } + } + + public function getHorTextAsset() + { + switch (abstract) + { + case PERFECT_GOLD: + return 'resultScreen/rankText/rankScrollPERFECT'; + case PERFECT: + return 'resultScreen/rankText/rankScrollPERFECT'; + case EXCELLENT: + return 'resultScreen/rankText/rankScrollEXCELLENT'; + case GREAT: + return 'resultScreen/rankText/rankScrollGREAT'; + case GOOD: + return 'resultScreen/rankText/rankScrollGOOD'; + case SHIT: + return 'resultScreen/rankText/rankScrollLOSS'; + default: + return 'resultScreen/rankText/rankScrollGOOD'; + } + } + + public function getVerTextAsset() + { + switch (abstract) + { + case PERFECT_GOLD: + return 'resultScreen/rankText/rankTextPERFECT'; + case PERFECT: + return 'resultScreen/rankText/rankTextPERFECT'; + case EXCELLENT: + return 'resultScreen/rankText/rankTextEXCELLENT'; + case GREAT: + return 'resultScreen/rankText/rankTextGREAT'; + case GOOD: + return 'resultScreen/rankText/rankTextGOOD'; + case SHIT: + return 'resultScreen/rankText/rankTextLOSS'; + default: + return 'resultScreen/rankText/rankTextGOOD'; + } + } + + public function getRankingFreeplayColor() + { + return switch (abstract) + { + case SHIT: + 0xFF6044FF; + case GOOD: + 0xFFEF8764; + case GREAT: + 0xFFEAF6FF; + case EXCELLENT: + 0xFFFDCB42; + case PERFECT: + 0xFFFF58B4; + case PERFECT_GOLD: + 0xFFFFB619; + } + } + + public function toString():String + { + return this; + } +} diff --git a/source/funkin/play/song/ScriptedSong.hx b/source/funkin/play/song/ScriptedSong.hx new file mode 100644 index 00000000..35cd8001 --- /dev/null +++ b/source/funkin/play/song/ScriptedSong.hx @@ -0,0 +1,8 @@ +package funkin.play.song; + +/** + * A script that can be tied to a Song. + * Create a scripted class that extends Song to use this. + */ +@:hscriptClass +class ScriptedSong extends funkin.play.song.Song implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx new file mode 100644 index 00000000..f0d79227 --- /dev/null +++ b/source/funkin/play/song/Song.hx @@ -0,0 +1,971 @@ +package funkin.play.song; + +import funkin.audio.VoicesGroup; +import funkin.audio.FunkinSound; +import funkin.data.IRegistryEntry; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongOffsets; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.SongData.SongTimeFormat; +import funkin.data.song.SongRegistry; +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.modding.events.ScriptEvent; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.util.SortUtil; + +/** + * This is a data structure managing information about the current song. + * This structure is created when the game starts, and includes all the data + * from the `metadata.json` file. + * It also includes the chart data, but only when this is the currently loaded song. + * + * It also receives script events; scripted classes which extend this class + * can be used to perform custom gameplay behaviors only on specific songs. + */ +@:nullSafety +class Song implements IPlayStateScriptedClass implements IRegistryEntry +{ + /** + * The default value for the song's name + */ + public static final DEFAULT_SONGNAME:String = 'Unknown'; + + /** + * The default value for the song's artist + */ + public static final DEFAULT_ARTIST:String = 'Unknown'; + + /** + * The default value for the song's time format + */ + public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; + + /** + * The default value for the song's divisions + */ + public static final DEFAULT_DIVISIONS:Null = null; + + /** + * The default value for whether the song loops. + */ + public static final DEFAULT_LOOPED:Bool = false; + + /** + * The default value for the song's playable stage. + */ + public static final DEFAULT_STAGE:String = 'mainStage'; + + /** + * The default value for the song's scroll speed. + */ + public static final DEFAULT_SCROLLSPEED:Float = 1.0; + + // key = variation id, value = metadata + final _metadata:Map; + + /** + * holds the difficulties (as in SongDifficulty) for each variation + * difficulties.get('default').get('easy') would return the easy difficulty for the default variation + */ + final difficulties:Map>; + + /** + * The list of variations a song has. + */ + public var variations(get, never):Array; + + function get_variations():Array + { + return _metadata.keys().array(); + } + + // this returns false so that any new song can override this and return true when needed + public function isSongNew(currentDifficulty:String, currentVariation:String):Bool + { + return false; + } + + /** + * Set to false if the song was edited in the charter and should not be saved as a high score. + */ + public var validScore:Bool = true; + + /** + * The readable name of the song. + */ + public var songName(get, never):String; + + function get_songName():String + { + if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME; + if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.songName ?? DEFAULT_SONGNAME; + return DEFAULT_SONGNAME; + } + + /** + * The artist of the song. + */ + public var songArtist(get, never):String; + + function get_songArtist():String + { + if (_data != null) return _data?.artist ?? DEFAULT_ARTIST; + if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.artist ?? DEFAULT_ARTIST; + return DEFAULT_ARTIST; + } + + /** + * The artist of the song. + */ + public var charter(get, never):String; + + function get_charter():String + { + if (_data != null) return _data?.charter ?? 'Unknown'; + if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.charter ?? 'Unknown'; + return Constants.DEFAULT_CHARTER; + } + + /** + * @param id The ID of the song to load. + * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded. + */ + public function new(id:String) + { + this.id = id; + + difficulties = new Map>(); + + _data = _fetchData(id); + + _metadata = _data == null ? [] : [Constants.DEFAULT_VARIATION => _data]; + + if (_data != null && _data.playData != null) + { + for (vari in _data.playData.songVariations) + { + if (!validateVariationId(vari)) + { + trace(' [WARN] Variation id "$vari" is invalid, skipping...'); + continue; + } + + var variMeta:Null = fetchVariationMetadata(id, vari); + if (variMeta != null) + { + _metadata.set(variMeta.variation, variMeta); + trace(' Loaded variation: $vari'); + } + else + { + FlxG.log.warn('[SONG] Failed to load variation metadata (${id}:${vari}), is the path correct?'); + trace(' FAILED to load variation: $vari'); + } + } + } + + if (_metadata.size() == 0) + { + trace('[WARN] Could not find song data for songId: $id'); + return; + } + + populateDifficulties(); + } + + /** + * Build a song from existing metadata rather than loading it from the `assets` folder. + * Used by the Chart Editor. + * + * @param songId The ID of the song. + * @param metadata The metadata of the song. + * @param variations The list of variations this song has. + * @param charts The chart data for each variation. + * @param includeScript Whether to initialize the scripted class tied to the song, if it exists. + * @param validScore Whether the song is elegible for highscores. + * @return The constructed song object. + */ + public static function buildRaw(songId:String, metadata:Array, variations:Array, charts:Map, + includeScript:Bool = true, validScore:Bool = false):Song + { + @:privateAccess + var result:Null; + + if (includeScript && SongRegistry.instance.isScriptedEntry(songId)) + { + var songClassName:String = SongRegistry.instance.getScriptedEntryClassName(songId); + + @:privateAccess + result = SongRegistry.instance.createScriptedEntry(songClassName); + } + else + { + @:privateAccess + result = SongRegistry.instance.createEntry(songId); + } + + if (result == null) throw 'ERROR: Could not build Song instance ($songId), is the attached script bad?'; + + result._metadata.clear(); + for (meta in metadata) + { + result._metadata.set(meta.variation, meta); + } + + result.difficulties.clear(); + result.populateDifficulties(); + + for (variation => chartData in charts) + { + result.applyChartData(chartData, variation); + } + + result.validScore = validScore; + + return result; + } + + /** + * Retrieve a list of the raw metadata for the song. + * @return The metadata JSON objects for the song's variations. + */ + public function getRawMetadata():Array + { + return _metadata.values(); + } + + /** + * List the album IDs for each variation of the song. + * @return A map of variation IDs to album IDs. + */ + public function listAlbums(variation:String):Map + { + var result:Map = new Map(); + + for (variationMap in difficulties) + { + for (difficultyId in variationMap.keys()) + { + var meta:Null = variationMap.get(difficultyId); + if (meta != null && meta.album != null) + { + result.set(difficultyId, meta.album); + } + } + } + + return result; + } + + /** + * Input a difficulty ID and a variation ID, and get the album ID. + * @param diffId + * @param variation + * @return String + */ + public function getAlbumId(diffId:String, variation:String):String + { + var diff:Null = getDifficulty(diffId, variation); + if (diff == null) return ''; + + return diff.album ?? ''; + } + + public function getStickerPackId(diffId:String, variation:String):Null + { + var diff:Null = getDifficulty(diffId, variation); + if (diff == null) return null; + + return diff.stickerPack; + } + + /** + * Populate the difficulty data from the provided metadata. + * Does not load chart data (that is triggered later when we want to play the song). + */ + function populateDifficulties():Void + { + if (_metadata == null || _metadata.size() == 0) return; + + // Variations may have different artist, time format, generatedBy, etc. + for (metadata in _metadata.values()) + { + if (metadata == null || metadata.playData == null) continue; + + // If there are no difficulties in the metadata, there's a problem. + if (metadata.playData.difficulties.length == 0) + { + trace('[SONG] Warning: Song $id (variation ${metadata.variation}) has no difficulties listed in metadata!'); + continue; + } + + // This resides within difficulties + var difficultyMap:Map = new Map(); + + // There may be more difficulties in the chart file than in the metadata, + // (i.e. non-playable charts like the one used for Pico on the speaker in Stress) + // but all the difficulties in the metadata must be in the chart file. + for (diffId in metadata.playData.difficulties) + { + var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation); + + difficulty.songName = metadata.songName; + difficulty.songArtist = metadata.artist; + difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER; + difficulty.timeFormat = metadata.timeFormat; + difficulty.divisions = metadata.divisions; + difficulty.timeChanges = metadata.timeChanges; + difficulty.looped = metadata.looped; + difficulty.generatedBy = metadata.generatedBy; + difficulty.offsets = metadata?.offsets ?? new SongOffsets(); + + difficulty.difficultyRating = metadata.playData.ratings.get(diffId) ?? 0; + difficulty.album = metadata.playData.album; + difficulty.stickerPack = metadata.playData.stickerPack; + + difficulty.stage = metadata.playData.stage; + difficulty.noteStyle = metadata.playData.noteStyle; + + difficulty.characters = metadata.playData.characters; + difficultyMap.set(diffId, difficulty); + } + difficulties.set(metadata.variation, difficultyMap); + } + } + + /** + * Parse and cache the chart for all difficulties of this song. + * @param force Whether to forcibly clear the list of charts first. + */ + public function cacheCharts(force:Bool = false):Void + { + if (force) + { + clearCharts(); + } + + trace('Caching ${variations.length} chart files for song $id'); + for (variation in variations) + { + var version:Null = SongRegistry.instance.fetchEntryChartVersion(id, variation); + if (version == null) continue; + var chart:Null = SongRegistry.instance.parseEntryChartDataWithMigration(id, variation, version); + if (chart == null) continue; + applyChartData(chart, variation); + } + trace('Done caching charts.'); + } + + function applyChartData(chartData:SongChartData, variation:String):Void + { + var chartNotes = chartData.notes; + + for (diffId in chartNotes.keys()) + { + // Retrieve the cached difficulty data. This one could potentially be null. + var nullDiff:Null = getDifficulty(diffId, variation); + + // if the difficulty doesn't exist, create a new one, and then proceed to fill it with data. + // I mostly do this since I don't wanna throw around ? everywhere for null check lol? + var difficulty:SongDifficulty = nullDiff ?? new SongDifficulty(this, diffId, variation); + + if (nullDiff == null) + { + trace('Fabricated new difficulty for $diffId.'); + var metadata = _metadata.get(variation); + difficulties.get(variation)?.set(diffId, difficulty); + + if (metadata != null) + { + difficulty.songName = metadata.songName; + difficulty.songArtist = metadata.artist; + difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER; + difficulty.timeFormat = metadata.timeFormat; + difficulty.divisions = metadata.divisions; + difficulty.timeChanges = metadata.timeChanges; + difficulty.looped = metadata.looped; + difficulty.generatedBy = metadata.generatedBy; + difficulty.offsets = metadata?.offsets ?? new SongOffsets(); + + difficulty.stage = metadata.playData.stage; + difficulty.noteStyle = metadata.playData.noteStyle; + + difficulty.characters = metadata.playData.characters; + } + } + // Add the chart data to the difficulty. + difficulty.notes = chartNotes.get(diffId) ?? []; + difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0; + + difficulty.events = chartData.events; + } + } + + /** + * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. + * @param diffId The difficulty ID, such as `easy` or `hard`. + * @param variation The variation ID to fetch the difficulty for. Or you can use `variations`. + * @param variations A list of variations to fetch the difficulty for. Looks for the first variation that exists. + * @return The difficulty data. + */ + public function getDifficulty(?diffId:String, ?variation:String, ?variations:Array):Null + { + if (diffId == null) diffId = listDifficulties(variation, variations)[0]; + if (variation == null) variation = Constants.DEFAULT_VARIATION; + if (variations == null) variations = [variation]; + + for (currentVariation in variations) + { + if (difficulties.get(currentVariation)?.exists(diffId) ?? false) + { + return difficulties.get(currentVariation)?.get(diffId); + } + } + + return null; + } + + /** + * Returns the first valid variation that matches both the difficulty id, and the current character / possible input variations + * @param diffId + * @param currentCharacter + * @param possibleVariations + * @return Null + */ + public function getFirstValidVariation(?diffId:String, ?currentCharacter:PlayableCharacter, ?possibleVariations:Array):Null + { + if (possibleVariations == null) + { + possibleVariations = getVariationsByCharacter(currentCharacter); + } + + if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0]; + + for (variationId in possibleVariations) + { + if (difficulties.get('$variationId')?.exists(diffId) ?? false) return variationId; + } + + return null; + } + + /** + * Given that this character is selected in the Freeplay menu, + * which variations should be available? + * @param char The playable character to query. + * @return An array of available variations. + */ + public function getVariationsByCharacter(?char:PlayableCharacter):Array + { + if (char == null) + { + var result = variations; + result.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST)); + return result; + } + + var result = []; + for (variation in variations) + { + var metadata = _metadata.get(variation); + + var playerCharId = metadata?.playData?.characters?.player; + if (playerCharId == null) continue; + + if (char.shouldShowCharacter(playerCharId)) + { + result.push(variation); + } + } + + result.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST)); + + return result; + } + + /** + * Nearly the same thing as getVariationsByCharacter, but takes a character ID instead. + * @param charId + * @return Array + * @see getVariationsByCharacter + */ + public function getVariationsByCharacterId(?charId:String):Array + { + var charPlayer = PlayerRegistry.instance.fetchEntry(charId ?? ''); + + return getVariationsByCharacter(charPlayer); + } + + /** + * List all the difficulties in this song. + * + * @param variationId Optionally filter by a single variation. + * @param variationIds Optionally filter by multiple variations. + * @param showLocked Include charts which are not unlocked + * @param showHidden Include charts which are not accessible to the player. + * + * @return The list of difficulties. + */ + public function listDifficulties(?variationId:String, ?variationIds:Array, showLocked:Bool = false, showHidden:Bool = false):Array + { + if (variationIds == null) variationIds = []; + if (variationId != null) variationIds.push(variationId); + + if (variationIds.length == 0) return []; + + var diffFiltered:Array = variationIds.map(function(variationId:String):Array { + var metadata = _metadata.get(variationId); + return metadata?.playData?.difficulties ?? []; + }) + .flatten() + .filterNull() + .distinct(); + + diffFiltered = diffFiltered.filter(function(diffId:String):Bool { + if (showHidden) return true; + for (targetVariation in variationIds) + { + if (isDifficultyVisible(diffId, targetVariation)) return true; + } + return false; + }); + + diffFiltered.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST_FULL)); + + return diffFiltered; + } + + /** + * TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. + * @return `easy`, `erect`, `normal-pico`, etc. + * @deprecated This function is deprecated, Funkin no longer uses suffixed difficulties. + */ + public function listSuffixedDifficulties(variationIds:Array, ?showLocked:Bool, ?showHidden:Bool):Array + { + var result = []; + + for (variation in variationIds) + { + var difficulties = listDifficulties(variation, null, showLocked, showHidden); + for (difficulty in difficulties) + { + var suffixedDifficulty = (variation != Constants.DEFAULT_VARIATION + && variation != 'erect') ? '$difficulty-${variation}' : difficulty; + result.push(suffixedDifficulty); + } + } + + result.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST_FULL)); + + return result; + } + + public function hasDifficulty(diffId:String, ?variationId:String, ?variationIds:Array):Bool + { + if (variationIds == null) variationIds = []; + if (variationId != null) variationIds.push(variationId); + + for (targetVariation in variationIds) + { + if (difficulties.get(targetVariation)?.exists(diffId) ?? false) return true; + } + return false; + } + + public function isDifficultyVisible(diffId:String, variationId:String):Bool + { + var variation = _metadata.get(variationId); + if (variation == null) return false; + return variation.playData.difficulties.contains(diffId); + } + + /** + * Return the list of available alternate instrumentals. + * Scripts can override this, fun. + * @param variationId + * @param difficultyId + */ + public function listAltInstrumentalIds(difficultyId:String, variationId:String):Array + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return []; + + return targetDifficulty?.characters?.altInstrumentals ?? []; + } + + public function getBaseInstrumentalId(difficultyId:String, variationId:String):String + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return ''; + + return targetDifficulty?.characters?.instrumental ?? ''; + } + + /** + * Purge the cached chart data for each difficulty/variation of this song. + */ + public function clearCharts():Void + { + for (variationMap in difficulties) + { + for (diff in variationMap) + { + diff.clearChart(); + } + } + } + + public function onPause(event:PauseScriptEvent):Void {}; + + public function onResume(event:ScriptEvent):Void {}; + + public function onSongLoaded(event:SongLoadScriptEvent):Void {}; + + public function onSongStart(event:ScriptEvent):Void {}; + + public function onSongEnd(event:ScriptEvent):Void {}; + + public function onGameOver(event:ScriptEvent):Void {}; + + public function onSongRetry(event:SongRetryEvent):Void {}; + + public function onNoteIncoming(event:NoteScriptEvent) {}; + + public function onNoteHit(event:HitNoteScriptEvent) {}; + + public function onNoteMiss(event:NoteScriptEvent):Void {}; + + public function onNoteHoldDrop(event:HoldNoteScriptEvent) {} + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void {}; + + public function onSongEvent(event:SongEventScriptEvent):Void {}; + + public function onStepHit(event:SongTimeScriptEvent):Void {}; + + public function onBeatHit(event:SongTimeScriptEvent):Void {}; + + public function onCountdownStart(event:CountdownScriptEvent):Void {}; + + public function onCountdownStep(event:CountdownScriptEvent):Void {}; + + public function onCountdownEnd(event:CountdownScriptEvent):Void {}; + + public function onScriptEvent(event:ScriptEvent):Void {}; + + public function onCreate(event:ScriptEvent):Void {}; + + public function onDestroy(event:ScriptEvent):Void {}; + + public function onUpdate(event:UpdateScriptEvent):Void {}; + + static function _fetchData(id:String):Null + { + trace('Fetching song metadata for $id'); + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id); + if (version == null) return null; + return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version); + } + + function fetchVariationMetadata(id:String, vari:String):Null + { + var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id, vari); + if (version == null) return null; + var meta:Null = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version); + return meta; + } + + static final VARIATION_REGEX = ~/^[a-z][a-z0-9]+$/; + + /** + * Validate that the variation ID is valid. + * Auto-accept if it's one of the base game default variations. + * Reject if the ID starts with a number, or contains invalid characters. + */ + static function validateVariationId(variation:String):Bool + { + if (Constants.DEFAULT_VARIATION_LIST.contains(variation)) return true; + + return VARIATION_REGEX.match(variation); + } +} + +class SongDifficulty +{ + /** + * The parent song for this difficulty. + */ + public final song:Song; + + /** + * The difficulty ID, such as `easy` or `hard`. + */ + public final difficulty:String; + + /** + * The metadata file that contains this difficulty. + */ + public final variation:String; + + /** + * The note chart for this difficulty. + */ + public var notes:Array; + + /** + * The event chart for this difficulty. + */ + public var events:Array; + + public var songName:String = Constants.DEFAULT_SONGNAME; + public var songArtist:String = Constants.DEFAULT_ARTIST; + public var charter:String = Constants.DEFAULT_CHARTER; + public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT; + public var divisions:Null = null; + public var looped:Bool = false; + public var offsets:SongOffsets = new SongOffsets(); + public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; + + public var timeChanges:Array = []; + + public var stage:String = Constants.DEFAULT_STAGE; + public var noteStyle:String = Constants.DEFAULT_NOTE_STYLE; + public var characters:SongCharacterData = null; + + public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED; + + public var difficultyRating:Int = 0; + public var album:Null = null; + public var stickerPack:Null = null; + + public function new(song:Song, diffId:String, variation:String) + { + this.song = song; + this.difficulty = diffId; + this.variation = variation; + } + + public function clearChart():Void + { + notes = null; + } + + public function getStartingBPM():Float + { + if (timeChanges.length == 0) + { + return 0; + } + + return timeChanges[0].bpm; + } + + public function getEvents():Array + { + return cast events; + } + + public function getInstPath(instrumental = ''):String + { + if (characters != null) + { + if (instrumental != '' && characters.altInstrumentals.contains(instrumental)) + { + var instId = '-$instrumental'; + return Paths.inst(this.song.id, instId); + } + else + { + // Fallback to default instrumental. + var instId = (characters.instrumental ?? '') != '' ? '-${characters.instrumental}' : ''; + return Paths.inst(this.song.id, instId); + } + } + else + { + return Paths.inst(this.song.id); + } + } + + public function cacheInst(instrumental = ''):Void + { + FlxG.sound.cache(getInstPath(instrumental)); + } + + public function playInst(volume:Float = 1.0, instId:String = '', looped:Bool = false):Void + { + var suffix:String = (instId != '') ? '-$instId' : ''; + + FlxG.sound.music = FunkinSound.load(Paths.inst(this.song.id, suffix), volume, looped, false, true, false, null, null, true); + + // Workaround for a bug where FlxG.sound.music.update() was being called twice. + FlxG.sound.list.remove(FlxG.sound.music); + } + + /** + * Cache the vocals for a given character. + * @param id The character we are about to play. + */ + public function cacheVocals():Void + { + for (voice in buildVoiceList()) + { + trace('Caching vocal track: $voice'); + FlxG.sound.cache(voice); + } + } + + /** + * Build a list of vocal files for the given character. + * Automatically resolves suffixed character IDs (so bf-car will resolve to bf if needed). + * + * @param id The character we are about to play. + */ + public function buildVoiceList():Array + { + var result:Array = []; + result = result.concat(buildPlayerVoiceList()); + result = result.concat(buildOpponentVoiceList()); + if (result.length == 0) + { + var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; + // Try to use `Voices.ogg` if no other voices are found. + if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); + } + return result; + } + + public function buildPlayerVoiceList():Array + { + var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; + + // Automatically resolve voices by removing suffixes. + // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. + // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. + + if (characters.playerVocals == null) + { + var playerId:String = characters.player; + var playerVoice:String = Paths.voices(this.song.id, '-${playerId}$suffix'); + + while (playerVoice != null && !Assets.exists(playerVoice)) + { + // Remove the last suffix. + // For example, bf-car becomes bf. + playerId = playerId.split('-').slice(0, -1).join('-'); + // Try again. + playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); + } + if (playerVoice == null) + { + // Try again without $suffix. + playerId = characters.player; + playerVoice = Paths.voices(this.song.id, '-${playerId}'); + while (playerVoice != null && !Assets.exists(playerVoice)) + { + // Remove the last suffix. + playerId = playerId.split('-').slice(0, -1).join('-'); + // Try again. + playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); + } + } + + return playerVoice != null ? [playerVoice] : []; + } + else + { + // The metadata explicitly defines the list of voices. + var playerIds:Array = characters?.playerVocals ?? [characters.player]; + var playerVoices:Array = playerIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); + + return playerVoices; + } + } + + public function buildOpponentVoiceList():Array + { + var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; + + // Automatically resolve voices by removing suffixes. + // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. + // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. + + if (characters.opponentVocals == null) + { + var opponentId:String = characters.opponent; + var opponentVoice:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); + while (opponentVoice != null && !Assets.exists(opponentVoice)) + { + // Remove the last suffix. + opponentId = opponentId.split('-').slice(0, -1).join('-'); + // Try again. + opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + } + if (opponentVoice == null) + { + // Try again without $suffix. + opponentId = characters.opponent; + opponentVoice = Paths.voices(this.song.id, '-${opponentId}'); + while (opponentVoice != null && !Assets.exists(opponentVoice)) + { + // Remove the last suffix. + opponentId = opponentId.split('-').slice(0, -1).join('-'); + // Try again. + opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + } + } + + return opponentVoice != null ? [opponentVoice] : []; + } + else + { + // The metadata explicitly defines the list of voices. + var opponentIds:Array = characters?.opponentVocals ?? [characters.opponent]; + var opponentVoices:Array = opponentIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); + + return opponentVoices; + } + } + + /** + * Create a VoicesGroup, an audio object that can play the vocals for all characters. + * @param charId The player ID. + * @return The generated vocal group. + */ + public function buildVocals(?instId:String = ''):VoicesGroup + { + var result:VoicesGroup = new VoicesGroup(); + + var playerVoiceList:Array = this.buildPlayerVoiceList(); + var opponentVoiceList:Array = this.buildOpponentVoiceList(); + + // Add player vocals. + for (playerVoice in playerVoiceList) + { + if (!Assets.exists(playerVoice)) continue; + result.addPlayerVoice(FunkinSound.load(playerVoice, 1.0, false, false, false, false, null, null, true)); + } + + // Add opponent vocals. + for (opponentVoice in opponentVoiceList) + { + if (!Assets.exists(opponentVoice)) continue; + result.addOpponentVoice(FunkinSound.load(opponentVoice, 1.0, false, false, false, false, null, null, true)); + } + + // Sometimes the sounds don't set their important value to true, so we have to do this manually. + result.forEach(function(snd:FunkinSound) { + snd.important = true; + }); + + result.playerVoicesOffset = offsets.getVocalOffset(characters.player, instId); + result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent, instId); + + return result; + } +} diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx new file mode 100644 index 00000000..3c6c18ab --- /dev/null +++ b/source/funkin/play/song/SongSerializer.hx @@ -0,0 +1,76 @@ +package funkin.play.song; + +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.util.FileUtil; +import openfl.net.FileReference; + +/** + * TODO: Refactor and remove this. + */ +class SongSerializer +{ + /** + * Access a SongChartData JSON file from a specific path, then load it. + * @param path The file path to read from. + */ + public static function importSongChartDataSync(path:String):SongChartData + { + var fileData = FileUtil.readStringFromPath(path); + + if (fileData == null) return null; + + var songChartData:SongChartData = fileData.parseJSON(); + + return songChartData; + } + + /** + * Access a SongMetadata JSON file from a specific path, then load it. + * @param path The file path to read from. + */ + public static function importSongMetadataSync(path:String):SongMetadata + { + var fileData = FileUtil.readStringFromPath(path); + + if (fileData == null) return null; + + var songMetadata:SongMetadata = fileData.parseJSON(); + + return songMetadata; + } + + /** + * Prompt the user to browse for a SongChartData JSON file path, then load it. + * @param callback The function to call when the file is loaded. + */ + public static function importSongChartDataAsync(callback:SongChartData->Void):Void + { + FileUtil.browseFileReference(function(fileReference:FileReference) { + var data = fileReference.data.toString(); + + if (data == null) return; + + var songChartData:SongChartData = data.parseJSON(); + + if (songChartData != null) callback(songChartData); + }); + } + + /** + * Prompt the user to browse for a SongMetadata JSON file path, then load it. + * @param callback The function to call when the file is loaded. + */ + public static function importSongMetadataAsync(callback:SongMetadata->Void):Void + { + FileUtil.browseFileReference(function(fileReference:FileReference) { + var data = fileReference.data.toString(); + + if (data == null) return; + + var songMetadata:SongMetadata = data.parseJSON(); + + if (songMetadata != null) callback(songMetadata); + }); + } +} diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx new file mode 100644 index 00000000..b539da77 --- /dev/null +++ b/source/funkin/play/stage/Bopper.hx @@ -0,0 +1,391 @@ +package funkin.play.stage; + +import flixel.FlxCamera; +import flixel.math.FlxPoint; +import flixel.util.FlxTimer; +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.modding.events.ScriptEvent; + +typedef AnimationFrameCallback = String->Int->Int->Void; +typedef AnimationFinishedCallback = String->Void; + +/** + * A Bopper is a stage prop which plays a dance animation. + * Y'know, a thingie that bops. A bopper. + */ +class Bopper extends StageProp implements IPlayStateScriptedClass +{ + /** + * The bopper plays the dance animation once every `danceEvery` beats. + * Set to 0 to disable idle animation. + * Supports up to 0.25 precision. + * @default 0.0 on props, 1.0 on characters + */ + public var danceEvery:Float = 0.0; + + /** + * Whether the bopper should dance left and right. + * - If true, alternate playing `danceLeft` and `danceRight`. + * - If false, play `idle` every time. + * + * You can manually set this value, or you can leave it as `null` to determine it automatically. + */ + public var shouldAlternate:Null = null; + + /** + * Offset the character's sprite by this much when playing each animation. + */ + public var animationOffsets:Map> = new Map>(); + + /** + * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations) + * that this bopper will play. + */ + public var idleSuffix(default, set):String = ''; + + /** + * If this bopper is rendered with pixel art, disable anti-aliasing. + * @default `false` + */ + public var isPixel(default, set):Bool = false; + + function set_isPixel(value:Bool):Bool + { + if (isPixel == value) return value; + return isPixel = value; + } + + /** + * Whether this bopper should bop every beat. By default it's true, but when used + * for characters/players, it should be false so it doesn't cut off their animations!!!!! + */ + public var shouldBop:Bool = true; + + function set_idleSuffix(value:String):String + { + this.idleSuffix = value; + this.dance(); + return value; + } + + /** + * The offset of the character relative to the position specified by the stage. + */ + public var globalOffsets(default, set):Array = [0, 0]; + + function set_globalOffsets(value:Array):Array + { + if (globalOffsets == null) globalOffsets = [0, 0]; + if (globalOffsets == value) return value; + + return globalOffsets = value; + } + + @:allow(funkin.ui.debug.anim.DebugBoundingState) + var animOffsets(default, set):Array = [0, 0]; + + public var originalPosition:FlxPoint = new FlxPoint(0, 0); + + function set_animOffsets(value:Array):Array + { + if (animOffsets == null) animOffsets = [0, 0]; + if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; + + return animOffsets = value; + } + + /** + * Whether to play `danceRight` next iteration. + * Only used when `shouldAlternate` is true. + */ + var hasDanced:Bool = false; + + public function new(danceEvery:Float = 0.0) + { + super(); + this.danceEvery = danceEvery; + + if (this.animation != null) + { + this.animation.onFrameChange.add(this.onAnimationFrame); + this.animation.onFinish.add(this.onAnimationFinished); + } + } + + /** + * Called when an animation finishes. + * @param name The name of the animation that just finished. + */ + function onAnimationFinished(name:String) + { + // TODO: Can we make a system of like, animation priority or something? + if (!canPlayOtherAnims) + { + canPlayOtherAnims = true; + } + } + + /** + * Called when the current animation's frame changes. + * @param name The name of the current animation. + * @param frameNumber The number of the current frame. + * @param frameIndex The index of the current frame. + * + * For example, if an animation was defined as having the indexes [3, 0, 1, 2], + * then the first callback would have frameNumber = 0 and frameIndex = 3. + */ + function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) + { + // Do nothing by default. + // This can be overridden by, for example, scripted characters, + // or by calling `animationFrame.add()`. + + // Try not to do anything expensive here, it runs many times a second. + } + + /** + * If this Bopper was defined by the stage, return the prop to its original position. + */ + public function resetPosition() + { + this.x = originalPosition.x; + this.y = originalPosition.y; + } + + function update_shouldAlternate():Void + { + this.shouldAlternate = hasAnimation('danceLeft'); + } + + /** + * Called once every step of the song. + */ + public function onStepHit(event:SongTimeScriptEvent) + { + if (danceEvery > 0 && (event.step % (danceEvery * Constants.STEPS_PER_BEAT)) == 0) + { + dance(shouldBop); + } + } + + public function onBeatHit(event:SongTimeScriptEvent):Void {} + + /** + * Called every `danceEvery` beats of the song. + */ + public function dance(forceRestart:Bool = false):Void + { + if (this.animation == null) + { + return; + } + + if (shouldAlternate == null) + { + update_shouldAlternate(); + } + + if (shouldAlternate) + { + if (hasDanced) + { + playAnimation('danceRight$idleSuffix', forceRestart); + } + else + { + playAnimation('danceLeft$idleSuffix', forceRestart); + } + hasDanced = !hasDanced; + } + else + { + playAnimation('idle$idleSuffix', forceRestart); + } + } + + public function hasAnimation(id:String):Bool + { + if (this.animation == null) return false; + + return this.animation.getByName(id) != null; + } + + /** + * Ensure that a given animation exists before playing it. + * Will gracefully check for name, then name with stripped suffixes, then fail to play. + * @param name The animation name to attempt to correct. + * @param fallback Instead of failing to play, try to play this animation instead. + */ + function correctAnimationName(name:String, ?fallback:String):String + { + // If the animation exists, we're good. + if (hasAnimation(name)) return name; + + // Attempt to strip a `-alt` suffix, if it exists. + if (name.lastIndexOf('-') != -1) + { + var correctName = name.substring(0, name.lastIndexOf('-')); + FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes ($correctName)...'); + return correctAnimationName(correctName); + } + else + { + if (fallback != null) + { + if (fallback == name) + { + FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); + return null; + } + else + { + FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...'); + return correctAnimationName('idle'); + } + } + else + { + FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); + return null; + } + } + } + + public var canPlayOtherAnims:Bool = true; + + public var ignoreExclusionPref:Array = []; + + /** + * @param name The name of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing + * @param reversed If true, play the animation backwards, from the last frame to the first. + */ + public function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reversed:Bool = false):Void + { + if ((!canPlayOtherAnims)) + { + var id = name; + if (getCurrentAnimation() == id && restart) {} + else if (ignoreExclusionPref != null && ignoreExclusionPref.length > 0) + { + var detected:Bool = false; + for (entry in ignoreExclusionPref) + { + if (StringTools.startsWith(id, entry)) + { + detected = true; + break; + } + } + if (!detected) return; + } + else + return; + } + + var correctName = correctAnimationName(name); + if (correctName == null) return; + + this.animation.play(correctName, restart, reversed, 0); + + if (ignoreOther) + { + canPlayOtherAnims = false; + } + + applyAnimationOffsets(correctName); + } + + var forceAnimationTimer:FlxTimer = new FlxTimer(); + + /** + * @param name The animation to play. + * @param duration The duration in which other (non-forced) animations will be skipped, in seconds (NOT MILLISECONDS). + */ + public function forceAnimationForDuration(name:String, duration:Float):Void + { + // TODO: Might be nice to rework this function, maybe have a numbered priority system? + + if (this.animation == null) return; + + var correctName = correctAnimationName(name); + if (correctName == null) return; + + this.animation.play(correctName, false, false); + applyAnimationOffsets(correctName); + + canPlayOtherAnims = false; + forceAnimationTimer.start(duration, (timer) -> { + canPlayOtherAnims = true; + }, 1); + } + + function applyAnimationOffsets(name:String):Void + { + var offsets = animationOffsets.get(name); + this.animOffsets = offsets; + } + + public function isAnimationFinished():Bool + { + return this.animation?.finished ?? false; + } + + public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void + { + animationOffsets.set(name, [xOffset, yOffset]); + } + + /** + * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the character is BROKEN!), + * returns an empty string to prevent NPEs. + */ + public function getCurrentAnimation():String + { + if (this.animation == null || this.animation.curAnim == null) return ""; + return this.animation.curAnim.name; + } + + // override getScreenPosition (used by FlxSprite's draw method) to account for animation offsets. + override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint + { + var output:FlxPoint = super.getScreenPosition(result, camera); + output.x -= (animOffsets[0] - globalOffsets[0]) * this.scale.x; + output.y -= (animOffsets[1] - globalOffsets[1]) * this.scale.y; + return output; + } + + public function onPause(event:PauseScriptEvent) {} + + public function onResume(event:ScriptEvent) {} + + public function onSongStart(event:ScriptEvent) {} + + public function onSongEnd(event:ScriptEvent) {} + + public function onGameOver(event:ScriptEvent) {} + + public function onNoteIncoming(event:NoteScriptEvent) {} + + public function onNoteHit(event:HitNoteScriptEvent) {} + + public function onNoteHoldDrop(event:HoldNoteScriptEvent) {} + + public function onNoteMiss(event:NoteScriptEvent) {} + + public function onSongEvent(event:SongEventScriptEvent) {} + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + + public function onCountdownStart(event:CountdownScriptEvent) {} + + public function onCountdownStep(event:CountdownScriptEvent) {} + + public function onCountdownEnd(event:CountdownScriptEvent) {} + + public function onSongLoaded(event:SongLoadScriptEvent) {} + + public function onSongRetry(event:SongRetryEvent) {} +} diff --git a/source/funkin/play/stage/ScriptedBopper.hx b/source/funkin/play/stage/ScriptedBopper.hx new file mode 100644 index 00000000..d35d5350 --- /dev/null +++ b/source/funkin/play/stage/ScriptedBopper.hx @@ -0,0 +1,8 @@ +package funkin.play.stage; + +/** + * A script that can be tied to a Bopper. + * Create a scripted class that extends Bopper to use this. + */ +@:hscriptClass +class ScriptedBopper extends funkin.play.stage.Bopper implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/stage/ScriptedStage.hx b/source/funkin/play/stage/ScriptedStage.hx new file mode 100644 index 00000000..95bdec50 --- /dev/null +++ b/source/funkin/play/stage/ScriptedStage.hx @@ -0,0 +1,9 @@ +package funkin.play.stage; + +/** + * A script that can be tied to a Stage. + * Create a scripted class that extends Stage, + * and call `super('stageID')` in the constructor. + */ +@:hscriptClass +class ScriptedStage extends funkin.play.stage.Stage implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx new file mode 100644 index 00000000..cf432a52 --- /dev/null +++ b/source/funkin/play/stage/Stage.hx @@ -0,0 +1,899 @@ +package funkin.play.stage; + +import openfl.display.BlendMode; +import funkin.graphics.framebuffer.FrameBufferManager; +import flixel.util.FlxColor; +import funkin.graphics.FunkinCamera; +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxPoint; +import flixel.system.FlxAssets.FlxShader; +import flixel.util.FlxSort; +import openfl.display.BitmapData; +import funkin.modding.IScriptedClass.IPlayStateScriptedClass; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.BaseCharacter; +import funkin.data.IRegistryEntry; +import funkin.data.stage.StageData; +import funkin.data.stage.StageData.StageDataCharacter; +import funkin.data.stage.StageRegistry; +import funkin.util.SortUtil; +import funkin.util.assets.FlxAnimationUtil; + +typedef StagePropGroup = FlxTypedSpriteGroup; + +/** + * A Stage is a group of objects rendered in the PlayState. + * + * A Stage is comprised of one or more props, each of which is an FlxSprite. + */ +class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements IRegistryEntry +{ + public var stageName(get, never):String; + + function get_stageName():String + { + return _data?.name ?? 'Unknown'; + } + + public var camZoom(get, never):Float; + + function get_camZoom():Float + { + return _data?.cameraZoom ?? 1.0; + } + + var frameBufferMan:FrameBufferManager; + + /** + * The texture that has the mask information. Used for shader effects. + */ + public var maskTexture:BitmapData; + + var namedProps:Map = new Map(); + var characters:Map = new Map(); + var boppers:Array = new Array(); + + /** + * The Stage elements get initialized at the beginning of the game. + * They're used to cache the data needed to build the stage, + * then accessed and fleshed out when the stage needs to be built. + * + * @param id + */ + public function new(id:String) + { + super(); + + this.id = id; + _data = _fetchData(id); + + if (_data == null) + { + throw 'Could not find stage data for stage id: $id'; + } + } + + /** + * Called when the player is moving into the PlayState where the song will be played. + */ + public function onCreate(event:ScriptEvent):Void + { + if (frameBufferMan != null) frameBufferMan.dispose(); + frameBufferMan = new FrameBufferManager(FlxG.camera); + setupFrameBuffers(); + + buildStage(); + this.refresh(); + + debugIconGroup = new FlxSpriteGroup(); + debugIconGroup.visible = false; + debugIconGroup.zIndex = 1000000; + // add(debugIconGroup); + } + + public function resetStage():Void + { + // Reset positions of characters. + if (getBoyfriend() != null) + { + getBoyfriend().resetCharacter(true); + // Reapply the camera offsets. + var stageCharData:StageDataCharacter = _data.characters.bf; + var finalScale:Float = getBoyfriend().getBaseScale() * stageCharData.scale; + getBoyfriend().setScale(finalScale); + getBoyfriend().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getBoyfriend().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; + } + else + { + trace('STAGE RESET: No boyfriend found.'); + } + if (getGirlfriend() != null) + { + getGirlfriend().resetCharacter(true); + // Reapply the camera offsets. + var stageCharData:StageDataCharacter = _data.characters.gf; + var finalScale:Float = getGirlfriend().getBaseScale() * stageCharData.scale; + getGirlfriend().setScale(finalScale); + getGirlfriend().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getGirlfriend().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; + } + if (getDad() != null) + { + getDad().resetCharacter(true); + // Reapply the camera offsets. + var stageCharData:StageDataCharacter = _data.characters.dad; + var finalScale:Float = getDad().getBaseScale() * stageCharData.scale; + getDad().setScale(finalScale); + getDad().cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + getDad().cameraFocusPoint.y += stageCharData.cameraOffsets[1]; + } + + // Reset positions of named props. + for (dataProp in _data.props) + { + // Fetch the prop. + var prop:StageProp = getNamedProp(dataProp.name); + + if (prop != null) + { + // Reset the position. + prop.x = dataProp.position[0]; + prop.y = dataProp.position[1]; + prop.zIndex = dataProp.zIndex; + } + } + + // We can assume unnamed props are not moving. + } + + /** + * The default stage construction routine. Called when the stage is going to be played in. + * Instantiates each prop and adds it to the stage, while setting its parameters. + */ + function buildStage():Void + { + trace('Building stage for display: ${this.id}'); + + this.debugIconGroup = new FlxSpriteGroup(); + + for (dataProp in _data.props) + { + trace(' Placing prop: ${dataProp.name} (${dataProp.assetPath})'); + + var isSolidColor = dataProp.assetPath.startsWith('#'); + var isAnimated = dataProp.animations.length > 0; + + var propSprite:StageProp; + if (dataProp.danceEvery != 0) + { + propSprite = new Bopper(dataProp.danceEvery); + } + else + { + propSprite = new StageProp(); + } + + if (isAnimated) + { + // Initalize sprite frames. + switch (dataProp.animType) + { + case 'packer': + propSprite.loadPacker(dataProp.assetPath); + default: // 'sparrow' + propSprite.loadSparrow(dataProp.assetPath); + } + } + else if (isSolidColor) + { + var width:Int = 1; + var height:Int = 1; + switch (dataProp.scale) + { + case Left(value): + width = Std.int(value); + height = Std.int(value); + + case Right(values): + width = Std.int(values[0]); + height = Std.int(values[1]); + } + propSprite.makeSolidColor(width, height, FlxColor.fromString(dataProp.assetPath)); + } + else + { + // Initalize static sprite. + propSprite.loadTexture(dataProp.assetPath); + + // Disables calls to update() for a performance boost. + propSprite.active = false; + } + + if (propSprite.frames == null || propSprite.frames.numFrames == 0) + { + @:privateAccess + trace(' ERROR: Could not build texture for prop. Check the asset path (${Paths.currentLevel ?? 'default'}, ${dataProp.assetPath}).'); + continue; + } + + if (!isSolidColor) + { + switch (dataProp.scale) + { + case Left(value): + propSprite.scale.set(value, value); + + case Right(values): + propSprite.scale.set(values[0], values[1]); + } + } + propSprite.updateHitbox(); + + propSprite.x = dataProp.position[0]; + propSprite.y = dataProp.position[1]; + + propSprite.alpha = dataProp.alpha; + + // If pixel, disable antialiasing. + propSprite.antialiasing = !dataProp.isPixel; + + // If pixel, we render it pixel perfect so there's less "mixels" + // propSprite.pixelPerfectRender = dataProp.isPixel; + // propSprite.pixelPerfectPosition = dataProp.isPixel; + + propSprite.scrollFactor.x = dataProp.scroll[0]; + propSprite.scrollFactor.y = dataProp.scroll[1]; + + propSprite.angle = dataProp.angle; + propSprite.color = FlxColor.fromString(dataProp.color); + @:privateAccess if (!isSolidColor) propSprite.blend = BlendMode.fromString(dataProp.blend); + + propSprite.zIndex = dataProp.zIndex; + + propSprite.flipX = dataProp.flipX; + propSprite.flipY = dataProp.flipY; + + switch (dataProp.animType) + { + case 'packer': + for (propAnim in dataProp.animations) + { + propSprite.animation.add(propAnim.name, propAnim.frameIndices); + + if (Std.isOfType(propSprite, Bopper)) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + } + default: // 'sparrow' + FlxAnimationUtil.addAtlasAnimations(propSprite, dataProp.animations); + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + } + } + + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + + if (!Std.isOfType(propSprite, BaseCharacter)) + { + cast(propSprite, Bopper).originalPosition.x = dataProp.position[0]; + cast(propSprite, Bopper).originalPosition.y = dataProp.position[1]; + } + } + + if (dataProp.startingAnimation != null) + { + propSprite.animation.play(dataProp.startingAnimation); + } + + if (Std.isOfType(propSprite, BaseCharacter)) + { + // Character stuff. + } + else if (Std.isOfType(propSprite, Bopper)) + { + addBopper(cast propSprite, dataProp.name); + } + else + { + addProp(propSprite, dataProp.name); + } + } + } + + /** + * Add a sprite to the stage. + * @param prop The sprite to add. + * @param name (Optional) A unique name for the sprite. + * You can call `getNamedProp(name)` to retrieve it later. + */ + public function addProp(prop:StageProp, ?name:String = null):Void + { + if (name != null) + { + namedProps.set(name, prop); + prop.name = name; + } + this.add(prop); + } + + /** + * Add a sprite to the stage which animates to the beat of the song. + */ + public function addBopper(bopper:Bopper, ?name:String = null):Void + { + boppers.push(bopper); + this.addProp(bopper, name); + bopper.name = name; + } + + /** + * Refreshes the stage, by redoing the render order of all props. + * It does this based on the `zIndex` of each prop. + */ + public function refresh():Void + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + /** + * Sets a shader for each prop in the stage + * @param shader The shader to apply to each prop + */ + public function setShader(shader:FlxShader):Void + { + forEachAlive(function(prop:FlxSprite) { + prop.shader = shader; + }); + } + + /** + * Adjusts the position and other properties of the soon-to-be child of this sprite group. + * Private helper to avoid duplicate code in `add()` and `insert()`. + * + * @param Sprite The sprite or sprite group that is about to be added or inserted into the group. + */ + override function preAdd(Sprite:FlxSprite):Void + { + if (Sprite == null) return; + var sprite:FlxSprite = cast Sprite; + sprite.x += x; + sprite.y += y; + sprite.alpha *= alpha; + // Don't override scroll factors. + // sprite.scrollFactor.copyFrom(scrollFactor); + sprite.cameras = _cameras; // _cameras instead of cameras because get_cameras() will not return null + + if (clipRect != null) clipRectTransform(sprite, clipRect); + } + + var debugIconGroup:FlxSpriteGroup; + + /** + * Used by the PlayState to add a character to the stage. + */ + public function addCharacter(character:BaseCharacter, charType:CharacterType):Void + { + if (character == null) return; + + #if FEATURE_DEBUG_FUNCTIONS + // Temporary marker that shows where the character's location is relative to. + // Should display at the stage position of the character (before any offsets). + // TODO: Make this a toggle? It's useful to turn on from time to time. + var debugIcon:FlxSprite = new FlxSprite(0, 0); + var debugIcon2:FlxSprite = new FlxSprite(0, 0); + debugIcon.makeGraphic(8, 8, 0xffff00ff); + debugIcon2.makeGraphic(8, 8, 0xff00ffff); + debugIcon.visible = true; + debugIcon2.visible = true; + debugIcon.zIndex = 1000000; + debugIcon2.zIndex = 1000000; + #end + + // Apply position and z-index. + var stageCharData:StageDataCharacter = null; + switch (charType) + { + case BF: + this.characters.set('bf', character); + stageCharData = _data.characters.bf; + character.flipX = !character.getDataFlipX(); + character.name = 'bf'; + character.initHealthIcon(false); + case GF: + this.characters.set('gf', character); + stageCharData = _data.characters.gf; + character.flipX = character.getDataFlipX(); + character.name = 'gf'; + case DAD: + this.characters.set('dad', character); + stageCharData = _data.characters.dad; + character.flipX = character.getDataFlipX(); + character.name = 'dad'; + character.initHealthIcon(true); + default: + this.characters.set(character.characterId, character); + } + + // Reset the character before adding it to the stage. + // This ensures positioning is based on the idle animation. + character.resetCharacter(true); + + if (stageCharData != null) + { + character.zIndex = stageCharData.zIndex; + + // Start with the per-stage character position. + // Subtracting the origin ensures characters are positioned relative to their feet. + // Subtracting the global offset allows positioning on a per-character basis. + // We previously applied the global offset here but that is now done elsewhere. + character.x = stageCharData.position[0] - character.characterOrigin.x; + character.y = stageCharData.position[1] - character.characterOrigin.y; + + character.originalPosition.set(character.x, character.y); + + var finalScale = character.getBaseScale() * stageCharData.scale; + character.setScale(finalScale); // Don't use scale.set for characters! + character.cameraFocusPoint.x += stageCharData.cameraOffsets[0]; + character.cameraFocusPoint.y += stageCharData.cameraOffsets[1]; + + character.scrollFactor.x = stageCharData.scroll[0]; + character.scrollFactor.y = stageCharData.scroll[1]; + + character.alpha = stageCharData.alpha; + character.angle = stageCharData.angle; + + #if FEATURE_DEBUG_FUNCTIONS + // Draw the debug icon at the character's feet. + if (charType == BF || charType == DAD) + { + debugIcon.x = stageCharData.position[0]; + debugIcon.y = stageCharData.position[1]; + debugIcon2.x = character.x; + debugIcon2.y = character.y; + } + #end + } + + // Set the characters type + character.characterType = charType; + + // Add the character to the scene. + this.add(character); + + ScriptEventDispatcher.callEvent(character, new ScriptEvent(ADDED, false)); + + #if FEATURE_DEBUG_FUNCTIONS + debugIconGroup.add(debugIcon); + debugIconGroup.add(debugIcon2); + #end + } + + /** + * Get the position of the girlfriend character, as defined in the stage data. + * @return An FlxPoint position. + */ + public inline function getGirlfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]); + } + + /** + * Get the position of the boyfriend character, as defined in the stage data. + * @return An FlxPoint position. + */ + public inline function getBoyfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]); + } + + /** + * Get the position of the dad character, as defined in the stage data. + * @return An FlxPoint position. + */ + public inline function getDadPosition():FlxPoint + { + return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]); + } + + /** + * Retrieves a given character from the stage. + */ + public function getCharacter(id:String):BaseCharacter + { + return this.characters.get(id); + } + + /** + * Retrieve the Boyfriend character. + * @param pop If true, the character will be removed from the stage as well. + * @return The Boyfriend character. + */ + public function getBoyfriend(pop:Bool = false):BaseCharacter + { + if (pop) + { + var boyfriend:BaseCharacter = getCharacter('bf'); + + // Remove the character from the stage. + this.remove(boyfriend); + this.characters.remove('bf'); + + return boyfriend; + } + else + { + return getCharacter('bf'); + } + } + + /** + * Retrieve the player/Boyfriend character. + * @param pop If true, the character will be removed from the stage as well. + * @return The player/Boyfriend character. + */ + public function getPlayer(pop:Bool = false):BaseCharacter + { + return getBoyfriend(pop); + } + + /** + * Retrieve the Girlfriend character. + * @param pop If true, the character will be removed from the stage as well. + * @return The Girlfriend character. + */ + public function getGirlfriend(pop:Bool = false):BaseCharacter + { + if (pop) + { + var girlfriend:BaseCharacter = getCharacter('gf'); + + // Remove the character from the stage. + this.remove(girlfriend); + this.characters.remove('gf'); + + return girlfriend; + } + else + { + return getCharacter('gf'); + } + } + + /** + * Retrieve the Dad character. + * @param pop If true, the character will be removed from the stage as well. + * @return The Dad character. + */ + public function getDad(pop:Bool = false):BaseCharacter + { + if (pop) + { + var dad:BaseCharacter = getCharacter('dad'); + + // Remove the character from the stage. + this.remove(dad); + this.characters.remove('dad'); + + return dad; + } + else + { + return getCharacter('dad'); + } + } + + /** + * Retrieve the opponent/Dad character. + * @param pop If true, the character will be removed from the stage as well. + * @return The opponent character. + */ + public function getOpponent(pop:Bool = false):BaseCharacter + { + return getDad(pop); + } + + /** + * Retrieve a specific prop by the name assigned in the JSON file. + * @param name The name of the prop to retrieve. + * @return The corresponding FlxSprite. + */ + public function getNamedProp(name:String):StageProp + { + return this.namedProps.get(name); + } + + /** + * Pause the animations of ALL sprites in this group. + */ + public function pause():Void + { + forEachAlive(function(prop:FlxSprite) { + if (prop.animation != null) prop.animation.pause(); + }); + } + + /** + * Resume the animations of ALL sprites in this group. + */ + public function resume():Void + { + forEachAlive(function(prop:FlxSprite) { + if (prop.animation != null) prop.animation.resume(); + }); + } + + /** + * Retrieve a list of all the asset paths required to load the stage. + * Override this in a scripted class to ensure that all necessary assets are loaded! + * + * @return An array of file names. + */ + public function fetchAssetPaths():Array + { + var result:Array = []; + for (dataProp in _data.props) + { + result.push(Paths.image(dataProp.assetPath)); + } + return result; + } + + /** + * Dispatch an event to all the characters in the stage. + * @param event The script event to dispatch. + */ + public function dispatchToCharacters(event:ScriptEvent):Void + { + var charList = this.characters.keys().array(); + + // Dad, then BF, then GF, in that order. + + if (charList.contains('dad')) + { + dispatchToCharacter('dad', event); + charList.remove('dad'); + } + + if (charList.contains('bf')) + { + dispatchToCharacter('bf', event); + charList.remove('bf'); + } + + if (charList.contains('gf')) + { + dispatchToCharacter('gf', event); + charList.remove('gf'); + } + + // Then the rest of the characters, if any. + for (characterId in charList) + { + dispatchToCharacter(characterId, event); + } + } + + /** + * Dispatch an event to a specific character. + * @param characterId The ID of the character to dispatch to. + * @param event The script event to dispatch. + */ + public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void + { + var character:BaseCharacter = getCharacter(characterId); + if (character != null) + { + ScriptEventDispatcher.callEvent(character, event); + } + } + + /** + * onDestroy gets called when the player is leaving the PlayState, + * and is used to clean up any objects that need to be destroyed. + */ + public function onDestroy(event:ScriptEvent):Void + { + // Make sure to call kill() when returning a stage to cache, + // and destroy() only when performing a hard cache refresh. + kill(); + + for (prop in this.namedProps) + { + if (prop != null) + { + remove(prop); + prop.kill(); + prop.destroy(); + } + } + namedProps.clear(); + + for (char in this.characters) + { + if (char != null) + { + remove(char); + char.kill(); + char.destroy(); + } + } + characters.clear(); + + for (bopper in boppers) + { + if (bopper != null) + { + remove(bopper); + bopper.kill(); + bopper.destroy(); + } + } + boppers = []; + + if (group != null) + { + for (sprite in this.group) + { + if (sprite != null) + { + sprite.kill(); + sprite.destroy(); + remove(sprite); + } + } + group.clear(); + } + + if (debugIconGroup != null && debugIconGroup.group != null) + { + debugIconGroup.kill(); + } + else + { + debugIconGroup = null; + } + + if (frameBufferMan != null) + { + frameBufferMan.dispose(); + } + } + + /** + * A function that gets called once per step in the song. + * @param curStep The current step number. + */ + public function onStepHit(event:SongTimeScriptEvent):Void {} + + /** + * A function that gets called once per beat in the song (once every four steps). + * @param curStep The current beat number. + */ + public function onBeatHit(event:SongTimeScriptEvent):Void {} + + public function onUpdate(event:UpdateScriptEvent) {} + + public override function kill() + { + _skipTransformChildren = true; + alive = false; + exists = false; + _skipTransformChildren = false; + if (group != null) group.kill(); + } + + public override function remove(Sprite:FlxSprite, Splice:Bool = false):FlxSprite + { + if (Sprite == null) return Sprite; + var sprite:FlxSprite = cast Sprite; + sprite.x -= x; + sprite.y -= y; + // alpha + sprite.cameras = null; + + if (group != null) group.remove(Sprite, Splice); + return Sprite; + } + + override function draw():Void + { + if (frameBufferMan != null) + { + frameBufferMan.lock(); + } + super.draw(); + if (frameBufferMan != null) + { + frameBufferMan.unlock(); + } + frameBuffersUpdated(); + } + + /** + * Called when the frame buffer manager is ready. + * Create frame buffers inside this method. + */ + function setupFrameBuffers():Void {} + + /** + * Called when all the frame buffers are updated. If you need any + * frame buffers before `grabScreen()`, make sure you + * grab the screen inside this method since it immediately uses the + * frame buffers. + */ + function frameBuffersUpdated():Void {} + + /** + * Grabs the current screen and returns it as a bitmap data. You can sefely modify it. + * @param applyFilters if this is `true`, the filters set to the camera will be applied to the resulting bitmap + * @return the grabbed screen + */ + function grabScreen(applyFilters:Bool):BitmapData + { + if (Std.isOfType(FlxG.camera, FunkinCamera)) + { + final cam:FunkinCamera = cast FlxG.camera; + return cam.grabScreen(applyFilters); + } + else + { + FlxG.log.error('cannot grab the screen: the main camera is not grabbable'); + return null; + } + } + + public function onScriptEvent(event:ScriptEvent) + { + // Ensure all custom events get broadcast to the elements of the stage. + // If we do it here, we don't have to add a handler to EACH script event function. + for (bopper in boppers) + { + ScriptEventDispatcher.callEvent(bopper, event); + } + } + + public function onPause(event:PauseScriptEvent) {} + + public function onResume(event:ScriptEvent) {} + + public function onSongStart(event:ScriptEvent) {} + + public function onSongEnd(event:ScriptEvent) {} + + public function onGameOver(event:ScriptEvent) {} + + public function onCountdownStart(event:CountdownScriptEvent) {} + + public function onCountdownStep(event:CountdownScriptEvent) {} + + public function onCountdownEnd(event:CountdownScriptEvent) {} + + public function onNoteIncoming(event:NoteScriptEvent) {} + + public function onNoteHit(event:HitNoteScriptEvent) {} + + public function onNoteMiss(event:NoteScriptEvent) {} + + public function onNoteHoldDrop(event:HoldNoteScriptEvent) {} + + public function onSongEvent(event:SongEventScriptEvent) {} + + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + + public function onSongLoaded(event:SongLoadScriptEvent) {} + + public function onSongRetry(event:SongRetryEvent) {} +} diff --git a/source/funkin/play/stage/StageProp.hx b/source/funkin/play/stage/StageProp.hx new file mode 100644 index 00000000..4d846162 --- /dev/null +++ b/source/funkin/play/stage/StageProp.hx @@ -0,0 +1,32 @@ +package funkin.play.stage; + +import funkin.modding.events.ScriptEvent; +import funkin.graphics.FunkinSprite; +import funkin.modding.IScriptedClass.IStateStageProp; + +class StageProp extends FunkinSprite implements IStateStageProp +{ + /** + * An internal name for this prop. + */ + public var name:String = ''; + + public function new() + { + super(); + } + + /** + * Called when this prop is added to the stage. + * @param event + */ + public function onAdd(event:ScriptEvent):Void {} + + public function onScriptEvent(event:ScriptEvent) {} + + public function onCreate(event:ScriptEvent) {} + + public function onDestroy(event:ScriptEvent) {} + + public function onUpdate(event:UpdateScriptEvent) {} +} diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx new file mode 100644 index 00000000..1ef2810d --- /dev/null +++ b/source/funkin/save/Save.hx @@ -0,0 +1,1702 @@ +package funkin.save; + +import flixel.util.FlxSave; +import funkin.input.Controls.Device; +import funkin.play.scoring.Scoring; +import funkin.play.scoring.Scoring.ScoringRank; +import funkin.save.migrator.RawSaveData_v1_0_0; +import funkin.save.migrator.SaveDataMigrator; +import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle; +import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme; +import funkin.ui.debug.stageeditor.StageEditorState.StageEditorTheme; +import funkin.util.FileUtil; +import funkin.util.SerializerUtil; +import thx.semver.Version; +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.Medals; +import funkin.api.newgrounds.Leaderboards; +#end + +@:nullSafety +class Save +{ + public static final SAVE_DATA_VERSION:thx.semver.Version = "2.1.0"; + public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = ">=2.1.0 <2.2.0"; + + // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility. + static final SAVE_PATH:String = 'FunkinCrew'; + static final SAVE_NAME:String = 'Funkin'; + + static final SAVE_PATH_LEGACY:String = 'ninjamuffin99'; + static final SAVE_NAME_LEGACY:String = 'funkin'; + + /** + * We always use this save slot. + * Alter this if you want to use a different save slot. + */ + static final BASE_SAVE_SLOT:Int = 1; + + public static var instance(get, never):Save; + static var _instance:Null = null; + + static function get_instance():Save + { + if (_instance == null) + { + return load(); + } + return _instance; + } + + var data:RawSaveData; + + public static function load():Save + { + trace("[SAVE] Loading save..."); + + // Bind save data. + var loadedSave:Save = loadFromSlot(BASE_SAVE_SLOT); + if (_instance == null) _instance = loadedSave; + + return loadedSave; + } + + public static function clearData():Void + { + _instance = clearSlot(BASE_SAVE_SLOT); + } + + /** + * Constructing a new Save will load the default values. + */ + public function new(?data:RawSaveData) + { + if (data == null) this.data = Save.getDefault(); + else + this.data = data; + + // Make sure the verison number is up to date before we flush. + updateVersionToLatest(); + } + + public static function getDefault():RawSaveData + { + return { + // Version number is an abstract(Array) internally. + // This means it copies by reference, so merging save data overides the version number lol. + version: thx.Dynamics.clone(Save.SAVE_DATA_VERSION), + + volume: 1.0, + mute: false, + + api: + { + newgrounds: + { + sessionId: null, + } + }, + scores: + { + // No saved scores. + levels: [], + songs: [], + }, + + favoriteSongs: [], + + options: + { + // Reasonable defaults. + framerate: 60, + naughtyness: true, + downscroll: false, + flashingLights: true, + zoomCamera: true, + debugDisplay: false, + autoPause: true, + vsyncMode: 'Off', + strumlineBackgroundOpacity: 0, + autoFullscreen: false, + inputOffset: 0, + audioVisualOffset: 0, + unlockedFramerate: false, + + screenshot: + { + shouldHideMouse: true, + fancyPreview: true, + previewOnSave: true, + saveFormat: 'PNG', + jpegQuality: 80, + }, + + controls: + { + // Leave controls blank so defaults are loaded. + p1: + { + keyboard: {}, + gamepad: {}, + }, + p2: + { + keyboard: {}, + gamepad: {}, + }, + }, + }, + + mods: + { + // No mods enabled. + enabledMods: [], + modOptions: [], + }, + + unlocks: + { + // Default to having seen the default character. + charactersSeen: ["bf"], + oldChar: false + }, + + optionsChartEditor: + { + // Reasonable defaults. + previousFiles: [], + noteQuant: 3, + chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None, + theme: ChartEditorTheme.Light, + playtestStartTime: false, + downscroll: false, + metronomeVolume: 1.0, + hitsoundVolumePlayer: 1.0, + hitsoundVolumeOpponent: 1.0, + themeMusic: true + }, + + optionsStageEditor: + { + previousFiles: [], + moveStep: "1px", + angleStep: 5, + theme: StageEditorTheme.Light + } + }; + } + + /** + * NOTE: Modifications will not be saved without calling `Save.flush()`! + */ + public var options(get, never):SaveDataOptions; + + function get_options():SaveDataOptions + { + return data.options; + } + + /** + * NOTE: Modifications will not be saved without calling `Save.flush()`! + */ + public var modOptions(get, never):Map; + + function get_modOptions():Map + { + return data.mods.modOptions; + } + + /** + * The current session ID for the logged-in Newgrounds user, or null if the user is cringe. + */ + public var ngSessionId(get, set):Null; + + function get_ngSessionId():Null + { + return data.api.newgrounds.sessionId; + } + + function set_ngSessionId(value:Null):Null + { + data.api.newgrounds.sessionId = value; + flush(); + return data.api.newgrounds.sessionId; + } + + public var enabledModIds(get, set):Array; + + function get_enabledModIds():Array + { + return data.mods.enabledMods; + } + + function set_enabledModIds(value:Array):Array + { + data.mods.enabledMods = value; + flush(); + return data.mods.enabledMods; + } + + public var chartEditorPreviousFiles(get, set):Array; + + function get_chartEditorPreviousFiles():Array + { + if (data.optionsChartEditor.previousFiles == null) data.optionsChartEditor.previousFiles = []; + + return data.optionsChartEditor.previousFiles; + } + + function set_chartEditorPreviousFiles(value:Array):Array + { + // Set and apply. + data.optionsChartEditor.previousFiles = value; + flush(); + return data.optionsChartEditor.previousFiles; + } + + public var chartEditorHasBackup(get, set):Bool; + + function get_chartEditorHasBackup():Bool + { + if (data.optionsChartEditor.hasBackup == null) data.optionsChartEditor.hasBackup = false; + + return data.optionsChartEditor.hasBackup; + } + + function set_chartEditorHasBackup(value:Bool):Bool + { + // Set and apply. + data.optionsChartEditor.hasBackup = value; + flush(); + return data.optionsChartEditor.hasBackup; + } + + public var chartEditorNoteQuant(get, set):Int; + + function get_chartEditorNoteQuant():Int + { + if (data.optionsChartEditor.noteQuant == null) data.optionsChartEditor.noteQuant = 3; + + return data.optionsChartEditor.noteQuant; + } + + function set_chartEditorNoteQuant(value:Int):Int + { + // Set and apply. + data.optionsChartEditor.noteQuant = value; + flush(); + return data.optionsChartEditor.noteQuant; + } + + public var chartEditorLiveInputStyle(get, set):ChartEditorLiveInputStyle; + + function get_chartEditorLiveInputStyle():ChartEditorLiveInputStyle + { + if (data.optionsChartEditor.chartEditorLiveInputStyle == null) data.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None; + + return data.optionsChartEditor.chartEditorLiveInputStyle; + } + + function set_chartEditorLiveInputStyle(value:ChartEditorLiveInputStyle):ChartEditorLiveInputStyle + { + // Set and apply. + data.optionsChartEditor.chartEditorLiveInputStyle = value; + flush(); + return data.optionsChartEditor.chartEditorLiveInputStyle; + } + + public var chartEditorDownscroll(get, set):Bool; + + function get_chartEditorDownscroll():Bool + { + if (data.optionsChartEditor.downscroll == null) data.optionsChartEditor.downscroll = false; + + return data.optionsChartEditor.downscroll; + } + + function set_chartEditorDownscroll(value:Bool):Bool + { + // Set and apply. + data.optionsChartEditor.downscroll = value; + flush(); + return data.optionsChartEditor.downscroll; + } + + public var chartEditorPlaytestStartTime(get, set):Bool; + + function get_chartEditorPlaytestStartTime():Bool + { + if (data.optionsChartEditor.playtestStartTime == null) data.optionsChartEditor.playtestStartTime = false; + + return data.optionsChartEditor.playtestStartTime; + } + + function set_chartEditorPlaytestStartTime(value:Bool):Bool + { + // Set and apply. + data.optionsChartEditor.playtestStartTime = value; + flush(); + return data.optionsChartEditor.playtestStartTime; + } + + public var chartEditorTheme(get, set):ChartEditorTheme; + + function get_chartEditorTheme():ChartEditorTheme + { + if (data.optionsChartEditor.theme == null) data.optionsChartEditor.theme = ChartEditorTheme.Light; + + return data.optionsChartEditor.theme; + } + + function set_chartEditorTheme(value:ChartEditorTheme):ChartEditorTheme + { + // Set and apply. + data.optionsChartEditor.theme = value; + flush(); + return data.optionsChartEditor.theme; + } + + public var chartEditorMetronomeVolume(get, set):Float; + + function get_chartEditorMetronomeVolume():Float + { + if (data.optionsChartEditor.metronomeVolume == null) data.optionsChartEditor.metronomeVolume = 1.0; + + return data.optionsChartEditor.metronomeVolume; + } + + function set_chartEditorMetronomeVolume(value:Float):Float + { + // Set and apply. + data.optionsChartEditor.metronomeVolume = value; + flush(); + return data.optionsChartEditor.metronomeVolume; + } + + public var chartEditorHitsoundVolumePlayer(get, set):Float; + + function get_chartEditorHitsoundVolumePlayer():Float + { + if (data.optionsChartEditor.hitsoundVolumePlayer == null) data.optionsChartEditor.hitsoundVolumePlayer = 1.0; + + return data.optionsChartEditor.hitsoundVolumePlayer; + } + + function set_chartEditorHitsoundVolumePlayer(value:Float):Float + { + // Set and apply. + data.optionsChartEditor.hitsoundVolumePlayer = value; + flush(); + return data.optionsChartEditor.hitsoundVolumePlayer; + } + + public var chartEditorHitsoundVolumeOpponent(get, set):Float; + + function get_chartEditorHitsoundVolumeOpponent():Float + { + if (data.optionsChartEditor.hitsoundVolumeOpponent == null) data.optionsChartEditor.hitsoundVolumeOpponent = 1.0; + + return data.optionsChartEditor.hitsoundVolumeOpponent; + } + + function set_chartEditorHitsoundVolumeOpponent(value:Float):Float + { + // Set and apply. + data.optionsChartEditor.hitsoundVolumeOpponent = value; + flush(); + return data.optionsChartEditor.hitsoundVolumeOpponent; + } + + public var chartEditorThemeMusic(get, set):Bool; + + function get_chartEditorThemeMusic():Bool + { + if (data.optionsChartEditor.themeMusic == null) data.optionsChartEditor.themeMusic = true; + + return data.optionsChartEditor.themeMusic; + } + + function set_chartEditorThemeMusic(value:Bool):Bool + { + // Set and apply. + data.optionsChartEditor.themeMusic = value; + flush(); + return data.optionsChartEditor.themeMusic; + } + + public var chartEditorPlaybackSpeed(get, set):Float; + + function get_chartEditorPlaybackSpeed():Float + { + if (data.optionsChartEditor.playbackSpeed == null) data.optionsChartEditor.playbackSpeed = 1.0; + + return data.optionsChartEditor.playbackSpeed; + } + + function set_chartEditorPlaybackSpeed(value:Float):Float + { + // Set and apply. + data.optionsChartEditor.playbackSpeed = value; + flush(); + return data.optionsChartEditor.playbackSpeed; + } + + public var charactersSeen(get, never):Array; + + function get_charactersSeen():Array + { + return data.unlocks.charactersSeen; + } + + /** + * Marks whether the player has seen the spotlight animation, which should only display once per save file ever. + */ + public var oldChar(get, set):Bool; + + function get_oldChar():Bool + { + return data.unlocks.oldChar; + } + + function set_oldChar(value:Bool):Bool + { + data.unlocks.oldChar = value; + flush(); + return data.unlocks.oldChar; + } + + public var stageEditorPreviousFiles(get, set):Array; + + function get_stageEditorPreviousFiles():Array + { + if (data.optionsStageEditor.previousFiles == null) data.optionsStageEditor.previousFiles = []; + + return data.optionsStageEditor.previousFiles; + } + + function set_stageEditorPreviousFiles(value:Array):Array + { + // Set and apply. + data.optionsStageEditor.previousFiles = value; + flush(); + return data.optionsStageEditor.previousFiles; + } + + public var stageEditorHasBackup(get, set):Bool; + + function get_stageEditorHasBackup():Bool + { + if (data.optionsStageEditor.hasBackup == null) data.optionsStageEditor.hasBackup = false; + + return data.optionsStageEditor.hasBackup; + } + + function set_stageEditorHasBackup(value:Bool):Bool + { + // Set and apply. + data.optionsStageEditor.hasBackup = value; + flush(); + return data.optionsStageEditor.hasBackup; + } + + public var stageEditorMoveStep(get, set):String; + + function get_stageEditorMoveStep():String + { + if (data.optionsStageEditor.moveStep == null) data.optionsStageEditor.moveStep = "1px"; + + return data.optionsStageEditor.moveStep; + } + + function set_stageEditorMoveStep(value:String):String + { + // Set and apply. + data.optionsStageEditor.moveStep = value; + flush(); + return data.optionsStageEditor.moveStep; + } + + public var stageEditorAngleStep(get, set):Float; + + function get_stageEditorAngleStep():Float + { + if (data.optionsStageEditor.angleStep == null) data.optionsStageEditor.angleStep = 5; + + return data.optionsStageEditor.angleStep; + } + + function set_stageEditorAngleStep(value:Float):Float + { + // Set and apply. + data.optionsStageEditor.angleStep = value; + flush(); + return data.optionsStageEditor.angleStep; + } + + public var stageEditorTheme(get, set):StageEditorTheme; + + function get_stageEditorTheme():StageEditorTheme + { + if (data.optionsStageEditor.theme == null) data.optionsStageEditor.theme = StageEditorTheme.Light; + + return data.optionsStageEditor.theme; + } + + function set_stageEditorTheme(value:StageEditorTheme):StageEditorTheme + { + // Set and apply. + data.optionsStageEditor.theme = value; + flush(); + return data.optionsStageEditor.theme; + } + + /** + * When we've seen a character unlock, add it to the list of characters seen. + * @param character + */ + public function addCharacterSeen(character:String):Void + { + if (!data.unlocks.charactersSeen.contains(character)) + { + trace('Character seen: ' + character); + data.unlocks.charactersSeen.push(character); + trace('New characters seen list: ' + data.unlocks.charactersSeen); + flush(); + } + } + + /** + * Return the score the user achieved for a given level on a given difficulty. + * + * @param levelId The ID of the level/week. + * @param difficultyId The difficulty to check. + * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved. + */ + public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null + { + if (data.scores?.levels == null) + { + if (data.scores == null) + { + data.scores = + { + songs: [], + levels: [] + }; + } + else + { + data.scores.levels = []; + } + } + + var level = data.scores.levels.get(levelId); + if (level == null) + { + level = []; + data.scores.levels.set(levelId, level); + } + + return level.get(difficultyId); + } + + /** + * Apply the score the user achieved for a given level on a given difficulty. + */ + public function setLevelScore(levelId:String, difficultyId:String, score:SaveScoreData):Void + { + var level = data.scores.levels.get(levelId); + if (level == null) + { + level = []; + data.scores.levels.set(levelId, level); + } + level.set(difficultyId, score); + + flush(); + } + + public function isLevelHighScore(levelId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool + { + var level = data.scores.levels.get(levelId); + if (level == null) + { + level = []; + data.scores.levels.set(levelId, level); + } + + var currentScore = level.get(difficultyId); + if (currentScore == null) + { + return true; + } + + return score.score > currentScore.score; + } + + public function hasBeatenLevel(levelId:String, ?difficultyList:Array):Bool + { + if (difficultyList == null) + { + difficultyList = ['easy', 'normal', 'hard']; + } + for (difficulty in difficultyList) + { + var score:Null = getLevelScore(levelId, difficulty); + if (score != null) + { + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + continue; + } + } + } + return false; + } + + /** + * Return the score the user achieved for a given song on a given difficulty. + * + * @param songId The ID of the song. + * @param difficultyId The difficulty to check. + * @param variation The variation to check. Defaults to empty string. Appended to difficulty with `-`, e.g. `easy-pico`. + * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved. + */ + public function getSongScore(songId:String, difficultyId:String = 'normal', ?variation:String):Null + { + var song = data.scores.songs.get(songId); + if (song == null) + { + trace('Could not find song data for $songId $difficultyId $variation'); + song = []; + data.scores.songs.set(songId, song); + } + + // 'default' variations are left with no suffix ('easy', 'normal', 'hard'), + // along with 'erect' variations ('erect', 'nightmare') + // otherwise, we want to add a suffix of our current variation to get the save data. + if (variation != null && variation != '' && variation != 'default' && variation != 'erect') + { + difficultyId = '${difficultyId}-${variation}'; + } + + return song.get(difficultyId); + } + + public function getSongRank(songId:String, difficultyId:String = 'normal', ?variation:String):Null + { + return Scoring.calculateRank(getSongScore(songId, difficultyId, variation)); + } + + /** + * Directly set the score the user achieved for a given song on a given difficulty. + */ + public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void + { + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + song.set(difficultyId, score); + + flush(); + } + + /** + * Only replace the ranking data for the song, because the old score is still better. + */ + public function applySongRank(songId:String, difficultyId:String, newScoreData:SaveScoreData):Void + { + var newRank = Scoring.calculateRank(newScoreData); + if (newScoreData == null || newRank == null) return; + + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + + var previousScoreData = song.get(difficultyId); + + var previousRank = Scoring.calculateRank(previousScoreData); + + if (previousScoreData == null || previousRank == null) + { + // Directly set the highscore. + setSongScore(songId, difficultyId, newScoreData); + + return; + } + + var newCompletion = (newScoreData.tallies.sick + newScoreData.tallies.good) / newScoreData.tallies.totalNotes; + var previousCompletion = (previousScoreData.tallies.sick + previousScoreData.tallies.good) / previousScoreData.tallies.totalNotes; + + // Set the high score and the high rank separately. + var newScore:SaveScoreData = + { + score: (previousScoreData.score > newScoreData.score) ? previousScoreData.score : newScoreData.score, + tallies: (previousRank > newRank || previousCompletion > newCompletion) ? previousScoreData.tallies : newScoreData.tallies + }; + + song.set(difficultyId, newScore); + + flush(); + } + + /** + * Is the provided score data better than the current high score for the given song? + * @param songId The song ID to check. + * @param difficultyId The difficulty to check. + * @param score The score to check. + * @return Whether the score is better than the current high score. + */ + public function isSongHighScore(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool + { + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + + var currentScore = song.get(difficultyId); + if (currentScore == null) + { + return true; + } + + return score.score > currentScore.score; + } + + /** + * Is the provided score data better than the current rank for the given song? + * @param songId The song ID to check. + * @param difficultyId The difficulty to check. + * @param score The score to check the rank for. + * @return Whether the score's rank is better than the current rank. + */ + public function isSongHighRank(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool + { + var newScoreRank = Scoring.calculateRank(score); + if (newScoreRank == null) + { + // The provided score is invalid. + return false; + } + + var song = data.scores.songs.get(songId); + if (song == null) + { + song = []; + data.scores.songs.set(songId, song); + } + var currentScore = song.get(difficultyId); + var currentScoreRank = Scoring.calculateRank(currentScore); + if (currentScoreRank == null) + { + // There is no primary highscore for this song. + return true; + } + + return newScoreRank > currentScoreRank; + } + + /** + * Has the provided song been beaten on one of the listed difficulties? + * Note: This function can still take in the 'difficulty-variation' format for the difficultyList parameter + * as it is used in the old save data format. However inputting a variation will append it to the difficulty + * so you can do `hasBeatenSong('dadbattle', ['easy-pico'])` to check if you've beaten the Pico mix on easy. + * or you can do `hasBeatenSong('dadbattle', ['easy'], 'pico')` to check if you've beaten the Pico mix on easy. + * however you should not mix the two as it will append '-pico' to the 'easy-pico' if it's inputted into the array. + * @param songId The song ID to check. + * @param difficultyList The difficulties to check. Defaults to `easy`, `normal`, and `hard`. + * @param variation The variation to check. Defaults to empty string. Appended to difficulty list with `-`, e.g. `easy-pico`. + * This is our old format for getting difficulty/variation information, however we don't want to mess around with + * save migration just yet. + * @return Whether the song has been beaten on any of the listed difficulties. + */ + public function hasBeatenSong(songId:String, ?difficultyList:Array, ?variation:String):Bool + { + if (difficultyList == null) + { + difficultyList = ['easy', 'normal', 'hard']; + } + + if (variation == null) variation = ''; + + for (difficulty in difficultyList) + { + if (variation != '') difficulty = '${difficulty}-${variation}'; + + var score:Null = getSongScore(songId, difficulty); + if (score != null) + { + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + continue; + } + } + } + return false; + } + + public function isSongFavorited(id:String):Bool + { + if (data.favoriteSongs == null) + { + data.favoriteSongs = []; + flush(); + }; + + return data.favoriteSongs.contains(id); + } + + public function favoriteSong(id:String):Void + { + if (!isSongFavorited(id)) + { + data.favoriteSongs.push(id); + flush(); + } + } + + public function unfavoriteSong(id:String):Void + { + if (isSongFavorited(id)) + { + data.favoriteSongs.remove(id); + flush(); + } + } + + public function getControls(playerId:Int, inputType:Device):Null + { + switch (inputType) + { + case Keys: + return (playerId == 0) ? data?.options?.controls?.p1.keyboard : data?.options?.controls?.p2.keyboard; + case Gamepad(_): + return (playerId == 0) ? data?.options?.controls?.p1.gamepad : data?.options?.controls?.p2.gamepad; + } + } + + public function hasControls(playerId:Int, inputType:Device):Bool + { + var controls = getControls(playerId, inputType); + if (controls == null) return false; + + var controlsFields = Reflect.fields(controls); + return controlsFields.length > 0; + } + + public function setControls(playerId:Int, inputType:Device, controls:SaveControlsData):Void + { + switch (inputType) + { + case Keys: + if (playerId == 0) + { + data.options.controls.p1.keyboard = controls; + } + else + { + data.options.controls.p2.keyboard = controls; + } + case Gamepad(_): + if (playerId == 0) + { + data.options.controls.p1.gamepad = controls; + } + else + { + data.options.controls.p2.gamepad = controls; + } + } + + flush(); + } + + public function isCharacterUnlocked(characterId:String):Bool + { + switch (characterId) + { + case 'bf': + return true; + case 'pico': + return hasBeatenLevel('weekend1'); + default: + trace('Unknown character ID: ' + characterId); + return true; + } + } + + /** + * The user's current volume setting. + */ + public var volume(get, set):Float; + + function get_volume():Float + { + return data.volume; + } + + function set_volume(value:Float):Float + { + return data.volume = value; + } + + /** + * Whether the user's volume is currently muted. + */ + public var mute(get, set):Bool; + + function get_mute():Bool + { + return data.mute; + } + + function set_mute(value:Bool):Bool + { + return data.mute = value; + } + + /** + * Call this to make sure the save data is written to disk. + */ + public function flush():Void + { + FlxG.save.flush(); + } + + /** + * If you set slot to `2`, it will load an independent save file from slot 2. + * @param slot + */ + @:haxe.warning("-WDeprecated") + static function loadFromSlot(slot:Int):Save + { + trace('[SAVE] Loading save from slot $slot...'); + + FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH); + + switch (FlxG.save.status) + { + case EMPTY: + trace('[SAVE] Save data in slot ${slot} is empty, checking for legacy save data...'); + var legacySaveData = fetchLegacySaveData(); + if (legacySaveData != null) + { + trace('[SAVE] Found legacy save data, converting...'); + var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData); + FlxG.save.mergeData(gameSave.data, true); + return gameSave; + } + else + { + trace('[SAVE] No legacy save data found.'); + var gameSave = new Save(); + FlxG.save.mergeData(gameSave.data, true); + return gameSave; + } + case ERROR(_): // DEPRECATED: Unused + return handleSaveDataError(slot); + case SAVE_ERROR(_): + return handleSaveDataError(slot); + case LOAD_ERROR(_): + return handleSaveDataError(slot); + case BOUND(_, _): + trace('[SAVE] Loaded existing save data in slot ${slot}.'); + var gameSave = SaveDataMigrator.migrate(FlxG.save.data); + FlxG.save.mergeData(gameSave.data, true); + + return gameSave; + } + } + + static function clearSlot(slot:Int):Save + { + FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH); + + if (FlxG.save.status != EMPTY) + { + // Archive the save data just in case. + // Not reliable but better than nothing. + var backupSlot:Int = Save.archiveBadSaveData(FlxG.save.data); + + FlxG.save.erase(); + + return new Save(); + } + else + { + return new Save(); + } + } + + /** + * Call this when there is an error loading the save data in slot X. + */ + static function handleSaveDataError(slot:Int):Save + { + var msg = 'There was an error loading your save data in slot ${slot}.'; + msg += '\nPlease report this issue to the developers.'; + lime.app.Application.current.window.alert(msg, "Save Data Failure"); + + // Don't touch that slot anymore. + // Instead, load the next available slot. + + var nextSlot = slot + 1; + + if (nextSlot < 1000) + { + return loadFromSlot(nextSlot); + } + else + { + throw "End of save data slots. Can't load any more."; + } + } + + public static function archiveBadSaveData(data:Dynamic):Int + { + // We want to save this somewhere so we can try to recover it for the user in the future! + + final RECOVERY_SLOT_START = 1000; + + return writeToAvailableSlot(RECOVERY_SLOT_START, data); + } + + public static function debug_queryBadSaveData():Void + { + final RECOVERY_SLOT_START = 1000; + final RECOVERY_SLOT_END = 1100; + var firstBadSaveData = querySlotRange(RECOVERY_SLOT_START, RECOVERY_SLOT_END); + if (firstBadSaveData > 0) + { + trace('[SAVE] Found bad save data in slot ${firstBadSaveData}!'); + trace('We should look into recovery...'); + + trace(haxe.Json.stringify(fetchFromSlotRaw(firstBadSaveData))); + } + } + + static function fetchFromSlotRaw(slot:Int):Null + { + var targetSaveData = new FlxSave(); + targetSaveData.bind('$SAVE_NAME${slot}', SAVE_PATH); + if (targetSaveData.isEmpty()) return null; + return targetSaveData.data; + } + + static function writeToAvailableSlot(slot:Int, data:Dynamic):Int + { + trace('[SAVE] Finding slot to write data to (starting with ${slot})...'); + + var targetSaveData = new FlxSave(); + targetSaveData.bind('$SAVE_NAME${slot}', SAVE_PATH); + while (!targetSaveData.isEmpty()) + { + // Keep trying to bind to slots until we find an empty slot. + trace('[SAVE] Slot ${slot} is taken, continuing...'); + slot++; + targetSaveData.bind('$SAVE_NAME${slot}', SAVE_PATH); + } + + trace('[SAVE] Writing data to slot ${slot}...'); + targetSaveData.mergeData(data, true); + + trace('[SAVE] Data written to slot ${slot}!'); + return slot; + } + + /** + * Return true if the given save slot is not empty. + * @param slot The slot number to check. + * @return Whether the slot is not empty. + */ + @:haxe.warning("-WDeprecated") + static function querySlot(slot:Int):Bool + { + var targetSaveData = new FlxSave(); + targetSaveData.bind('$SAVE_NAME${slot}', SAVE_PATH); + switch (targetSaveData.status) + { + case EMPTY: + return false; + case ERROR(_): // DEPRECATED: Unused + return false; + case LOAD_ERROR(_): + return false; + case SAVE_ERROR(_): + return false; + case BOUND(_, _): + return true; + } + } + + /** + * Return true if any of the slots in the given range is not empty. + * @param start The starting slot number to check. + * @param end The ending slot number to check. + * @return The first slot in the range that is not empty, or `-1` if none are. + */ + static function querySlotRange(start:Int, end:Int):Int + { + for (i in start...end) + { + if (querySlot(i)) + { + return i; + } + } + return -1; + } + + static function fetchLegacySaveData():Null + { + trace("[SAVE] Checking for legacy save data..."); + var legacySave:FlxSave = new FlxSave(); + legacySave.bind(SAVE_NAME_LEGACY, SAVE_PATH_LEGACY); + if (legacySave.isEmpty()) + { + trace("[SAVE] No legacy save data found."); + return null; + } + else + { + trace("[SAVE] Legacy save data found."); + trace(legacySave.data); + return cast legacySave.data; + } + } + + /** + * Serialize this Save into a JSON string. + * @param pretty Whether the JSON should be big ol string (false), + * or formatted with tabs (true) + * @return The JSON string. + */ + public function serialize(pretty:Bool = true):String + { + var ignoreNullOptionals = true; + var writer = new json2object.JsonWriter(ignoreNullOptionals); + return writer.write(data, pretty ? ' ' : null); + } + + public function updateVersionToLatest():Void + { + this.data.version = Save.SAVE_DATA_VERSION; + } + + public function debug_dumpSave():Void + { + FileUtil.saveFile(haxe.io.Bytes.ofString(this.serialize()), [FileUtil.FILE_FILTER_JSON], null, null, './save.json', 'Write save data as JSON...'); + } +} + +/** + * An anonymous structure containingg all the user's save data. + * Isn't stored with JSON, stored with some sort of Haxe built-in serialization? + */ +typedef RawSaveData = +{ + // Flixel save data. + var volume:Float; + var mute:Bool; + + /** + * A semantic versioning string for the save data format. + */ + var version:Version; + + var api:SaveApiData; + + /** + * The user's saved scores. + */ + var scores:SaveHighScoresData; + + /** + * The user's preferences. + */ + var options:SaveDataOptions; + + var unlocks:SaveDataUnlocks; + + /** + * The user's favorited songs in the Freeplay menu, + * as a list of song IDs. + */ + var favoriteSongs:Array; + + var mods:SaveDataMods; + + /** + * The user's preferences specific to the Chart Editor. + */ + var optionsChartEditor:SaveDataChartEditorOptions; + + /** + * The user's preferences specific to the Stage Editor. + */ + var optionsStageEditor:SaveDataStageEditorOptions; +}; + +typedef SaveApiData = +{ + var newgrounds:SaveApiNewgroundsData; +} + +typedef SaveApiNewgroundsData = +{ + var sessionId:Null; +} + +typedef SaveDataUnlocks = +{ + /** + * Every time we see the unlock animation for a character, + * add it to this list so that we don't show it again. + */ + var charactersSeen:Array; + + /** + * This is a conditional when the player enters the character state + * For the first time ever + */ + var oldChar:Bool; +} + +/** + * An anoymous structure containing options about the user's high scores. + */ +typedef SaveHighScoresData = +{ + /** + * Scores for each level (or week). + */ + var levels:SaveScoreLevelsData; + + /** + * Scores for individual songs. + */ + var songs:SaveScoreSongsData; +}; + +typedef SaveDataMods = +{ + var enabledMods:Array; + + // TODO: Make this not trip up the serializer when debugging. + @:jignored + var modOptions:Map; +} + +/** + * Key is the level ID, value is the SaveScoreLevelData. + */ +typedef SaveScoreLevelsData = Map; + +/** + * Key is the song ID, value is the data for each difficulty. + */ +typedef SaveScoreSongsData = Map; + +/** + * Key is the difficulty ID, value is the score. + */ +typedef SaveScoreDifficultiesData = Map; + +/** + * An individual score. Contains the score, accuracy, and count of each judgement hit. + */ +typedef SaveScoreData = +{ + /** + * The score achieved. + */ + var score:Int; + + /** + * The count of each judgement hit. + */ + var tallies:SaveScoreTallyData; +} + +typedef SaveScoreTallyData = +{ + var sick:Int; + var good:Int; + var bad:Int; + var shit:Int; + var missed:Int; + var combo:Int; + var maxCombo:Int; + var totalNotesHit:Int; + var totalNotes:Int; +} + +/** + * An anonymous structure containing all the user's options and preferences for the main game. + * Every time you add a new option, it needs to be added here. + */ +typedef SaveDataOptions = +{ + /** + * FPS + * @default `60` + */ + var framerate:Int; + + /** + * Whether some particularly foul language is displayed. + * @default `true` + */ + var naughtyness:Bool; + + /** + * If enabled, the strumline is at the bottom of the screen rather than the top. + * @default `false` + */ + var downscroll:Bool; + + /** + * If disabled, flashing lights in the main menu and other areas will be less intense. + * @default `true` + */ + var flashingLights:Bool; + + /** + * If disabled, the camera bump synchronized to the beat. + * @default `false` + */ + var zoomCamera:Bool; + + /** + * If enabled, an FPS and memory counter will be displayed even if this is not a debug build. + * @default `false` + */ + var debugDisplay:Bool; + + /** + * If enabled, the game will automatically pause when tabbing out. + * @default `true` + */ + var autoPause:Bool; + + /** + * If enabled, the game will utilize VSync (or adaptive VSync) on startup. + * @default `Off` + */ + var vsyncMode:String; + + /** + * If >0, the game will display a semi-opaque background under the notes. + * `0` for no background, `100` for solid black if you're freaky like that + * @default `0` + */ + var strumlineBackgroundOpacity:Int; + + /** + * If enabled, the game will automatically launch in fullscreen on startup. + * @default `true` + */ + var autoFullscreen:Bool; + + /** + * Offset the user's inputs by this many ms. + * @default `0` + */ + var inputOffset:Int; + + /** + * Affects the delay between the audio and the visuals during gameplay. + * @default `0` + */ + var audioVisualOffset:Int; + + /** + * If we want the framerate to be unlocked on HTML5. + * @default `false` + */ + var unlockedFramerate:Bool; + + /** + * Screenshot options + * @param shouldHideMouse Should the mouse be hidden when taking a screenshot? Default: `true` + * @param fancyPreview Show a fancy preview? Default: `true` + * @param previewOnSave Only show the fancy preview after a screenshot is saved? Default: `true` + * @param saveFormat The save format of the screenshot, PNG or JPEG. Default: `PNG` + * @param jpegQuality The JPEG Quality, if we're saving to the format. Default: `80` + */ + var screenshot: + { + var shouldHideMouse:Bool; + var fancyPreview:Bool; + var previewOnSave:Bool; + var saveFormat:String; + var jpegQuality:Int; + }; + + var controls: + { + var p1: + { + var keyboard:SaveControlsData; + var gamepad:SaveControlsData; + }; + var p2: + { + var keyboard:SaveControlsData; + var gamepad:SaveControlsData; + }; + }; +}; + +/** + * An anonymous structure containing a specific player's bound keys. + * Each key is an action name and each value is an array of keycodes. + * + * If a keybind is `null`, it needs to be reinitialized to the default. + * If a keybind is `[]`, it is UNBOUND by the user and should not be rebound. + */ +typedef SaveControlsData = +{ + /** + * Keybind for navigating in the menu. + * @default `Up Arrow` + */ + var ?UI_UP:Array; + + /** + * Keybind for navigating in the menu. + * @default `Left Arrow` + */ + var ?UI_LEFT:Array; + + /** + * Keybind for navigating in the menu. + * @default `Right Arrow` + */ + var ?UI_RIGHT:Array; + + /** + * Keybind for navigating in the menu. + * @default `Down Arrow` + */ + var ?UI_DOWN:Array; + + /** + * Keybind for hitting notes. + * @default `A` and `Left Arrow` + */ + var ?NOTE_LEFT:Array; + + /** + * Keybind for hitting notes. + * @default `W` and `Up Arrow` + */ + var ?NOTE_UP:Array; + + /** + * Keybind for hitting notes. + * @default `S` and `Down Arrow` + */ + var ?NOTE_DOWN:Array; + + /** + * Keybind for hitting notes. + * @default `D` and `Right Arrow` + */ + var ?NOTE_RIGHT:Array; + + /** + * Keybind for continue/OK in menus. + * @default `Enter` and `Space` + */ + var ?ACCEPT:Array; + + /** + * Keybind for back/cancel in menus. + * @default `Escape` + */ + var ?BACK:Array; + + /** + * Keybind for pausing the game. + * @default `Escape` + */ + var ?PAUSE:Array; + + /** + * Keybind for advancing cutscenes. + * @default `Z` and `Space` and `Enter` + */ + var ?CUTSCENE_ADVANCE:Array; + + /** + * Keybind for increasing volume. + * @default `Plus` + */ + var ?VOLUME_UP:Array; + + /** + * Keybind for decreasing volume. + * @default `Minus` + */ + var ?VOLUME_DOWN:Array; + + /** + * Keybind for muting/unmuting volume. + * @default `Zero` + */ + var ?VOLUME_MUTE:Array; + + /** + * Keybind for restarting a song. + * @default `R` + */ + var ?RESET:Array; +} + +/** + * An anonymous structure containing all the user's options and preferences, specific to the Chart Editor. + */ +typedef SaveDataChartEditorOptions = +{ + /** + * Whether the Chart Editor created a backup the last time it closed. + * Prompt the user to load it, then set this back to `false`. + * @default `false` + */ + var ?hasBackup:Bool; + + /** + * Previous files opened in the Chart Editor. + * @default `[]` + */ + var ?previousFiles:Array; + + /** + * Note snapping level in the Chart Editor. + * @default `3` + */ + var ?noteQuant:Int; + + /** + * Live input style in the Chart Editor. + * @default `ChartEditorLiveInputStyle.None` + */ + var ?chartEditorLiveInputStyle:ChartEditorLiveInputStyle; + + /** + * Theme in the Chart Editor. + * @default `ChartEditorTheme.Light` + */ + var ?theme:ChartEditorTheme; + + /** + * Downscroll in the Chart Editor. + * @default `false` + */ + var ?downscroll:Bool; + + /** + * Metronome volume in the Chart Editor. + * @default `1.0` + */ + var ?metronomeVolume:Float; + + /** + * Hitsound volume (player) in the Chart Editor. + * @default `1.0` + */ + var ?hitsoundVolumePlayer:Float; + + /** + * Hitsound volume (opponent) in the Chart Editor. + * @default `1.0` + */ + var ?hitsoundVolumeOpponent:Float; + + /** + * If true, playtest songs from the current position in the Chart Editor. + * @default `false` + */ + var ?playtestStartTime:Bool; + + /** + * Theme music in the Chart Editor. + * @default `true` + */ + var ?themeMusic:Bool; + + /** + * Instrumental volume in the Chart Editor. + * @default `1.0` + */ + var ?instVolume:Float; + + /** + * Voices volume in the Chart Editor. + * @default `1.0` + */ + var ?voicesVolume:Float; + + /** + * Playback speed in the Chart Editor. + * @default `1.0` + */ + var ?playbackSpeed:Float; +}; + +typedef SaveDataStageEditorOptions = +{ + // a lot of these things were copied from savedatacharteditoroptions + + /** + * Whether the Stage Editor created a backup the last time it closed. + * Prompt the user to load it, then set this back to `false`. + * @default `false` + */ + var ?hasBackup:Bool; + + /** + * Previous files opened in the Stage Editor. + * @default `[]` + */ + var ?previousFiles:Array; + + /** + * The Step at which an Object or Character is moved. + * @default `1px` + */ + var ?moveStep:String; + + /** + * The Step at which an Object is rotated. + * @default `5` + */ + var ?angleStep:Float; + + /** + * Theme in the Stage Editor. + * @default `StageEditorTheme.Light` + */ + var ?theme:StageEditorTheme; +}; diff --git a/source/funkin/save/changelog.md b/source/funkin/save/changelog.md new file mode 100644 index 00000000..aa98d309 --- /dev/null +++ b/source/funkin/save/changelog.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.1.0] - 2024-10-18 +This version introduces changes to save data loading in order to improve compatibility with older versions. +### Changed +- `optionsStageEditor.theme` converted from an Enum to a String to fix save data compatibility issues. + - In the future, Enum values should not be used in order to prevent incompatibilities caused by introducing new types to the save data that older versions cannot parse. +- `optionsChartEditor.theme` converted from an Enum to a String to fix save data compatibility issues. +- `optionsChartEditor.chartEditorLiveInputStyle` converted from an Enum to a String to fix save data compatibility issues. + +## [2.0.6] - 2024-10-11 +### Added +- `optionsStageEditor` to `Save` for storing user preferences for the stage editor. + +## [2.0.5] - 2024-05-21 +### Fixed +- Resolved an issue where HTML5 wouldn't store the semantic version properly, causing the game to fail to load the save. + +## [2.0.4] - 2024-05-21 +### Added +- `unlocks.charactersSeen:Array` to `Save` +- `unlocks.oldChar:Bool` to `Save` +- `favoriteSongs:Array` to `Save` + +## [2.0.3] - 2024-01-09 +### Added +- `inputOffset:Float` to `SongDataOptions` +- `audioVisualOffset:Float` to `SongDataOptions` diff --git a/source/funkin/save/migrator/RawSaveData_v1_0_0.hx b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx new file mode 100644 index 00000000..a516f944 --- /dev/null +++ b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx @@ -0,0 +1,51 @@ +package funkin.save.migrator; + +import thx.semver.Version; + +typedef RawSaveData_v1_0_0 = +{ + var seenVideo:Bool; + var mute:Bool; + var volume:Float; + + var sessionId:String; + + var songCompletion:Map; + + var songScores:Map; + + var ?controls: + { + ?p1:SavePlayerControlsData_v1_0_0, + ?p2:SavePlayerControlsData_v1_0_0 + }; + var enabledMods:Array; + var weeksUnlocked:Array; + var windowSettings:Array; +} + +typedef SavePlayerControlsData_v1_0_0 = +{ + var keys:SaveControlsData_v1_0_0; + var pad:SaveControlsData_v1_0_0; +}; + +typedef SaveControlsData_v1_0_0 = +{ + var ?ACCEPT:Array; + var ?BACK:Array; + var ?CUTSCENE_ADVANCE:Array; + var ?NOTE_DOWN:Array; + var ?NOTE_LEFT:Array; + var ?NOTE_RIGHT:Array; + var ?NOTE_UP:Array; + var ?PAUSE:Array; + var ?RESET:Array; + var ?UI_DOWN:Array; + var ?UI_LEFT:Array; + var ?UI_RIGHT:Array; + var ?UI_UP:Array; + var ?VOLUME_DOWN:Array; + var ?VOLUME_MUTE:Array; + var ?VOLUME_UP:Array; +}; diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx new file mode 100644 index 00000000..3dc012fc --- /dev/null +++ b/source/funkin/save/migrator/SaveDataMigrator.hx @@ -0,0 +1,333 @@ +package funkin.save.migrator; + +import funkin.save.Save; +import funkin.save.migrator.RawSaveData_v1_0_0; +import thx.semver.Version; +import funkin.util.VersionUtil; + +@:nullSafety +class SaveDataMigrator +{ + /** + * Migrate from one 2.x version to another. + */ + public static function migrate(inputData:Dynamic):Save + { + var version:Null = VersionUtil.parseVersion(inputData?.version ?? null); + + if (version == null) + { + trace('[SAVE] No version found in save data! Returning blank data.'); + trace(inputData); + return new Save(Save.getDefault()); + } + else + { + // Sometimes the Haxe serializer has issues with the version so we fix it here. + version = VersionUtil.repairVersion(version); + if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE)) + { + // Import the structured data. + var saveDataWithDefaults:RawSaveData = cast thx.Objects.deepCombine(Save.getDefault(), inputData); + var save:Save = new Save(saveDataWithDefaults); + return save; + } + else if (VersionUtil.validateVersion(version, "2.0.x")) + { + return migrate_v2_0_0(inputData); + } + else + { + var message:String = 'Error migrating save data, expected ${Save.SAVE_DATA_VERSION}.'; + var slot:Int = Save.archiveBadSaveData(inputData); + var fullMessage:String = 'An error occurred migrating your save data.\n${message}\nInvalid data has been moved to save slot ${slot}.'; + lime.app.Application.current.window.alert(fullMessage, "Save Data Failure"); + return new Save(Save.getDefault()); + } + } + } + + static function migrate_v2_0_0(inputData:Dynamic):Save + { + // Import the structured data. + var saveDataWithDefaults:RawSaveData = cast thx.Objects.deepCombine(Save.getDefault(), inputData); + + // Reset these values to valid ones. + saveDataWithDefaults.optionsChartEditor.chartEditorLiveInputStyle = funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle.None; + saveDataWithDefaults.optionsChartEditor.theme = funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme.Light; + saveDataWithDefaults.optionsStageEditor.theme = funkin.ui.debug.stageeditor.StageEditorState.StageEditorTheme.Light; + + var save:Save = new Save(saveDataWithDefaults); + return save; + } + + /** + * Migrate from 1.x to the latest version. + */ + public static function migrateFromLegacy(inputData:Dynamic):Save + { + var inputSaveData:RawSaveData_v1_0_0 = cast inputData; + + var result:Save = new Save(Save.getDefault()); + + result.volume = inputSaveData.volume; + result.mute = inputSaveData.mute; + + result.ngSessionId = inputSaveData.sessionId; + + // TODO: Port over the save data from the legacy save data format. + migrateLegacyScores(result, inputSaveData); + + migrateLegacyControls(result, inputSaveData); + + return result; + } + + static function migrateLegacyScores(result:Save, inputSaveData:RawSaveData_v1_0_0):Void + { + if (inputSaveData.songCompletion == null) + { + inputSaveData.songCompletion = []; + } + + if (inputSaveData.songScores == null) + { + inputSaveData.songScores = []; + } + + migrateLegacyLevelScore(result, inputSaveData, 'week0'); + migrateLegacyLevelScore(result, inputSaveData, 'week1'); + migrateLegacyLevelScore(result, inputSaveData, 'week2'); + migrateLegacyLevelScore(result, inputSaveData, 'week3'); + migrateLegacyLevelScore(result, inputSaveData, 'week4'); + migrateLegacyLevelScore(result, inputSaveData, 'week5'); + migrateLegacyLevelScore(result, inputSaveData, 'week6'); + migrateLegacyLevelScore(result, inputSaveData, 'week7'); + + migrateLegacySongScore(result, inputSaveData, ['tutorial', 'Tutorial']); + + migrateLegacySongScore(result, inputSaveData, ['bopeebo', 'Bopeebo']); + migrateLegacySongScore(result, inputSaveData, ['fresh', 'Fresh']); + migrateLegacySongScore(result, inputSaveData, ['dadbattle', 'Dadbattle']); + + migrateLegacySongScore(result, inputSaveData, ['monster', 'Monster']); + migrateLegacySongScore(result, inputSaveData, ['south', 'South']); + migrateLegacySongScore(result, inputSaveData, ['spookeez', 'Spookeez']); + + migrateLegacySongScore(result, inputSaveData, ['pico', 'Pico']); + migrateLegacySongScore(result, inputSaveData, ['philly-nice', 'Philly', 'philly', 'Philly-Nice']); + migrateLegacySongScore(result, inputSaveData, ['blammed', 'Blammed']); + + migrateLegacySongScore(result, inputSaveData, ['satin-panties', 'Satin-Panties']); + migrateLegacySongScore(result, inputSaveData, ['high', 'High']); + migrateLegacySongScore(result, inputSaveData, ['milf', 'Milf', 'MILF']); + + migrateLegacySongScore(result, inputSaveData, ['cocoa', 'Cocoa']); + migrateLegacySongScore(result, inputSaveData, ['eggnog', 'Eggnog']); + migrateLegacySongScore(result, inputSaveData, ['winter-horrorland', 'Winter-Horrorland']); + + migrateLegacySongScore(result, inputSaveData, ['senpai', 'Senpai']); + migrateLegacySongScore(result, inputSaveData, ['roses', 'Roses']); + migrateLegacySongScore(result, inputSaveData, ['thorns', 'Thorns']); + + migrateLegacySongScore(result, inputSaveData, ['ugh', 'Ugh']); + migrateLegacySongScore(result, inputSaveData, ['guns', 'Guns']); + migrateLegacySongScore(result, inputSaveData, ['stress', 'Stress']); + } + + static function migrateLegacyLevelScore(result:Save, inputSaveData:RawSaveData_v1_0_0, levelId:String):Void + { + var scoreDataEasy:SaveScoreData = + { + score: inputSaveData.songScores.get('${levelId}-easy') ?? 0, + // accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + result.setLevelScore(levelId, 'easy', scoreDataEasy); + + var scoreDataNormal:SaveScoreData = + { + score: inputSaveData.songScores.get('${levelId}') ?? 0, + // accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + result.setLevelScore(levelId, 'normal', scoreDataNormal); + + var scoreDataHard:SaveScoreData = + { + score: inputSaveData.songScores.get('${levelId}-hard') ?? 0, + // accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + result.setLevelScore(levelId, 'hard', scoreDataHard); + } + + static function migrateLegacySongScore(result:Save, inputSaveData:RawSaveData_v1_0_0, songIds:Array):Void + { + var scoreDataEasy:SaveScoreData = + { + score: 0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + + for (songId in songIds) + { + scoreDataEasy.score = Std.int(Math.max(scoreDataEasy.score, inputSaveData.songScores.get('${songId}-easy') ?? 0)); + // scoreDataEasy.accuracy = Math.max(scoreDataEasy.accuracy, inputSaveData.songCompletion.get('${songId}-easy') ?? 0.0); + } + result.setSongScore(songIds[0], 'easy', scoreDataEasy); + + var scoreDataNormal:SaveScoreData = + { + score: 0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + + for (songId in songIds) + { + scoreDataNormal.score = Std.int(Math.max(scoreDataNormal.score, inputSaveData.songScores.get('${songId}') ?? 0)); + // scoreDataNormal.accuracy = Math.max(scoreDataNormal.accuracy, inputSaveData.songCompletion.get('${songId}') ?? 0.0); + } + result.setSongScore(songIds[0], 'normal', scoreDataNormal); + + var scoreDataHard:SaveScoreData = + { + score: 0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }; + + for (songId in songIds) + { + scoreDataHard.score = Std.int(Math.max(scoreDataHard.score, inputSaveData.songScores.get('${songId}-hard') ?? 0)); + // scoreDataHard.accuracy = Math.max(scoreDataHard.accuracy, inputSaveData.songCompletion.get('${songId}-hard') ?? 0.0); + } + result.setSongScore(songIds[0], 'hard', scoreDataHard); + } + + static function migrateLegacyControls(result:Save, inputSaveData:RawSaveData_v1_0_0):Void + { + var p1Data = inputSaveData?.controls?.p1; + if (p1Data != null) + { + migrateLegacyPlayerControls(result, 1, p1Data); + } + + var p2Data = inputSaveData?.controls?.p2; + if (p2Data != null) + { + migrateLegacyPlayerControls(result, 2, p2Data); + } + } + + static function migrateLegacyPlayerControls(result:Save, playerId:Int, controlsData:SavePlayerControlsData_v1_0_0):Void + { + var outputKeyControls:SaveControlsData = + { + ACCEPT: controlsData?.keys?.ACCEPT ?? null, + BACK: controlsData?.keys?.BACK ?? null, + CUTSCENE_ADVANCE: controlsData?.keys?.CUTSCENE_ADVANCE ?? null, + NOTE_DOWN: controlsData?.keys?.NOTE_DOWN ?? null, + NOTE_LEFT: controlsData?.keys?.NOTE_LEFT ?? null, + NOTE_RIGHT: controlsData?.keys?.NOTE_RIGHT ?? null, + NOTE_UP: controlsData?.keys?.NOTE_UP ?? null, + PAUSE: controlsData?.keys?.PAUSE ?? null, + RESET: controlsData?.keys?.RESET ?? null, + UI_DOWN: controlsData?.keys?.UI_DOWN ?? null, + UI_LEFT: controlsData?.keys?.UI_LEFT ?? null, + UI_RIGHT: controlsData?.keys?.UI_RIGHT ?? null, + UI_UP: controlsData?.keys?.UI_UP ?? null, + VOLUME_DOWN: controlsData?.keys?.VOLUME_DOWN ?? null, + VOLUME_MUTE: controlsData?.keys?.VOLUME_MUTE ?? null, + VOLUME_UP: controlsData?.keys?.VOLUME_UP ?? null, + }; + + var outputPadControls:SaveControlsData = + { + ACCEPT: controlsData?.pad?.ACCEPT ?? null, + BACK: controlsData?.pad?.BACK ?? null, + CUTSCENE_ADVANCE: controlsData?.pad?.CUTSCENE_ADVANCE ?? null, + NOTE_DOWN: controlsData?.pad?.NOTE_DOWN ?? null, + NOTE_LEFT: controlsData?.pad?.NOTE_LEFT ?? null, + NOTE_RIGHT: controlsData?.pad?.NOTE_RIGHT ?? null, + NOTE_UP: controlsData?.pad?.NOTE_UP ?? null, + PAUSE: controlsData?.pad?.PAUSE ?? null, + RESET: controlsData?.pad?.RESET ?? null, + UI_DOWN: controlsData?.pad?.UI_DOWN ?? null, + UI_LEFT: controlsData?.pad?.UI_LEFT ?? null, + UI_RIGHT: controlsData?.pad?.UI_RIGHT ?? null, + UI_UP: controlsData?.pad?.UI_UP ?? null, + VOLUME_DOWN: controlsData?.pad?.VOLUME_DOWN ?? null, + VOLUME_MUTE: controlsData?.pad?.VOLUME_MUTE ?? null, + VOLUME_UP: controlsData?.pad?.VOLUME_UP ?? null, + }; + + result.setControls(playerId, Keys, outputKeyControls); + result.setControls(playerId, Gamepad(0), outputPadControls); + } +} diff --git a/source/funkin/save/migrator/SaveData_v2_0_0.hx b/source/funkin/save/migrator/SaveData_v2_0_0.hx new file mode 100644 index 00000000..5369d873 --- /dev/null +++ b/source/funkin/save/migrator/SaveData_v2_0_0.hx @@ -0,0 +1,26 @@ +package funkin.save.migrator; + +// Internal enums used to ensure old save data can be parsed by the default Haxe unserializer. +// In the future, only primitive types and abstract enums should be used in save data! + +@:native("funkin.ui.debug.stageeditor.StageEditorTheme") +enum StageEditorTheme +{ + Light; + Dark; +} + +@:native("funkin.ui.debug.charting.ChartEditorTheme") +enum ChartEditorTheme +{ + Light; + Dark; +} + +@:native("funkin.ui.debug.charting.ChartEditorLiveInputStyle") +enum ChartEditorLiveInputStyle +{ + None; + NumberKeys; + WASDKeys; +} diff --git a/source/funkin/ui/AtlasMenuList.hx b/source/funkin/ui/AtlasMenuList.hx new file mode 100644 index 00000000..efff6da9 --- /dev/null +++ b/source/funkin/ui/AtlasMenuList.hx @@ -0,0 +1,95 @@ +package funkin.ui; + +import flixel.graphics.frames.FlxAtlasFrames; +import funkin.ui.MenuList; + +typedef AtlasAsset = flixel.util.typeLimit.OneOfTwo; + +/** + * A menulist whose items share a single texture atlas. + */ +class AtlasMenuList extends MenuTypedList +{ + public var atlas:FlxAtlasFrames; + + public function new(atlas, navControls:NavControls = Vertical, ?wrapMode) + { + super(navControls, wrapMode); + + if (Std.isOfType(atlas, String)) this.atlas = Paths.getSparrowAtlas(cast atlas); + else + this.atlas = cast atlas; + } + + public function createItem(x = 0.0, y = 0.0, name, callback, fireInstantly = false) + { + var item = new AtlasMenuItem(x, y, name, atlas, callback); + item.fireInstantly = fireInstantly; + return addItem(name, item); + } + + override function destroy() + { + super.destroy(); + atlas = null; + } +} + +/** + * A menu list item which uses single texture atlas. + */ +class AtlasMenuItem extends MenuListItem +{ + var atlas:FlxAtlasFrames; + + public var centered:Bool = false; + + public function new(x = 0.0, y = 0.0, name:String, atlas, callback) + { + this.atlas = atlas; + super(x, y, name, callback); + } + + override function setData(name:String, ?callback:Void->Void) + { + frames = atlas; + animation.addByPrefix('idle', '$name idle', 24); + animation.addByPrefix('selected', '$name selected', 24); + + super.setData(name, callback); + } + + public function changeAnim(animName:String) + { + animation.play(animName); + updateHitbox(); + + if (centered) + { + // position by center + centerOrigin(); + offset.copyFrom(origin); + } + } + + override function idle() + { + changeAnim('idle'); + } + + override function select() + { + changeAnim('selected'); + } + + override function get_selected() + { + return animation.curAnim != null && animation.curAnim.name == "selected"; + } + + override function destroy() + { + super.destroy(); + atlas = null; + } +} diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx new file mode 100644 index 00000000..fdb8e5de --- /dev/null +++ b/source/funkin/ui/AtlasText.hx @@ -0,0 +1,304 @@ +package funkin.ui; + +import flixel.FlxSprite; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.util.FlxStringUtil; + +/** + * AtlasText is an improved version of Alphabet and FlxBitmapText. + * It supports animations on the letters, and is less buggy than Alphabet. + */ +class AtlasText extends FlxTypedSpriteGroup +{ + static var fonts = new Map(); + static var casesAllowed = new Map(); + + public var text(default, set):String = ""; + + var font:AtlasFontData; + + public var atlas(get, never):FlxAtlasFrames; + + inline function get_atlas() + return font.atlas; + + public var caseAllowed(get, never):Case; + + inline function get_caseAllowed() + return font.caseAllowed; + + public var maxHeight(get, never):Float; + + inline function get_maxHeight() + return font.maxHeight; + + public function new(x = 0.0, y = 0.0, text:String, fontName:AtlasFont = AtlasFont.DEFAULT) + { + if (!fonts.exists(fontName)) fonts[fontName] = new AtlasFontData(fontName); + font = fonts[fontName]; + + super(x, y); + + this.text = text; + } + + function set_text(value:String) + { + if (value == null) value = ""; + + var caseValue = restrictCase(value); + var caseText = restrictCase(this.text); + + this.text = value; + if (caseText == caseValue) return value; // cancel redraw + + if (caseValue.indexOf(caseText) == 0) + { + // new text is just old text with additions at the end, append the difference + appendTextCased(caseValue.substr(caseText.length)); + return this.text; + } + + value = caseValue; + + group.kill(); + + if (value == "") return this.text; + + appendTextCased(caseValue); + return this.text; + } + + /** + * Adds new characters, without needing to redraw the previous characters + * @param text The text to add. + * @throws String if `text` is null. + */ + public function appendText(text:String) + { + if (text == null) throw "cannot append null"; + + if (text == "") return; + + this.text = this.text + text; + } + + /** + * Converts all characters to fit the font's `allowedCase`. + * @param text + */ + function restrictCase(text:String) + { + return switch (caseAllowed) + { + case Both: text; + case Upper: text.toUpperCase(); + case Lower: text.toLowerCase(); + } + } + + /** + * Adds new text on top of the existing text. Helper for other methods; DOESN'T CHANGE `this.text`. + * @param text The text to add, assumed to match the font's `caseAllowed`. + */ + function appendTextCased(text:String) + { + var charCount = group.countLiving(); + var xPos:Float = 0; + var yPos:Float = 0; + // `countLiving` returns -1 if group is empty + if (charCount == -1) charCount = 0; + else if (charCount > 0) + { + var lastChar = group.members[charCount - 1]; + xPos = lastChar.x + lastChar.width - x; + yPos = lastChar.y + lastChar.height - maxHeight - y; + } + + var splitValues = text.split(""); + for (i in 0...splitValues.length) + { + switch (splitValues[i]) + { + case " ": + { + xPos += 40; + } + case "\n": + { + xPos = 0; + yPos += maxHeight; + } + case char: + { + var charSprite:AtlasChar; + if (group.members.length <= charCount) charSprite = new AtlasChar(atlas, char); + else + { + charSprite = group.members[charCount]; + charSprite.revive(); + charSprite.char = char; + charSprite.alpha = 1; // gets multiplied when added + } + charSprite.x = xPos; + charSprite.y = yPos + maxHeight - charSprite.height; + add(charSprite); + + xPos += charSprite.width; + charCount++; + } + } + } + } + + public function getWidth():Int + { + var width = 0; + for (char in this.text.split("")) + { + switch (char) + { + case " ": + { + width += 40; + } + case "\n": + {} + case char: + { + var sprite = new AtlasChar(atlas, char); + sprite.revive(); + sprite.char = char; + sprite.alpha = 1; + width += Std.int(sprite.width); + } + } + } + return width; + } + + override function toString() + { + return "InputItem, " + FlxStringUtil.getDebugString([ + LabelValuePair.weak("x", x), + LabelValuePair.weak("y", y), + LabelValuePair.weak("text", text) + ]); + } +} + +class AtlasChar extends FlxSprite +{ + public var char(default, set):String; + + public function new(x = 0.0, y = 0.0, atlas:FlxAtlasFrames, char:String) + { + super(x, y); + frames = atlas; + this.char = char; + } + + function set_char(value:String) + { + if (this.char != value) + { + var prefix = getAnimPrefix(value); + animation.addByPrefix('anim', prefix, 24); + if (animation.exists('anim')) + { + animation.play('anim'); + } + else + { + trace('Could not find animation for char "' + value + '"'); + } + updateHitbox(); + } + + return this.char = value; + } + + function getAnimPrefix(char:String) + { + return switch (char) + { + case '&': return '-andpersand-'; + case "😠": '-angry faic-'; // TODO: Do multi-flag characters work? + case "'": '-apostraphie-'; + case "\\": '-back slash-'; + case ",": '-comma-'; + case '-': '-dash-'; + case '↓': '-down arrow-'; // U+2193 + case "”": '-end quote-'; // U+0022 + case "!": '-exclamation point-'; // U+0021 + case "/": '-forward slash-'; // U+002F + case '>': '-greater than-'; // U+003E + case '♥': '-heart-'; // U+2665 + case '♡': '-heart-'; + case '←': '-left arrow-'; // U+2190 + case '<': '-less than-'; // U+003C + case "*": '-multiply x-'; + case '.': '-period-'; // U+002E + case "?": '-question mark-'; + case '→': '-right arrow-'; // U+2192 + case "“": '-start quote-'; + case '↑': '-up arrow-'; // U+2191 + + // Default to getting the character itself. + default: char; + } + } +} + +private class AtlasFontData +{ + static public var upperChar = ~/^[A-Z]\d+$/; + static public var lowerChar = ~/^[a-z]\d+$/; + + public var atlas:FlxAtlasFrames; + public var maxHeight:Float = 0.0; + public var caseAllowed:Case = Both; + + public function new(name:AtlasFont) + { + var fontName:String = name; + atlas = Paths.getSparrowAtlas('fonts/${fontName.toLowerCase()}'); + if (atlas == null) + { + FlxG.log.warn('Could not find font atlas for font "${fontName}".'); + return; + } + + atlas.parent.destroyOnNoUse = false; + atlas.parent.persist = true; + + var containsUpper = false; + var containsLower = false; + + for (frame in atlas.frames) + { + maxHeight = Math.max(maxHeight, frame.frame.height); + + if (!containsUpper) containsUpper = upperChar.match(frame.name); + + if (!containsLower) containsLower = lowerChar.match(frame.name); + } + + if (containsUpper != containsLower) caseAllowed = containsUpper ? Upper : Lower; + } +} + +enum Case +{ + Both; + Upper; + Lower; +} + +enum abstract AtlasFont(String) from String to String +{ + var DEFAULT = "default"; + var BOLD = "bold"; + var FREEPLAY_CLEAR = "freeplay-clear"; +} diff --git a/source/funkin/ui/Codex.hx b/source/funkin/ui/Codex.hx new file mode 100644 index 00000000..77ac669a --- /dev/null +++ b/source/funkin/ui/Codex.hx @@ -0,0 +1,60 @@ +package funkin.ui; + +import funkin.ui.Page.PageName; +import flixel.group.FlxGroup; + +/** + * The Codex class is what holds our `Page` objects together. Apologies for the potentially obtuse quirky name. + * Codex stands for "Collection Of Pages ex"... imagine P is rotated 180 degress now its a d :) + * I just wanted something not called "PageManager" grr... + */ +class Codex extends FlxGroup +{ + var pages:Map>; + + public var currentName:T; + public var currentPage(get, never):Page; + + inline function get_currentPage():Page + return pages[currentName]; + + public function new(initPage:T) + { + super(); + pages = new Map>(); + currentName = initPage; + } + + public function addPage>(name:T, page:P):P + { + page.onSwitch.add(switchPage); + page.codex = this; + pages[name] = page; + add(page); + page.exists = currentName == name; + return page; + } + + public function setPage(name:T):Void + { + if (pages.exists(currentName)) + { + currentPage.exists = false; + currentPage.visible = false; + } + + currentName = name; + + if (pages.exists(currentName)) + { + currentPage.exists = true; + currentPage.visible = true; + } + } + + public function switchPage(name:T):Void + { + // TODO: Animate this transition? + setPage(name); + } +} diff --git a/source/funkin/ui/MenuItem.hx b/source/funkin/ui/MenuItem.hx new file mode 100644 index 00000000..2a483ea7 --- /dev/null +++ b/source/funkin/ui/MenuItem.hx @@ -0,0 +1,61 @@ +package funkin.ui; + +import flixel.FlxSprite; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxMath; +import funkin.util.MathUtil; +import flixel.util.FlxColor; + +class MenuItem extends FlxSpriteGroup +{ + public var targetY:Float = 0; + public var week:FlxSprite; + + public function new(x:Float, y:Float, weekNum:Int = 0, weekType:WeekType) + { + super(x, y); + + var weekStr:String = switch (weekType) + { + case WEEK: + "week"; + case WEEKEND: + "weekend"; + } + + week = new FlxSprite().loadGraphic(Paths.image('storymenu/' + weekStr + weekNum)); + add(week); + } + + var isFlashing:Bool = false; + var flashTick:Float = 0; + final flashFramerate:Float = 20; + + public function startFlashing():Void + { + isFlashing = true; + } + + override function update(elapsed:Float) + { + super.update(elapsed); + y = MathUtil.coolLerp(y, (targetY * 120) + 480, 0.17); + + if (isFlashing) + { + flashTick += elapsed; + if (flashTick >= 1 / flashFramerate) + { + flashTick %= 1 / flashFramerate; + week.color = (week.color == FlxColor.WHITE) ? 0xFF33ffff : FlxColor.WHITE; + } + } + } +} + +enum abstract WeekType(String) to String +{ + var WEEK; + var WEEKEND; +} diff --git a/source/funkin/ui/MenuList.hx b/source/funkin/ui/MenuList.hx new file mode 100644 index 00000000..9e04da8d --- /dev/null +++ b/source/funkin/ui/MenuList.hx @@ -0,0 +1,360 @@ +package funkin.ui; + +import flixel.FlxSprite; +import flixel.effects.FlxFlicker; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.util.FlxSignal.FlxTypedSignal; +import funkin.audio.FunkinSound; + +class MenuTypedList extends FlxTypedGroup +{ + public var selectedIndex(default, null):Int = 0; + public var selectedItem(get, never):T; + + /** Called when a new item is highlighted */ + public var onChange(default, null) = new FlxTypedSignalVoid>(); + + /** Called when an item is accepted */ + public var onAcceptPress(default, null) = new FlxTypedSignalVoid>(); + + /** The navigation control scheme to use */ + public var navControls:NavControls; + + /** Set to false to disable nav control */ + public var enabled:Bool = true; + + /** */ + public var wrapMode:WrapMode = Both; + + var byName = new Map(); + + /** Set to true, internally to disable controls, without affecting vars like `enabled` */ + public var busy(default, null):Bool = false; + + // bit awkward because BACK is also a menu control and this doesn't affect that + + public function new(navControls:NavControls = Vertical, ?wrapMode:WrapMode) + { + this.navControls = navControls; + + if (wrapMode != null) this.wrapMode = wrapMode; + else + { + this.wrapMode = switch (navControls) + { + case Horizontal: Horizontal; + case Vertical: Vertical; + default: Both; + } + } + super(); + } + + public function addItem(name:String, item:T):T + { + if (length == selectedIndex) item.select(); + + byName[name] = item; + return add(item); + } + + public function resetItem(oldName:String, newName:String, ?callback:Void->Void):T + { + if (!byName.exists(oldName)) throw "No item named:" + oldName; + + var item = byName[oldName]; + byName.remove(oldName); + byName[newName] = item; + item.setItem(newName, callback); + + return item; + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (enabled && !busy) updateControls(); + } + + inline function updateControls() + { + var controls = PlayerSettings.player1.controls; + + var wrapX = wrapMode.match(Horizontal | Both); + var wrapY = wrapMode.match(Vertical | Both); + var newIndex = switch (navControls) + { + case Vertical: navList(controls.UI_UP_P, controls.UI_DOWN_P, wrapY); + case Horizontal: navList(controls.UI_LEFT_P, controls.UI_RIGHT_P, wrapX); + case Both: navList(controls.UI_LEFT_P || controls.UI_UP_P, controls.UI_RIGHT_P || controls.UI_DOWN_P, !wrapMode.match(None)); + + case Columns(num): navGrid(num, controls.UI_LEFT_P, controls.UI_RIGHT_P, wrapX, controls.UI_UP_P, controls.UI_DOWN_P, wrapY); + case Rows(num): navGrid(num, controls.UI_UP_P, controls.UI_DOWN_P, wrapY, controls.UI_LEFT_P, controls.UI_RIGHT_P, wrapX); + } + + if (newIndex != selectedIndex) + { + FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + selectItem(newIndex); + } + + // Todo: bypass popup blocker on firefox + if (controls.ACCEPT) accept(); + } + + function navAxis(index:Int, size:Int, prev:Bool, next:Bool, allowWrap:Bool):Int + { + if (prev == next) return index; + + if (prev) + { + if (index > 0) index--; + else if (allowWrap) index = size - 1; + } + else + { + if (index < size - 1) index++; + else if (allowWrap) index = 0; + } + + return index; + } + + /** + * Controls navigation on a linear list of items such as Vertical. + * @param prev + * @param next + * @param allowWrap + */ + inline function navList(prev:Bool, next:Bool, allowWrap:Bool) + { + return navAxis(selectedIndex, length, prev, next, allowWrap); + } + + /** + * Controls navigation on a grid + * @param latSize The size of the fixed axis of the grid, or the "lateral axis" + * @param latPrev Whether the 'prev' key is pressed along the fixed-lengthed axis. eg: "left" in Column mode + * @param latNext Whether the 'next' key is pressed along the fixed-lengthed axis. eg: "right" in Column mode + * @param prev Whether the 'prev' key is pressed along the variable-lengthed axis. eg: "up" in Column mode + * @param next Whether the 'next' key is pressed along the variable-lengthed axis. eg: "down" in Column mode + * @param allowWrap unused + */ + function navGrid(latSize:Int, latPrev:Bool, latNext:Bool, latAllowWrap:Bool, prev:Bool, next:Bool, allowWrap:Bool):Int + { + // The grid length along the variable-length axis + var size = Math.ceil(length / latSize); + // The selected position along the variable-length axis + var index = Math.floor(selectedIndex / latSize); + // The selected position along the fixed axis + var latIndex = selectedIndex % latSize; + + latIndex = navAxis(latIndex, latSize, latPrev, latNext, latAllowWrap); + index = navAxis(index, size, prev, next, allowWrap); + + return Std.int(Math.min(length - 1, index * latSize + latIndex)); + } + + public function accept() + { + var selected = members[selectedIndex]; + onAcceptPress.dispatch(selected); + + if (selected.fireInstantly) selected.callback(); + else + { + busy = true; + FunkinSound.playOnce(Paths.sound('confirmMenu')); + FlxFlicker.flicker(selected, 1, 0.06, true, false, function(_) { + busy = false; + selected.callback(); + }); + } + } + + public function selectItem(index:Int) + { + members[selectedIndex].idle(); + + selectedIndex = index; + + var selected = members[selectedIndex]; + selected.select(); + onChange.dispatch(selected); + } + + public function has(name:String) + { + return byName.exists(name); + } + + public function getItem(name:String) + { + return byName[name]; + } + + override function destroy() + { + super.destroy(); + byName.clear(); + onChange.removeAll(); + onAcceptPress.removeAll(); + } + + inline function get_selectedItem():T + { + return members[selectedIndex]; + } +} + +class MenuListItem extends FlxSprite +{ + public var callback:Void->Void; + public var name:String; + + /** + * Set to true for things like opening URLs otherwise, it may it get blocked. + */ + public var fireInstantly = false; + + public var selected(get, never):Bool; + + function get_selected() + return alpha == 1.0; + + public function new(x = 0.0, y = 0.0, name:String, callback) + { + super(x, y); + + setData(name, callback); + idle(); + } + + function setData(name:String, ?callback:Void->Void) + { + this.name = name; + + if (callback != null) this.callback = callback; + } + + /** + * Calls setData and resets/redraws the state of the item + * @param name the label. + * @param callback Unchanged if null. + */ + public function setItem(name:String, ?callback:Void->Void) + { + setData(name, callback); + + if (selected) select(); + else + idle(); + } + + public function idle() + { + alpha = 0.6; + } + + public function select() + { + alpha = 1.0; + } +} + +class MenuTypedItem extends MenuListItem +{ + public var label(default, set):T; + + public function new(x = 0.0, y = 0.0, label:T, name:String, callback) + { + super(x, y, name, callback); + // set label after super otherwise setters fuck up + this.label = label; + } + + /** + * Use this when you only want to show the label + */ + public function setEmptyBackground() + { + var oldWidth = width; + var oldHeight = height; + makeGraphic(1, 1, 0x0); + width = oldWidth; + height = oldHeight; + } + + function set_label(value:T) + { + if (value != null) + { + value.x = x; + value.y = y; + value.alpha = alpha; + } + return this.label = value; + } + + override function update(elapsed:Float) + { + super.update(elapsed); + if (label != null) label.update(elapsed); + } + + override function draw() + { + super.draw(); + if (label != null) + { + label.cameras = cameras; + label.scrollFactor.copyFrom(scrollFactor); + label.draw(); + } + } + + override function set_alpha(value:Float):Float + { + super.set_alpha(value); + + if (label != null) label.alpha = alpha; + + return alpha; + } + + override function set_x(value:Float):Float + { + super.set_x(value); + + if (label != null) label.x = x; + + return x; + } + + override function set_y(Value:Float):Float + { + super.set_y(Value); + + if (label != null) label.y = y; + + return y; + } +} + +enum NavControls +{ + Horizontal; + Vertical; + Both; + Columns(num:Int); + Rows(num:Int); +} + +enum WrapMode +{ + Horizontal; + Vertical; + Both; + None; +} diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx new file mode 100644 index 00000000..47d0d1ef --- /dev/null +++ b/source/funkin/ui/MusicBeatState.hx @@ -0,0 +1,217 @@ +package funkin.ui; + +import funkin.modding.IScriptedClass.IEventHandler; +import funkin.ui.mainmenu.MainMenuState; +import flixel.FlxSubState; +import flixel.addons.transition.FlxTransitionableState; +import flixel.text.FlxText; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import flixel.util.FlxSort; +import funkin.modding.PolymodHandler; +import funkin.modding.events.ScriptEvent; +import funkin.modding.module.ModuleHandler; +import funkin.util.SortUtil; +import funkin.input.Controls; + +/** + * 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; + + inline function get_controls():Controls + return PlayerSettings.player1.controls; + + public var leftWatermarkText:Null = null; + public var rightWatermarkText:Null = null; + + public var conductorInUse(get, set):Conductor; + + var _conductorInUse:Null; + + function get_conductorInUse():Conductor + { + if (_conductorInUse == null) return Conductor.instance; + return _conductorInUse; + } + + function set_conductorInUse(value:Conductor):Conductor + { + return _conductorInUse = value; + } + + public function new() + { + super(); + + initCallbacks(); + } + + function initCallbacks() + { + subStateOpened.add(onOpenSubStateComplete); + subStateClosed.add(onCloseSubStateComplete); + } + + override function create() + { + super.create(); + + createWatermarkText(); + + Conductor.beatHit.add(this.beatHit); + Conductor.stepHit.add(this.stepHit); + } + + public override function destroy():Void + { + super.destroy(); + Conductor.beatHit.remove(this.beatHit); + Conductor.stepHit.remove(this.stepHit); + } + + function handleFunctionControls():Void + { + // Emergency exit button. + if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + dispatchEvent(new UpdateScriptEvent(elapsed)); + } + + override function onFocus():Void + { + super.onFocus(); + + dispatchEvent(new FocusScriptEvent(FOCUS_GAINED)); + } + + override function onFocusLost():Void + { + super.onFocusLost(); + + dispatchEvent(new FocusScriptEvent(FOCUS_LOST)); + } + + function createWatermarkText() + { + // Both have an xPos of 0, but a width equal to the full screen. + // The rightWatermarkText is right aligned, which puts the text in the correct spot. + leftWatermarkText = new FlxText(0, FlxG.height - 18, FlxG.width, '', 12); + rightWatermarkText = new FlxText(0, FlxG.height - 18, FlxG.width, '', 12); + + // 100,000 should be good enough. + leftWatermarkText.zIndex = 100000; + rightWatermarkText.zIndex = 100000; + leftWatermarkText.scrollFactor.set(0, 0); + rightWatermarkText.scrollFactor.set(0, 0); + leftWatermarkText.setFormat("VCR OSD Mono", 16, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + rightWatermarkText.setFormat("VCR OSD Mono", 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + + add(leftWatermarkText); + add(rightWatermarkText); + } + + public function dispatchEvent(event:ScriptEvent) + { + ModuleHandler.callEvent(event); + } + + function reloadAssets() + { + PolymodHandler.forceReloadAssets(); + + // Create a new instance of the current state, so old data is cleared. + FlxG.resetState(); + } + + public function stepHit():Bool + { + var event = new SongTimeScriptEvent(SONG_STEP_HIT, conductorInUse.currentBeat, conductorInUse.currentStep); + + dispatchEvent(event); + + if (event.eventCanceled) return false; + + return true; + } + + public function beatHit():Bool + { + var event = new SongTimeScriptEvent(SONG_BEAT_HIT, conductorInUse.currentBeat, conductorInUse.currentStep); + + dispatchEvent(event); + + if (event.eventCanceled) return false; + + return true; + } + + /** + * Refreshes the state, by redoing the render order of all sprites. + * It does this based on the `zIndex` of each prop. + */ + public function refresh() + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + @:nullSafety(Off) + override function startOutro(onComplete:() -> Void):Void + { + var event = new StateChangeScriptEvent(STATE_CHANGE_BEGIN, null, true); + + dispatchEvent(event); + + if (event.eventCanceled) + { + return; + } + else + { + FunkinSound.stopAllAudio(); + + onComplete(); + } + } + + public override function openSubState(targetSubState:FlxSubState):Void + { + var event = new SubStateScriptEvent(SUBSTATE_OPEN_BEGIN, targetSubState, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + super.openSubState(targetSubState); + } + + function onOpenSubStateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(SUBSTATE_OPEN_END, targetState, true)); + } + + public override function closeSubState():Void + { + var event = new SubStateScriptEvent(SUBSTATE_CLOSE_BEGIN, this.subState, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + super.closeSubState(); + } + + function onCloseSubStateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(SUBSTATE_CLOSE_END, targetState, true)); + } +} diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx new file mode 100644 index 00000000..d13f9945 --- /dev/null +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -0,0 +1,240 @@ +package funkin.ui; + +import flixel.FlxSubState; +import flixel.text.FlxText; +import funkin.ui.mainmenu.MainMenuState; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import funkin.modding.events.ScriptEvent; +import funkin.modding.IScriptedClass.IEventHandler; +import funkin.modding.module.ModuleHandler; +import funkin.modding.PolymodHandler; +import funkin.util.SortUtil; +import flixel.util.FlxSort; +import funkin.input.Controls; + +/** + * MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState. + */ +@:nullSafety +class MusicBeatSubState extends FlxSubState implements IEventHandler +{ + public var leftWatermarkText:Null = null; + public var rightWatermarkText:Null = null; + + public var conductorInUse(get, set):Conductor; + + var _conductorInUse:Null; + + function get_conductorInUse():Conductor + { + if (_conductorInUse == null) return Conductor.instance; + return _conductorInUse; + } + + function set_conductorInUse(value:Conductor):Conductor + { + return _conductorInUse = value; + } + + var controls(get, never):Controls; + + inline function get_controls():Controls + return PlayerSettings.player1.controls; + + public function new(bgColor:FlxColor = FlxColor.TRANSPARENT) + { + super(); + this.bgColor = bgColor; + + initCallbacks(); + } + + function initCallbacks() + { + subStateOpened.add(onOpenSubStateComplete); + subStateClosed.add(onCloseSubStateComplete); + } + + override function create():Void + { + super.create(); + + createWatermarkText(); + + Conductor.beatHit.add(this.beatHit); + Conductor.stepHit.add(this.stepHit); + + initConsoleHelpers(); + } + + public override function destroy():Void + { + super.destroy(); + Conductor.beatHit.remove(this.beatHit); + Conductor.stepHit.remove(this.stepHit); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Emergency exit button. + if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); + + // Display Conductor info in the watch window. + FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); + Conductor.watchQuick(conductorInUse); + + dispatchEvent(new UpdateScriptEvent(elapsed)); + } + + override function onFocus():Void + { + super.onFocus(); + + dispatchEvent(new FocusScriptEvent(FOCUS_GAINED)); + } + + override function onFocusLost():Void + { + super.onFocusLost(); + + dispatchEvent(new FocusScriptEvent(FOCUS_LOST)); + } + + public function initConsoleHelpers():Void {} + + function reloadAssets() + { + PolymodHandler.forceReloadAssets(); + + // Restart the current state, so old data is cleared. + FlxG.resetState(); + } + + /** + * Refreshes the state, by redoing the render order of all sprites. + * It does this based on the `zIndex` of each prop. + */ + public function refresh() + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + /** + * Called when a step is hit in the current song. + * Continues outside of PlayState, for things like animations in menus. + * @return Whether the event should continue (not canceled). + */ + public function stepHit():Bool + { + var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, conductorInUse.currentBeat, conductorInUse.currentStep); + + dispatchEvent(event); + + if (event.eventCanceled) return false; + + return true; + } + + /** + * Called when a beat is hit in the current song. + * Continues outside of PlayState, for things like animations in menus. + * @return Whether the event should continue (not canceled). + */ + public function beatHit():Bool + { + var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, conductorInUse.currentBeat, conductorInUse.currentStep); + + dispatchEvent(event); + + if (event.eventCanceled) return false; + + return true; + } + + public function dispatchEvent(event:ScriptEvent) + { + ModuleHandler.callEvent(event); + } + + function createWatermarkText():Void + { + // Both have an xPos of 0, but a width equal to the full screen. + // The rightWatermarkText is right aligned, which puts the text in the correct spot. + leftWatermarkText = new FlxText(0, FlxG.height - 18, FlxG.width, '', 12); + rightWatermarkText = new FlxText(0, FlxG.height - 18, FlxG.width, '', 12); + + // 100,000 should be good enough. + leftWatermarkText.zIndex = 100000; + rightWatermarkText.zIndex = 100000; + leftWatermarkText.scrollFactor.set(0, 0); + rightWatermarkText.scrollFactor.set(0, 0); + leftWatermarkText.setFormat('VCR OSD Mono', 16, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + rightWatermarkText.setFormat('VCR OSD Mono', 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + + add(leftWatermarkText); + add(rightWatermarkText); + } + + /** + * Close this substate and replace it with a different one. + */ + public function switchSubState(substate:FlxSubState):Void + { + this.close(); + this._parentState.openSubState(substate); + } + + @:nullSafety(Off) + override function startOutro(onComplete:() -> Void):Void + { + var event = new StateChangeScriptEvent(STATE_CHANGE_BEGIN, null, true); + + dispatchEvent(event); + + if (event.eventCanceled) + { + return; + } + else + { + FunkinSound.stopAllAudio(); + + onComplete(); + } + } + + public override function openSubState(targetSubState:FlxSubState):Void + { + var event = new SubStateScriptEvent(SUBSTATE_OPEN_BEGIN, targetSubState, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + super.openSubState(targetSubState); + } + + function onOpenSubStateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(SUBSTATE_OPEN_END, targetState, true)); + } + + public override function closeSubState():Void + { + var event = new SubStateScriptEvent(SUBSTATE_CLOSE_BEGIN, this.subState, true); + + dispatchEvent(event); + + if (event.eventCanceled) return; + + super.closeSubState(); + } + + function onCloseSubStateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(SUBSTATE_CLOSE_END, targetState, true)); + } +} diff --git a/source/funkin/ui/Page.hx b/source/funkin/ui/Page.hx new file mode 100644 index 00000000..16dab990 --- /dev/null +++ b/source/funkin/ui/Page.hx @@ -0,0 +1,84 @@ +package funkin.ui; + +import flixel.group.FlxGroup; +import flixel.FlxG; +import flixel.FlxSubState; +import flixel.util.FlxSignal; +import funkin.input.Controls; +import funkin.audio.FunkinSound; + +/** + * A page in a menu system. + * + * You can use it on it's own, or handle it with a `Codex` + */ +class Page extends FlxGroup +{ + public var onSwitch(default, null) = new FlxTypedSignalVoid>(); + public var onExit(default, null) = new FlxSignal(); + + public var enabled(default, set) = true; + public var canExit = true; + + public var codex:Codex; + + var controls(get, never):Controls; + + inline function get_controls() + return PlayerSettings.player1.controls; + + var subState:FlxSubState; + + inline function switchPage(name:T) + { + onSwitch.dispatch(name); + } + + inline function exit() + { + onExit.dispatch(); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (enabled) updateEnabled(elapsed); + } + + function updateEnabled(elapsed:Float) + { + if (canExit && controls.BACK) + { + exit(); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } + } + + function set_enabled(value:Bool) + { + return this.enabled = value; + } + + function openPrompt(prompt:Prompt, onClose:Void->Void) + { + enabled = false; + prompt.closeCallback = function() { + enabled = true; + if (onClose != null) onClose(); + } + + FlxG.state.openSubState(prompt); + } + + override function destroy() + { + super.destroy(); + onSwitch.removeAll(); + } +} + +/** + * For you to fill in your own page name stuff, see OptionsState.hx and it's OptionsMenuPageName + */ +enum abstract PageName(String) from String to String {} diff --git a/source/funkin/ui/PixelatedIcon.hx b/source/funkin/ui/PixelatedIcon.hx new file mode 100644 index 00000000..8b580ec2 --- /dev/null +++ b/source/funkin/ui/PixelatedIcon.hx @@ -0,0 +1,97 @@ +package funkin.ui; + +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) + { + super(x, y); + this.makeGraphic(32, 32, 0x00000000); + this.antialiasing = false; + this.active = false; + } + + public function setCharacter(char:String):Void + { + var charPath:String = "freeplay/icons/"; + + switch (char) + { + case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf": + charPath += "bfpixel"; + case "monster-christmas": + charPath += "monsterpixel"; + case "mom" | "mom-car": + charPath += "mommypixel"; + case "pico-blazin" | "pico-playable" | "pico-speaker" | "pico-pixel" | "pico-holding-nene": + charPath += "picopixel"; + case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen": + charPath += "gfpixel"; + case "dad": + charPath += "dadpixel"; + case "darnell-blazin": + charPath += "darnellpixel"; + case "senpai-angry": + charPath += "senpaipixel"; + case "spooky-dark": + charPath += "spookypixel"; + case "tankman-atlas" | "tankman-bloody": + charPath += "tankmanpixel"; + default: + charPath += '${char}pixel'; + } + + if (!openfl.utils.Assets.exists(Paths.image(charPath))) + { + trace('[WARN] Character ${char} has no freeplay icon.'); + this.visible = false; + return; + } + else + { + this.visible = true; + } + + var isAnimated = openfl.utils.Assets.exists(Paths.file('images/$charPath.xml')); + + if (isAnimated) + { + this.frames = Paths.getSparrowAtlas(charPath); + } + else + { + this.loadGraphic(Paths.image(charPath)); + } + + this.scale.x = this.scale.y = 2; + + switch (char) + { + case 'parents-christmas': + this.origin.x = 140; + default: + this.origin.x = 100; + } + + if (isAnimated) + { + this.active = true; + this.animation.addByPrefix('idle', 'idle0', 10, true); + this.animation.addByPrefix('confirm', 'confirm0', 10, false); + this.animation.addByPrefix('confirm-hold', 'confirm-hold0', 10, true); + + this.animation.finishCallback = function(name:String):Void { + trace('Finish pixel animation: ${name}'); + if (name == 'confirm') this.animation.play('confirm-hold'); + }; + + this.animation.play('idle'); + } + } +} diff --git a/source/funkin/ui/Prompt.hx b/source/funkin/ui/Prompt.hx new file mode 100644 index 00000000..eebeb5ae --- /dev/null +++ b/source/funkin/ui/Prompt.hx @@ -0,0 +1,122 @@ +package funkin.ui; + +import flixel.FlxSprite; +import funkin.ui.AtlasText.AtlasFont; +import funkin.ui.MenuList; + +/** + * Opens a yes/no dialog box as a substate over the current state. + */ +class Prompt extends flixel.FlxSubState +{ + inline static var MARGIN = 100; + + public var onYes:Void->Void; + public var onNo:Void->Void; + public var buttons:TextMenuList; + public var field:AtlasText; + public var back:FlxSprite; + + var style:ButtonStyle; + + public function new(text:String, style:ButtonStyle = Ok) + { + this.style = style; + super(0x80000000); + + buttons = new TextMenuList(Horizontal); + + field = new AtlasText(text, AtlasFont.BOLD); + field.scrollFactor.set(0, 0); + } + + override function create() + { + super.create(); + + field.y = MARGIN; + field.screenCenter(X); + add(field); + + createButtons(); + add(buttons); + } + + public function createBg(width:Int, height:Int, color = 0xFF808080) + { + back = new FlxSprite(); + back.makeGraphic(width, height, color, false, "prompt-bg"); + back.screenCenter(XY); + add(back); + members.unshift(members.pop()); // bring to front + } + + public function createBgFromMargin(margin = MARGIN, color = 0xFF808080) + { + createBg(Std.int(FlxG.width - margin * 2), Std.int(FlxG.height - margin * 2), color); + } + + public function setButtons(style:ButtonStyle) + { + if (this.style != style) + { + this.style = style; + createButtons(); + } + } + + function createButtons() + { + // destroy previous buttons + while (buttons.members.length > 0) + { + buttons.remove(buttons.members[0], true).destroy(); + } + + switch (style) + { + case Yes_No: + createButtonsHelper("yes", "no"); + case Ok: + createButtonsHelper("ok"); + case Custom(yes, no): + createButtonsHelper(yes, no); + case None: + buttons.exists = false; + }; + } + + function createButtonsHelper(yes:String, ?no:String) + { + buttons.exists = true; + // pass anonymous functions rather than the current callbacks, in case they change later + var yesButton = buttons.createItem(yes, function() onYes()); + yesButton.screenCenter(X); + yesButton.y = FlxG.height - yesButton.height - MARGIN; + yesButton.scrollFactor.set(0, 0); + if (no != null) + { + // place right + yesButton.x = FlxG.width - yesButton.width - MARGIN; + + var noButton = buttons.createItem(no, function() onNo()); + noButton.x = MARGIN; + noButton.y = FlxG.height - noButton.height - MARGIN; + noButton.scrollFactor.set(0, 0); + } + } + + public function setText(text:String) + { + field.text = text; + field.screenCenter(X); + } +} + +enum ButtonStyle +{ + Ok; + Yes_No; + Custom(yes:String, no:Null); // Todo: more than 2 + None; +} diff --git a/source/funkin/ui/TextMenuList.hx b/source/funkin/ui/TextMenuList.hx new file mode 100644 index 00000000..11238fbf --- /dev/null +++ b/source/funkin/ui/TextMenuList.hx @@ -0,0 +1,59 @@ +package funkin.ui; + +import funkin.ui.AtlasText; +import funkin.ui.MenuList; + +@:nullSafety +class TextMenuList extends MenuTypedList +{ + public function new(navControls:NavControls = Vertical, ?wrapMode) + { + super(navControls, wrapMode); + } + + public function createItem(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void, fireInstantly = false):TextMenuItem + { + var item:TextMenuItem = new TextMenuItem(x, y, name, font, callback); + item.fireInstantly = fireInstantly; + return addItem(name, item); + } +} + +@:nullSafety +class TextMenuItem extends TextTypedMenuItem +{ + public function new(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void) + { + super(x, y, new AtlasText(0, 0, name, font), name, callback); + setEmptyBackground(); + } +} + +@:nullSafety +class TextTypedMenuItem extends MenuTypedItem +{ + public function new(x = 0.0, y = 0.0, label:T, name:String, ?callback:Void->Void) + { + super(x, y, label, name, callback); + } + + override function setItem(name:String, ?callback:Void->Void) + { + if (label != null) + { + label.text = name; + label.alpha = alpha; + width = label.width; + height = label.height; + } + + super.setItem(name, callback); + } + + override function set_label(value:T):T + { + super.set_label(value); + setItem(name, callback); + return value; + } +} diff --git a/source/funkin/ui/charSelect/CharIcon.hx b/source/funkin/ui/charSelect/CharIcon.hx new file mode 100644 index 00000000..6d627428 --- /dev/null +++ b/source/funkin/ui/charSelect/CharIcon.hx @@ -0,0 +1,17 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; + +class CharIcon extends FlxSprite +{ + public var locked:Bool = false; + + public function new(x:Float, y:Float, locked:Bool = false) + { + super(x, y); + + this.locked = locked; + + makeGraphic(128, 128); + } +} diff --git a/source/funkin/ui/charSelect/CharIconCharacter.hx b/source/funkin/ui/charSelect/CharIconCharacter.hx new file mode 100644 index 00000000..7f7b5c21 --- /dev/null +++ b/source/funkin/ui/charSelect/CharIconCharacter.hx @@ -0,0 +1,49 @@ +package funkin.ui.charSelect; + +import openfl.display.BitmapData; +import openfl.filters.DropShadowFilter; +import openfl.filters.ConvolutionFilter; +import funkin.graphics.shaders.StrokeShader; + +class CharIconCharacter extends CharIcon +{ + public var dropShadowFilter:DropShadowFilter; + + var matrixFilter:Array = [ + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + ]; + + var divisor:Int = 1; + var bias:Int = 0; + var convolutionFilter:ConvolutionFilter; + + public var noDropShadow:BitmapData; + public var withDropShadow:BitmapData; + + var strokeShader:StrokeShader; + + public function new(path:String) + { + super(0, 0, false); + + loadGraphic(Paths.image('freeplay/icons/' + path + 'pixel')); + setGraphicSize(128, 128); + updateHitbox(); + antialiasing = false; + + strokeShader = new StrokeShader(); + // shader = strokeShader; + + // noDropShadow = pixels.clone(); + + // dropShadowFilter = new DropShadowFilter(5, 45, 0, 1, 0, 0); + // convolutionFilter = new ConvolutionFilter(3, 3, matrixFilter, divisor, bias); + // pixels.applyFilter(pixels, pixels.rect, new openfl.geom.Point(0, 0), dropShadowFilter); + // pixels.applyFilter(pixels, pixels.rect, new openfl.geom.Point(0, 0), convolutionFilter); + // withDropShadow = pixels.clone(); + + // pixels = noDropShadow.clone(); + } +} diff --git a/source/funkin/ui/charSelect/CharIconLocked.hx b/source/funkin/ui/charSelect/CharIconLocked.hx new file mode 100644 index 00000000..dbe84a6c --- /dev/null +++ b/source/funkin/ui/charSelect/CharIconLocked.hx @@ -0,0 +1,3 @@ +package funkin.ui.charSelect; + +class CharIconLocked extends CharIcon {} diff --git a/source/funkin/ui/charSelect/CharSelectGF.hx b/source/funkin/ui/charSelect/CharSelectGF.hx new file mode 100644 index 00000000..09555174 --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectGF.hx @@ -0,0 +1,205 @@ +package funkin.ui.charSelect; + +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import flixel.math.FlxMath; +import funkin.util.FramesJSFLParser; +import funkin.util.FramesJSFLParser.FramesJSFLInfo; +import funkin.util.FramesJSFLParser.FramesJSFLFrame; +import funkin.modding.IScriptedClass.IBPMSyncedScriptedClass; +import funkin.modding.events.ScriptEvent; +import funkin.vis.dsp.SpectralAnalyzer; +import funkin.data.freeplay.player.PlayerRegistry; + +class CharSelectGF extends FlxAtlasSprite implements IBPMSyncedScriptedClass +{ + var fadeTimer:Float = 0; + var fadingStatus:FadeStatus = OFF; + var fadeAnimIndex:Int = 0; + + var animInInfo:FramesJSFLInfo; + var animOutInfo:FramesJSFLInfo; + + var intendedYPos:Float = 0; + var intendedAlpha:Float = 0; + var list:Array = []; + + var analyzer:SpectralAnalyzer; + + var currentGFPath:Null; + var enableVisualizer:Bool = false; + + public function new() + { + super(0, 0, Paths.animateAtlas("charSelect/gfChill")); + + list = anim.curSymbol.getFrameLabelNames(); + + switchGF("bf"); + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + switch (fadingStatus) + { + case OFF: + // do nothing if it's off! + // or maybe force position to be 0,0? + // maybe reset timers? + resetFadeAnimParams(); + case FADE_OUT: + doFade(animOutInfo); + case FADE_IN: + doFade(animInInfo); + default: + } + } + + public function onStepHit(event:SongTimeScriptEvent):Void {} + + var danceEvery:Int = 2; + + public function onBeatHit(event:SongTimeScriptEvent):Void + { + // TODO: There's a minor visual bug where there's a little stutter. + // This happens because the animation is getting restarted while it's already playing. + // I tried make this not interrupt an existing idle, + // but isAnimationFinished() and isLoopComplete() both don't work! What the hell? + // danceEvery isn't necessary if that gets fixed. + if (getCurrentAnimation() == "idle" && (event.beat % danceEvery == 0)) + { + trace('GF beat hit'); + playAnimation("idle", true, false, false); + } + }; + + override public function draw() + { + if (analyzer != null) drawFFT(); + super.draw(); + } + + function drawFFT() + { + if (enableVisualizer) + { + var levels = analyzer.getLevels(); + var frame = anim.curSymbol.timeline.get("VIZ_bars").get(anim.curFrame); + var elements = frame.getList(); + var len:Int = cast Math.min(elements.length, 7); + + for (i in 0...len) + { + var animFrame:Int = Math.round(levels[i].value * 12); + + #if desktop + // Web version scales with the Flixel volume level. + // This line brings platform parity but looks worse. + // animFrame = Math.round(animFrame * FlxG.sound.volume); + #end + + animFrame = Math.floor(Math.min(12, animFrame)); + animFrame = Math.floor(Math.max(0, animFrame)); + + animFrame = Std.int(Math.abs(animFrame - 12)); // shitty dumbass flip, cuz dave got da shit backwards lol! + + elements[i].symbol.firstFrame = animFrame; + } + } + } + + /** + * @param animInfo Should not be confused with animInInfo! + * This is merely a local var for the function! + */ + function doFade(animInfo:FramesJSFLInfo):Void + { + fadeTimer += FlxG.elapsed; + if (fadeTimer >= 1 / 24) + { + fadeTimer -= FlxG.elapsed; + // only inc the index for the first frame, used for reference of where to "start" + if (fadeAnimIndex == 0) + { + fadeAnimIndex++; + return; + } + + var curFrame:FramesJSFLFrame = animInfo.frames[fadeAnimIndex]; + var prevFrame:FramesJSFLFrame = animInfo.frames[fadeAnimIndex - 1]; + + var xDiff:Float = curFrame.x - prevFrame.x; + var yDiff:Float = curFrame.y - prevFrame.y; + var alphaDiff:Float = curFrame.alpha - prevFrame.alpha; + alphaDiff /= 100; // flash exports alpha as a whole number + + alpha += alphaDiff; + alpha = FlxMath.bound(alpha, 0, 1); + x += xDiff; + y += yDiff; + + fadeAnimIndex++; + } + + if (fadeAnimIndex >= animInfo.frames.length) fadingStatus = OFF; + } + + function resetFadeAnimParams() + { + fadeTimer = 0; + fadeAnimIndex = 0; + } + + /** + * For switching between "GFs" such as gf, nene, etc + * @param bf Which BF we are selecting, so that we know the accompyaning GF + */ + public function switchGF(bf:String):Void + { + var previousGFPath = currentGFPath; + + var bfObj = PlayerRegistry.instance.fetchEntry(bf); + var gfData = bfObj?.getCharSelectData()?.gf; + currentGFPath = gfData?.assetPath != null ? Paths.animateAtlas(gfData?.assetPath) : null; + + // We don't need to update any anims if we didn't change GF + trace('currentGFPath(${currentGFPath})'); + if (currentGFPath == null) + { + this.visible = false; + return; + } + else if (previousGFPath != currentGFPath) + { + this.visible = true; + loadAtlas(currentGFPath); + + enableVisualizer = gfData?.visualizer ?? false; + + var animInfoPath = Paths.file('images/${gfData?.animInfoPath}'); + + animInInfo = FramesJSFLParser.parse(animInfoPath + '/In.txt'); + animOutInfo = FramesJSFLParser.parse(animInfoPath + '/Out.txt'); + } + + playAnimation("idle", true, false, false); + + updateHitbox(); + } + + public function onScriptEvent(event:ScriptEvent):Void {}; + + public function onCreate(event:ScriptEvent):Void {}; + + public function onDestroy(event:ScriptEvent):Void {}; + + public function onUpdate(event:UpdateScriptEvent):Void {}; +} + +enum FadeStatus +{ + OFF; + FADE_OUT; + FADE_IN; +} diff --git a/source/funkin/ui/charSelect/CharSelectPlayer.hx b/source/funkin/ui/charSelect/CharSelectPlayer.hx new file mode 100644 index 00000000..23184e40 --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectPlayer.hx @@ -0,0 +1,88 @@ +package funkin.ui.charSelect; + +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.modding.IScriptedClass.IBPMSyncedScriptedClass; +import funkin.modding.events.ScriptEvent; + +class CharSelectPlayer extends FlxAtlasSprite implements IBPMSyncedScriptedClass +{ + public function new(x:Float, y:Float) + { + super(x, y, Paths.animateAtlas("charSelect/bfChill")); + + onAnimationComplete.add(function(animLabel:String) { + switch (animLabel) + { + case "slidein": + if (hasAnimation("slidein idle point")) + { + playAnimation("slidein idle point", true, false, false); + } + else + { + playAnimation("idle", true, false, false); + } + case "deselect": + playAnimation("deselect loop start", true, false, true); + + case "slidein idle point", "cannot select Label", "unlock": + playAnimation("idle", true, false, false); + case "idle": + trace('Waiting for onBeatHit'); + } + }); + } + + public function onStepHit(event:SongTimeScriptEvent):Void {} + + public function onBeatHit(event:SongTimeScriptEvent):Void + { + // TODO: There's a minor visual bug where there's a little stutter. + // This happens because the animation is getting restarted while it's already playing. + // I tried make this not interrupt an existing idle, + // but isAnimationFinished() and isLoopComplete() both don't work! What the hell? + // danceEvery isn't necessary if that gets fixed. + // + if (getCurrentAnimation() == "idle") + { + playAnimation("idle", true, false, false); + } + }; + + public function updatePosition(str:String) + { + switch (str) + { + case "bf": + x = 0; + y = 0; + case "pico": + x = 0; + y = 0; + case "random": + } + } + + public function switchChar(str:String) + { + switch str + { + default: + loadAtlas(Paths.animateAtlas("charSelect/" + str + "Chill")); + } + + playAnimation("slidein", true, false, false); + + updateHitbox(); + + updatePosition(str); + } + + public function onScriptEvent(event:ScriptEvent):Void {}; + + public function onCreate(event:ScriptEvent):Void {}; + + public function onDestroy(event:ScriptEvent):Void {}; + + public function onUpdate(event:UpdateScriptEvent):Void {}; +} diff --git a/source/funkin/ui/charSelect/CharSelectSubState.hx b/source/funkin/ui/charSelect/CharSelectSubState.hx new file mode 100644 index 00000000..87f08960 --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectSubState.hx @@ -0,0 +1,1115 @@ +package funkin.ui.charSelect; + +import openfl.filters.BitmapFilter; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.system.debug.watch.Tracker.TrackerProfile; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import openfl.filters.DropShadowFilter; +import funkin.graphics.FunkinCamera; +import funkin.graphics.shaders.BlueFade; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.stage.Stage; +import funkin.save.Save; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.PixelatedIcon; +import funkin.util.MathUtil; +import funkin.vis.dsp.SpectralAnalyzer; +import openfl.display.BlendMode; +import openfl.filters.ShaderFilter; +import funkin.util.FramesJSFLParser; +import funkin.util.FramesJSFLParser.FramesJSFLInfo; +import funkin.graphics.FunkinSprite; +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.Medals; +#end + +class CharSelectSubState extends MusicBeatSubState +{ + var cursor:FlxSprite; + + var cursorBlue:FlxSprite; + var cursorDarkBlue:FlxSprite; + var grpCursors:FlxTypedGroup; + var cursorConfirmed:FlxSprite; + var cursorDenied:FlxSprite; + var cursorX:Int = 0; + var cursorY:Int = 0; + var cursorFactor:Float = 110; + var cursorOffsetX:Float = -16; + var cursorOffsetY:Float = -48; + var cursorLocIntended:FlxPoint = new FlxPoint(0, 0); + var lerpAmnt:Float = 0.95; + var tmrFrames:Int = 60; + var currentStage:Stage; + var playerChill:CharSelectPlayer; + var playerChillOut:CharSelectPlayer; + var gfChill:CharSelectGF; + var gfChillOut:CharSelectGF; + var barthing:FlxAtlasSprite; + var dipshitBacking:FlxSprite; + var chooseDipshit:FlxSprite; + var dipshitBlur:FlxSprite; + var transitionGradient:FlxSprite; + var curChar(default, set):String = "pico"; + var nametag:Nametag; + var camFollow:FlxObject; + var autoFollow:Bool = false; + var availableChars:Map = new Map(); + var pressedSelect:Bool = false; + var selectTimer:FlxTimer = new FlxTimer(); + var allowInput:Bool = false; + + var selectSound:FunkinSound; + var unlockSound:FunkinSound; + var lockedSound:FunkinSound; + var introSound:FunkinSound; + var staticSound:FunkinSound; + + var charSelectCam:FunkinCamera; + + var selectedBizz:Array = [ + new DropShadowFilter(0, 0, 0xFFFFFF, 1, 2, 2, 19, 1, false, false, false), + new DropShadowFilter(5, 45, 0x000000, 1, 2, 2, 1, 1, false, false, false) + ]; + + var bopInfo:FramesJSFLInfo; + var blackScreen:FunkinSprite; + + public function new() + { + super(); + loadAvailableCharacters(); + } + + function loadAvailableCharacters():Void + { + var playerIds:Array = PlayerRegistry.instance.listEntryIds(); + + for (playerId in playerIds) + { + var player:Null = PlayerRegistry.instance.fetchEntry(playerId); + if (player == null) continue; + var playerData = player.getCharSelectData(); + if (playerData == null) continue; + + var targetPosition:Int = playerData.position ?? 0; + while (availableChars.exists(targetPosition)) + { + targetPosition += 1; + } + + trace('Placing player ${playerId} at position ${targetPosition}'); + availableChars.set(targetPosition, playerId); + } + } + + var fadeShader:BlueFade = new BlueFade(); + + override public function create():Void + { + super.create(); + + bopInfo = FramesJSFLParser.parse(Paths.file("images/charSelect/iconBopInfo/iconBopInfo.txt")); + + var bg:FlxSprite = new FlxSprite(-153, -140); + bg.loadGraphic(Paths.image('charSelect/charSelectBG')); + bg.scrollFactor.set(0.1, 0.1); + add(bg); + + var crowd:FlxAtlasSprite = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/crowd")); + crowd.anim.play(); + crowd.anim.onComplete.add(function() { + crowd.anim.play(); + }); + crowd.scrollFactor.set(0.3, 0.3); + add(crowd); + + var stageSpr:FlxSprite = new FlxSprite(-40, 391); + stageSpr.frames = Paths.getSparrowAtlas("charSelect/charSelectStage"); + stageSpr.animation.addByPrefix("idle", "stage full instance 1", 24, true); + stageSpr.animation.play("idle"); + add(stageSpr); + + var curtains:FlxSprite = new FlxSprite(-47, -49); + curtains.loadGraphic(Paths.image('charSelect/curtains')); + curtains.scrollFactor.set(1.4, 1.4); + add(curtains); + + barthing = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/barThing")); + barthing.anim.play(""); + barthing.anim.onComplete.add(function() { + barthing.anim.play(""); + }); + barthing.blend = BlendMode.MULTIPLY; + barthing.scrollFactor.set(0, 0); + add(barthing); + + barthing.y += 80; + FlxTween.tween(barthing, {y: barthing.y - 80}, 1.3, {ease: FlxEase.expoOut}); + + var charLight:FlxSprite = new FlxSprite(800, 250); + charLight.loadGraphic(Paths.image('charSelect/charLight')); + add(charLight); + + var charLightGF:FlxSprite = new FlxSprite(180, 240); + charLightGF.loadGraphic(Paths.image('charSelect/charLight')); + add(charLightGF); + + gfChill = new CharSelectGF(); + gfChill.switchGF("bf"); + add(gfChill); + + playerChillOut = new CharSelectPlayer(0, 0); + playerChillOut.switchChar("bf"); + playerChillOut.visible = false; + add(playerChillOut); + + playerChill = new CharSelectPlayer(0, 0); + playerChill.switchChar("bf"); + add(playerChill); + + var speakers:FlxAtlasSprite = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/charSelectSpeakers")); + speakers.anim.play(""); + speakers.anim.onComplete.add(function() { + speakers.anim.play(""); + }); + speakers.scrollFactor.set(1.8, 1.8); + add(speakers); + + var fgBlur:FlxSprite = new FlxSprite(-125, 170); + fgBlur.loadGraphic(Paths.image('charSelect/foregroundBlur')); + fgBlur.blend = openfl.display.BlendMode.MULTIPLY; + add(fgBlur); + + dipshitBlur = new FlxSprite(419, -65); + dipshitBlur.frames = Paths.getSparrowAtlas("charSelect/dipshitBlur"); + dipshitBlur.animation.addByPrefix('idle', "CHOOSE vertical offset instance 1", 24, true); + dipshitBlur.blend = BlendMode.ADD; + dipshitBlur.animation.play("idle"); + add(dipshitBlur); + + dipshitBacking = new FlxSprite(423, -17); + dipshitBacking.frames = Paths.getSparrowAtlas("charSelect/dipshitBacking"); + dipshitBacking.animation.addByPrefix('idle', "CHOOSE horizontal offset instance 1", 24, true); + dipshitBacking.blend = BlendMode.ADD; + dipshitBacking.animation.play("idle"); + add(dipshitBacking); + + dipshitBacking.y += 210; + FlxTween.tween(dipshitBacking, {y: dipshitBacking.y - 210}, 1.1, {ease: FlxEase.expoOut}); + + chooseDipshit = new FlxSprite(426, -13); + chooseDipshit.loadGraphic(Paths.image('charSelect/chooseDipshit')); + add(chooseDipshit); + + chooseDipshit.y += 200; + FlxTween.tween(chooseDipshit, {y: chooseDipshit.y - 200}, 1, {ease: FlxEase.expoOut}); + + dipshitBlur.y += 220; + FlxTween.tween(dipshitBlur, {y: dipshitBlur.y - 220}, 1.2, {ease: FlxEase.expoOut}); + + chooseDipshit.scrollFactor.set(); + dipshitBacking.scrollFactor.set(); + dipshitBlur.scrollFactor.set(); + + nametag = new Nametag(); + add(nametag); + + nametag.scrollFactor.set(); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSprite, ["x", "y", "alpha", "scale", "blend"])); + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxAtlasSprite, ["x", "y"])); + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSound, ["pitch", "volume"])); + + // FlxG.debugger.track(crowd); + // FlxG.debugger.track(stageSpr, "stageSpr"); + // FlxG.debugger.track(bfChill, "bf chill"); + // FlxG.debugger.track(playerChill, "player"); + // FlxG.debugger.track(nametag, "nametag"); + // FlxG.debugger.track(selectSound, "selectSound"); + // FlxG.debugger.track(chooseDipshit, "choose dipshit"); + // FlxG.debugger.track(barthing, "barthing"); + // FlxG.debugger.track(fgBlur, "fgBlur"); + // FlxG.debugger.track(dipshitBlur, "dipshitBlur"); + // FlxG.debugger.track(dipshitBacking, "dipshitBacking"); + // FlxG.debugger.track(charLightGF, "charLight"); + // FlxG.debugger.track(gfChill, "gfChill"); + + grpCursors = new FlxTypedGroup(); + add(grpCursors); + + cursor = new FlxSprite(0, 0); + cursor.loadGraphic(Paths.image('charSelect/charSelector')); + cursor.color = 0xFFFFFF00; + + // FFCC00 + + cursorBlue = new FlxSprite(0, 0); + cursorBlue.loadGraphic(Paths.image('charSelect/charSelector')); + cursorBlue.color = 0xFF3EBBFF; + + cursorDarkBlue = new FlxSprite(0, 0); + cursorDarkBlue.loadGraphic(Paths.image('charSelect/charSelector')); + cursorDarkBlue.color = 0xFF3C74F7; + + cursorBlue.blend = BlendMode.SCREEN; + cursorDarkBlue.blend = BlendMode.SCREEN; + + cursorConfirmed = new FlxSprite(0, 0); + cursorConfirmed.scrollFactor.set(); + cursorConfirmed.frames = Paths.getSparrowAtlas("charSelect/charSelectorConfirm"); + cursorConfirmed.animation.addByPrefix("idle", "cursor ACCEPTED instance 1", 24, true); + cursorConfirmed.visible = false; + add(cursorConfirmed); + + cursorDenied = new FlxSprite(0, 0); + cursorDenied.scrollFactor.set(); + cursorDenied.frames = Paths.getSparrowAtlas("charSelect/charSelectorDenied"); + cursorDenied.animation.addByPrefix("idle", "cursor DENIED instance 1", 24, false); + cursorDenied.visible = false; + add(cursorDenied); + + grpCursors.add(cursorDarkBlue); + grpCursors.add(cursorBlue); + grpCursors.add(cursor); + + selectSound = new FunkinSound(); + selectSound.loadEmbedded(Paths.sound('CS_select')); + selectSound.pitch = 1; + selectSound.volume = 0.7; + + FlxG.sound.defaultSoundGroup.add(selectSound); + FlxG.sound.list.add(selectSound); + + unlockSound = new FunkinSound(); + unlockSound.loadEmbedded(Paths.sound('CS_unlock')); + unlockSound.pitch = 1; + + unlockSound.volume = 0; + unlockSound.play(true); + + FlxG.sound.defaultSoundGroup.add(unlockSound); + FlxG.sound.list.add(unlockSound); + + lockedSound = new FunkinSound(); + lockedSound.loadEmbedded(Paths.sound('CS_locked')); + lockedSound.pitch = 1; + + lockedSound.volume = 1.; + + FlxG.sound.defaultSoundGroup.add(lockedSound); + FlxG.sound.list.add(lockedSound); + + staticSound = new FunkinSound(); + staticSound.loadEmbedded(Paths.sound('static loop')); + staticSound.pitch = 1; + + staticSound.looped = true; + + staticSound.volume = 0.6; + + FlxG.sound.defaultSoundGroup.add(staticSound); + FlxG.sound.list.add(staticSound); + + // playing it here to preload it. not doing this makes a super awkward pause at the end of the intro + // TODO: probably make an intro thing for funkinSound itself that preloads the next audio? + FunkinSound.playMusic('stayFunky', + { + startingVolume: 0, + overrideExisting: true, + restartTrack: true, + }); + + initLocks(); + + for (index => member in grpIcons.members) + { + member.y += 300; + FlxTween.tween(member, {y: member.y - 300}, 1, {ease: FlxEase.expoOut}); + } + + cursor.scrollFactor.set(); + cursorBlue.scrollFactor.set(); + cursorDarkBlue.scrollFactor.set(); + + FlxTween.color(cursor, 0.2, 0xFFFFFF00, 0xFFFFCC00, {type: PINGPONG}); + + // FlxG.debugger.track(cursor); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(CharSelectSubState, ["curChar", "grpXSpread", "grpYSpread"])); + FlxG.debugger.track(this); + + camFollow = new FlxObject(0, 0, 1, 1); + add(camFollow); + camFollow.screenCenter(); + + // FlxG.camera.follow(camFollow, LOCKON, 0.01); + FlxG.camera.follow(camFollow, LOCKON); + + var fadeShaderFilter:ShaderFilter = new ShaderFilter(fadeShader); + FlxG.camera.filters = [fadeShaderFilter]; + + var temp:FlxSprite = new FlxSprite(); + temp.loadGraphic(Paths.image('charSelect/placement')); + add(temp); + temp.alpha = 0.0; + + Conductor.stepHit.add(spamOnStep); + // FlxG.debugger.track(temp, "tempBG"); + + transitionGradient = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.flipY = true; + transitionGradient.updateHitbox(); + FlxTween.tween(transitionGradient, {y: -720}, 1, {ease: FlxEase.expoOut}); + add(transitionGradient); + + camFollow.screenCenter(); + camFollow.y -= 150; + fadeShader.fade(0.0, 1.0, 0.8, {ease: FlxEase.quadOut}); + FlxTween.tween(camFollow, {y: camFollow.y + 150}, 1.5, + { + ease: FlxEase.expoOut, + onComplete: function(_) { + autoFollow = true; + FlxG.camera.follow(camFollow, LOCKON, 0.01); + } + }); + + var blackScreen = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, 0xFF000000); + blackScreen.x = -(FlxG.width * 0.5); + blackScreen.y = -(FlxG.height * 0.5); + add(blackScreen); + + introSound = new FunkinSound(); + introSound.loadEmbedded(Paths.sound('CS_Lights')); + introSound.pitch = 1; + introSound.volume = 0; + + FlxG.sound.defaultSoundGroup.add(introSound); + FlxG.sound.list.add(introSound); + + openSubState(new IntroSubState()); + subStateClosed.addOnce((_) -> { + remove(blackScreen); + if (!Save.instance.oldChar) + { + camera.flash(); + + introSound.volume = 1; + introSound.play(true); + } + checkNewChar(); + + Save.instance.oldChar = true; + }); + } + + function checkNewChar():Void + { + if (nonLocks.length > 0) selectTimer.start(2, (_) -> { + unLock(); + }); + else + { + #if FEATURE_NEWGROUNDS + // Make the character unlock medal retroactive. + if (availableChars.size() > 1) Medals.award(CharSelect); + #end + + FunkinSound.playMusic('stayFunky', + { + startingVolume: 1, + overrideExisting: true, + restartTrack: true, + onLoad: function() { + allowInput = true; + + @:privateAccess + gfChill.analyzer = new SpectralAnalyzer(FlxG.sound.music._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end + } + }); + } + } + + var grpIcons:FlxSpriteGroup; + var grpXSpread(default, set):Float = 107; + var grpYSpread(default, set):Float = 127; + var nonLocks = []; + + function initLocks():Void + { + grpIcons = new FlxSpriteGroup(); + add(grpIcons); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSpriteGroup, ["x", "y"])); + // FlxG.debugger.track(grpIcons, "iconGrp"); + + for (i in 0...9) + { + if (availableChars.exists(i) && Save.instance.charactersSeen.contains(availableChars[i])) + { + var path:String = availableChars.get(i); + var temp:PixelatedIcon = new PixelatedIcon(0, 0); + temp.setCharacter(path); + temp.setGraphicSize(128, 128); + temp.updateHitbox(); + temp.ID = 0; + grpIcons.add(temp); + } + else + { + var playableCharacterId:String = availableChars.get(i); + var player:Null = PlayerRegistry.instance.fetchEntry(playableCharacterId); + var isPlayerUnlocked:Bool = player?.isUnlocked() ?? false; + if (availableChars.exists(i) && isPlayerUnlocked) nonLocks.push(i); + + var temp:Lock = new Lock(0, 0, i); + temp.ID = 1; + + // temp.onAnimationComplete.add(function(anim) { + // if (anim == "unlock") playerChill.playAnimation("unlock", true); + // }); + + grpIcons.add(temp); + } + } + + updateIconPositions(); + + grpIcons.scrollFactor.set(); + } + + function unLock():Void + { + var index = nonLocks[0]; + + pressedSelect = true; + + var copy = 3; + + var yThing = -1; + + while ((index + 1) > copy) + { + yThing++; + copy += 3; + } + + var xThing = (copy - index - 2) * -1; + // Look, I'd write better code but I had better aneurysms, my bad - Cheems + cursorY = yThing; + cursorX = xThing; + + selectSound.play(true); + + nonLocks.shift(); + + selectTimer.start(0.5, function(_) { + var lock:Lock = cast grpIcons.group.members[index]; + + lock.anim.getFrameLabel("unlockAnim").add(function() { + playerChillOut.playAnimation("death"); + }); + + lock.playAnimation("unlock"); + + unlockSound.volume = 0.7; + unlockSound.play(true); + + syncLock = lock; + + sync = true; + + lock.onAnimationComplete.addOnce(function(_) { + syncLock = null; + var char = availableChars.get(index); + camera.flash(0xFFFFFFFF, 0.1); + playerChill.playAnimation("unlock"); + playerChill.visible = true; + + var id = grpIcons.members.indexOf(lock); + + nametag.switchChar(char); + gfChill.switchGF(char); + gfChill.visible = true; + + var icon = new PixelatedIcon(0, 0); + icon.setCharacter(char); + icon.setGraphicSize(128, 128); + icon.updateHitbox(); + grpIcons.insert(id, icon); + grpIcons.remove(lock, true); + icon.ID = 0; + + bopPlay = true; + + updateIconPositions(); + playerChillOut.onAnimationComplete.addOnce((_) -> if (_ == "death") + { + // sync = false; + playerChillOut.visible = false; + playerChillOut.switchChar(char); + }); + + #if FEATURE_NEWGROUNDS + // Grant the medal when the player unlocks a character. + Medals.award(CharSelect); + #end + + Save.instance.addCharacterSeen(char); + if (nonLocks.length == 0) + { + pressedSelect = false; + @:bypassAccessor curChar = char; + + staticSound.stop(); + + FunkinSound.playMusic('stayFunky', + { + startingVolume: 1, + overrideExisting: true, + restartTrack: true, + onLoad: function() { + allowInput = true; + + @:privateAccess + gfChill.analyzer = new SpectralAnalyzer(FlxG.sound.music._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end + } + }); + } + else + playerChill.onAnimationComplete.addOnce((_) -> unLock()); + }); + + playerChill.visible = false; + playerChill.switchChar(availableChars[index]); + + playerChillOut.visible = true; + }); + } + + function updateIconPositions() + { + grpIcons.x = 450; + grpIcons.y = 120; + for (index => member in grpIcons.members) + { + var posX:Float = (index % 3); + var posY:Float = Math.floor(index / 3); + + member.x = posX * grpXSpread; + member.y = posY * grpYSpread; + + member.x += grpIcons.x; + member.y += grpIcons.y; + } + } + + var sync:Bool = false; + var syncLock:Lock = null; + var audioBizz:Float = 0; + + function syncAudio(elapsed:Float):Void + { + @:privateAccess + if (sync && unlockSound.time > 0) + { + // if (playerChillOut.anim.framerate > 0) + // { + // if (syncLock != null) syncLock.anim.framerate = 0; + // playerChillOut.anim.framerate = 0; + // } + + playerChillOut.anim._tick = 0; + if (syncLock != null) syncLock.anim._tick = 0; + + if ((unlockSound.time - audioBizz) >= ((delay) * 100)) + { + if (syncLock != null) syncLock.anim._tick = delay; + + playerChillOut.anim._tick = delay; + audioBizz += delay * 100; + } + } + } + + function goToFreeplay():Void + { + allowInput = false; + autoFollow = false; + + FlxTween.tween(cursor, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorBlue, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorDarkBlue, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorConfirmed, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + + FlxTween.tween(barthing, {y: barthing.y + 80}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(dipshitBacking, {y: dipshitBacking.y + 210}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(chooseDipshit, {y: chooseDipshit.y + 200}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(dipshitBlur, {y: dipshitBlur.y + 220}, 0.8, {ease: FlxEase.backIn}); + for (index => member in grpIcons.members) + { + // member.y += 300; + FlxTween.tween(member, {y: member.y + 300}, 0.8, {ease: FlxEase.backIn}); + } + FlxG.camera.follow(camFollow, LOCKON); + FlxTween.tween(transitionGradient, {y: -150}, 0.8, {ease: FlxEase.backIn}); + fadeShader.fade(1.0, 0, 0.8, {ease: FlxEase.quadIn}); + FlxTween.tween(camFollow, {y: camFollow.y - 150}, 0.8, + { + ease: FlxEase.backIn, + onComplete: function(_) { + FlxG.switchState(FreeplayState.build( + { + { + character: curChar, + fromCharSelect: true + } + })); + } + }); + } + + var holdTmrUp:Float = 0; + var holdTmrDown:Float = 0; + var holdTmrLeft:Float = 0; + var holdTmrRight:Float = 0; + var spamUp:Bool = false; + var spamDown:Bool = false; + var spamLeft:Bool = false; + var spamRight:Bool = false; + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + Conductor.instance.update(); + + if (controls.UI_UP_R || controls.UI_DOWN_R || controls.UI_LEFT_R || controls.UI_RIGHT_R) selectSound.pitch = 1; + + syncAudio(elapsed); + + if (allowInput && !pressedSelect) + { + if (controls.UI_UP) holdTmrUp += elapsed; + if (controls.UI_UP_R) + { + holdTmrUp = 0; + spamUp = false; + } + + if (controls.UI_DOWN) holdTmrDown += elapsed; + if (controls.UI_DOWN_R) + { + holdTmrDown = 0; + spamDown = false; + } + + if (controls.UI_LEFT) holdTmrLeft += elapsed; + if (controls.UI_LEFT_R) + { + holdTmrLeft = 0; + spamLeft = false; + } + + if (controls.UI_RIGHT) holdTmrRight += elapsed; + if (controls.UI_RIGHT_R) + { + holdTmrRight = 0; + spamRight = false; + } + + var initSpam = 0.5; + + if (holdTmrUp >= initSpam) spamUp = true; + if (holdTmrDown >= initSpam) spamDown = true; + if (holdTmrLeft >= initSpam) spamLeft = true; + if (holdTmrRight >= initSpam) spamRight = true; + + if (controls.UI_UP_P) + { + cursorY -= 1; + cursorDenied.visible = false; + + holdTmrUp = 0; + + selectSound.play(true); + } + if (controls.UI_DOWN_P) + { + cursorY += 1; + cursorDenied.visible = false; + holdTmrDown = 0; + selectSound.play(true); + } + if (controls.UI_LEFT_P) + { + cursorX -= 1; + cursorDenied.visible = false; + + holdTmrLeft = 0; + selectSound.play(true); + } + if (controls.UI_RIGHT_P) + { + cursorX += 1; + cursorDenied.visible = false; + holdTmrRight = 0; + selectSound.play(true); + } + } + + if (cursorX < -1) + { + cursorX = 1; + } + if (cursorX > 1) + { + cursorX = -1; + } + if (cursorY < -1) + { + cursorY = 1; + } + if (cursorY > 1) + { + cursorY = -1; + } + + if (availableChars.exists(getCurrentSelected()) && Save.instance.charactersSeen.contains(availableChars[getCurrentSelected()])) + { + curChar = availableChars.get(getCurrentSelected()); + + if (allowInput && !pressedSelect && controls.ACCEPT) + { + spamUp = false; + spamDown = false; + spamLeft = false; + spamRight = false; + + cursorConfirmed.visible = true; + cursorConfirmed.animation.play("idle", true); + + grpCursors.visible = false; + + FlxG.sound.play(Paths.sound('CS_confirm')); + + FlxTween.tween(FlxG.sound.music, {pitch: 0.1}, 1, {ease: FlxEase.quadInOut}); + FlxTween.tween(FlxG.sound.music, {volume: 0.0}, 1.5, {ease: FlxEase.quadInOut}); + playerChill.playAnimation("select"); + gfChill.playAnimation("confirm", true, false, true); + pressedSelect = true; + selectTimer.start(1.5, (_) -> { + // pressedSelect = false; + // FlxG.switchState(FreeplayState.build( + // { + // { + // character: curChar + // } + // })); + goToFreeplay(); + }); + } + + if (allowInput && pressedSelect && controls.BACK) + { + cursorConfirmed.visible = false; + grpCursors.visible = true; + + FlxTween.globalManager.cancelTweensOf(FlxG.sound.music); + FlxTween.tween(FlxG.sound.music, {pitch: 1.0, volume: 1.0}, 1, {ease: FlxEase.quartInOut}); + playerChill.playAnimation("deselect"); + gfChill.playAnimation("deselect"); + pressedSelect = false; + FlxTween.tween(FlxG.sound.music, {pitch: 1.0}, 1, + { + ease: FlxEase.quartInOut, + onComplete: (_) -> { + if (playerChill.getCurrentAnimation() == "deselect loop start" || playerChill.getCurrentAnimation() == "deselect") + { + playerChill.playAnimation("idle", true, false, true); + gfChill.playAnimation("idle", true, false, true); + } + } + }); + selectTimer.cancel(); + } + } + else + { + curChar = "locked"; + + gfChill.visible = false; + + if (allowInput && controls.ACCEPT) + { + cursorDenied.visible = true; + + playerChill.playAnimation("cannot select Label", true); + + lockedSound.play(true); + + cursorDenied.animation.play("idle", true); + cursorDenied.animation.finishCallback = (_) -> { + cursorDenied.visible = false; + }; + } + } + + updateLockAnims(); + + if (autoFollow == true) + { + camFollow.screenCenter(); + camFollow.x += cursorX * 10; + camFollow.y += cursorY * 10; + } + + cursorLocIntended.x = (cursorFactor * cursorX) + (FlxG.width / 2) - cursor.width / 2; + cursorLocIntended.y = (cursorFactor * cursorY) + (FlxG.height / 2) - cursor.height / 2; + + cursorLocIntended.x += cursorOffsetX; + cursorLocIntended.y += cursorOffsetY; + + cursor.x = MathUtil.smoothLerp(cursor.x, cursorLocIntended.x, elapsed, 0.1); + cursor.y = MathUtil.smoothLerp(cursor.y, cursorLocIntended.y, elapsed, 0.1); + + cursorBlue.x = MathUtil.coolLerp(cursorBlue.x, cursor.x, lerpAmnt * 0.4); + cursorBlue.y = MathUtil.coolLerp(cursorBlue.y, cursor.y, lerpAmnt * 0.4); + + cursorDarkBlue.x = MathUtil.coolLerp(cursorDarkBlue.x, cursorLocIntended.x, lerpAmnt * 0.2); + cursorDarkBlue.y = MathUtil.coolLerp(cursorDarkBlue.y, cursorLocIntended.y, lerpAmnt * 0.2); + + cursorConfirmed.x = cursor.x - 2; + cursorConfirmed.y = cursor.y - 4; + + cursorDenied.x = cursor.x - 2; + cursorDenied.y = cursor.y - 4; + } + + var bopTimer:Float = 0; + var delay = 1 / 24; + var bopFr = 0; + var bopPlay:Bool = false; + var bopRefX:Float = 0; + var bopRefY:Float = 0; + + function doBop(icon:PixelatedIcon, elapsed:Float):Void + { + if (bopFr >= bopInfo.frames.length) + { + bopRefX = 0; + bopRefY = 0; + bopPlay = false; + bopFr = 0; + return; + } + bopTimer += elapsed; + + if (bopTimer >= delay) + { + bopTimer -= bopTimer; + + var refFrame = bopInfo.frames[bopInfo.frames.length - 1]; + var curFrame = bopInfo.frames[bopFr]; + if (bopFr >= 13) icon.filters = selectedBizz; + + var scaleXDiff:Float = curFrame.scaleX - refFrame.scaleX; + var scaleYDiff:Float = curFrame.scaleY - refFrame.scaleY; + + icon.scale.set(2.6, 2.6); + icon.scale.add(scaleXDiff, scaleYDiff); + + bopFr++; + } + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + // Dispatch events (like onBeatHit) to props + ScriptEventDispatcher.callEvent(playerChill, event); + ScriptEventDispatcher.callEvent(gfChill, event); + } + + function spamOnStep():Void + { + if (spamUp || spamDown || spamLeft || spamRight) + { + // selectSound.changePitchBySemitone(1); + if (selectSound.pitch > 5) selectSound.pitch = 5; + selectSound.play(true); + + cursorDenied.visible = false; + + if (spamUp) + { + cursorY -= 1; + holdTmrUp = 0; + } + if (spamDown) + { + cursorY += 1; + holdTmrDown = 0; + } + if (spamLeft) + { + cursorX -= 1; + holdTmrLeft = 0; + } + if (spamRight) + { + cursorX += 1; + holdTmrRight = 0; + } + } + } + + private function updateLockAnims():Void + { + for (index => member in grpIcons.group.members) + { + switch (member.ID) + { + case 1: + var lock:Lock = cast member; + if (index == getCurrentSelected()) + { + switch (lock.getCurrentAnimation()) + { + case "idle": + lock.playAnimation("selected"); + case "selected" | "clicked": + if (controls.ACCEPT) lock.playAnimation("clicked", true); + } + } + else + { + lock.playAnimation("idle"); + } + case 0: + var memb:PixelatedIcon = cast member; + + if (index == getCurrentSelected()) + { + // memb.pixels = memb.withDropShadow.clone(); + + if (bopPlay) + { + if (bopRefX == 0) + { + bopRefX = memb.x; + bopRefY = memb.y; + } + doBop(memb, FlxG.elapsed); + } + else + { + memb.filters = selectedBizz; + memb.scale.set(2.6, 2.6); + } + if (pressedSelect && memb.animation.curAnim.name == "idle") memb.animation.play("confirm"); + if (autoFollow && !pressedSelect && memb.animation.curAnim.name != "idle") + { + memb.animation.play("confirm", false, true); + member.animation.finishCallback = (_) -> { + member.animation.play("idle"); + member.animation.finishCallback = null; + }; + } + } + else + { + // memb.pixels = memb.noDropShadow.clone(); + memb.filters = null; + memb.scale.set(2, 2); + } + } + } + } + + function getCurrentSelected():Int + { + var tempX:Int = cursorX + 1; + var tempY:Int = cursorY + 1; + var gridPosition:Int = tempX + tempY * 3; + return gridPosition; + } + + function set_curChar(value:String):String + { + if (curChar == value) return value; + + curChar = value; + + if (value == "locked") staticSound.play(); + else + staticSound.stop(); + + nametag.switchChar(value); + gfChill.visible = false; + playerChill.visible = false; + playerChillOut.visible = true; + playerChillOut.playAnimation("slideout"); + var index = playerChillOut.anim.getFrameLabel("slideout").index; + playerChillOut.onAnimationFrame.removeAll(); + playerChillOut.onAnimationFrame.add((_, frame:Int) -> { + if (frame >= index + 1) + { + playerChill.visible = true; + playerChill.switchChar(value); + gfChill.switchGF(value); + gfChill.visible = true; + } + if (frame >= index + 2) + { + playerChillOut.switchChar(value); + playerChillOut.visible = false; + playerChillOut.onAnimationFrame.removeAll(); + } + }); + + return value; + } + + function set_grpXSpread(value:Float):Float + { + grpXSpread = value; + updateIconPositions(); + return value; + } + + function set_grpYSpread(value:Float):Float + { + grpYSpread = value; + updateIconPositions(); + return value; + } +} diff --git a/source/funkin/ui/charSelect/CharacterUnlockState.hx b/source/funkin/ui/charSelect/CharacterUnlockState.hx new file mode 100644 index 00000000..730bd6c2 --- /dev/null +++ b/source/funkin/ui/charSelect/CharacterUnlockState.hx @@ -0,0 +1,126 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; +import flixel.FlxState; +import flixel.group.FlxSpriteGroup; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.components.HealthIcon; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.ui.mainmenu.MainMenuState; + +using flixel.util.FlxSpriteUtil; + +/** + * When you want the player to unlock a character, call `CharacterUnlockState.unlock(characterName)`. + * It handles both the act of unlocking the character and displaying the dialog. + */ +class CharacterUnlockState extends MusicBeatState +{ + public var targetCharacterId:String = ""; + public var targetCharacterData:Null; + + var nextState:FlxState; + + static final DIALOG_BG_COLOR:FlxColor = 0xFF000000; // Iconic + static final DIALOG_COLOR:FlxColor = 0xFF4344F6; // Iconic + static final DIALOG_FONT_COLOR:FlxColor = 0xFFFFFFFF; // Iconic + + var busy:Bool = false; + + public function new(targetPlayableCharacter:String, ?nextState:FlxState) + { + super(); + + this.targetCharacterId = targetPlayableCharacter; + this.targetCharacterData = PlayerRegistry.instance.fetchEntry(targetCharacterId); + this.nextState = nextState == null ? new MainMenuState() : nextState; + } + + override function create():Void + { + super.create(); + + handleMusic(); + + bgColor = DIALOG_BG_COLOR; + + var dialogContainer:FlxSpriteGroup = new FlxSpriteGroup(); + add(dialogContainer); + + // Build the graphic for the text... + var charName:String = targetCharacterData != null ? targetCharacterData.getName() : targetCharacterId.toTitleCase(); + // var dialogText:FlxText = new FlxText(0, 0, 0, 'You can now play as $charName.\n\nCheck it out in Freeplay!'); + var dialogText:FlxText = new FlxText(0, 0, 0, 'You can now play as $charName.'); + dialogText.setFormat("VCR OSD Mono", 32, DIALOG_FONT_COLOR, LEFT); + + // THEN we can size the dialog to match... + var dialogBG:FlxSprite = new FlxSprite(0, 0); + dialogBG.makeGraphic(Std.int(dialogText.width + 32), Std.int(dialogText.height + 32), FlxColor.TRANSPARENT); + dialogBG.drawRoundRect(0, 0, dialogBG.width, dialogBG.height, 16, 16, DIALOG_COLOR); + dialogContainer.add(dialogBG); + + dialogBG.screenCenter(XY); + + // THEN we can position the text inside that. + dialogText.x = dialogBG.x + 16; + dialogText.y = dialogBG.y + 16; + dialogContainer.add(dialogText); + + // HealthIcon handles getting the right frames for us, + // but it has a bunch of overhead in it that makes it gross to work with outside the health bar. + var healthIconCharacterId = targetCharacterData.getOwnedCharacterIds()[0]; + var baseCharacter = CharacterDataParser.fetchCharacter(healthIconCharacterId); + var healthIcon:HealthIcon = new HealthIcon(healthIconCharacterId); + @:privateAccess + healthIcon.configure(baseCharacter._data.healthIcon); + healthIcon.autoUpdate = false; + healthIcon.bopEvery = 0; // You can increase this number later once the animation is done. + healthIcon.size.set(0.5, 0.5); + healthIcon.x = dialogBG.x + 390; + healthIcon.y = dialogBG.y + 6; + healthIcon.flipX = true; + healthIcon.snapToTargetSize(); + dialogContainer.add(healthIcon); + + dialogContainer.scale.set(0, 0); + FlxTween.num(0.0, 1.0, 0.75, + { + ease: FlxEase.elasticOut, + }, function(curScale) { + dialogContainer.scale.set(curScale, curScale); + healthIcon.size.set(0.5 * curScale, 0.5 * curScale); + }); + + // performUnlock(); + } + + function handleMusic():Void + { + FlxG.sound.music?.stop(); + FlxG.sound.play(Paths.sound('confirmMenu')); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (controls.ACCEPT || controls.BACK && !busy) + { + busy = true; + startClose(); + } + } + + function startClose():Void + { + // Fade to black, then switch state. + FlxG.camera.fade(FlxColor.BLACK, 0.75, false, () -> { + FlxG.switchState(nextState); + }); + } +} diff --git a/source/funkin/ui/charSelect/IntroSubState.hx b/source/funkin/ui/charSelect/IntroSubState.hx new file mode 100644 index 00000000..51a4a52a --- /dev/null +++ b/source/funkin/ui/charSelect/IntroSubState.hx @@ -0,0 +1,138 @@ +package funkin.ui.charSelect; + +#if html5 +import funkin.graphics.video.FlxVideo; +#end +#if hxvlc +import funkin.graphics.video.FunkinVideoSprite; +#end +import funkin.ui.MusicBeatSubState; +import funkin.save.Save; + +/** + * When you first enter the character select state, it will play an introductory video opening up the lights + */ +class IntroSubState extends MusicBeatSubState +{ + #if html5 + static final LIGHTS_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('introSelect')); + #end + + #if hxvlc + static final LIGHTS_VIDEO_PATH:String = Paths.videos('introSelect'); + #end + + public override function create():Void + { + if (Save.instance.oldChar) + { + onLightsEnd(); + return; + } + // Pause existing music. + if (FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + #if html5 + trace('Playing web video ${LIGHTS_VIDEO_PATH}'); + playVideoHTML5(LIGHTS_VIDEO_PATH); + #end + + #if hxvlc + trace('Playing native video ${LIGHTS_VIDEO_PATH}'); + playVideoNative(LIGHTS_VIDEO_PATH); + #end + + // // Im TOO lazy to even care, so uh, yep + // FlxG.camera.zoom = 0.66666666666666666666666666666667; + // vid.x = -(FlxG.width - (FlxG.width * FlxG.camera.zoom)); + // vid.y = -((FlxG.height - (FlxG.height * FlxG.camera.zoom)) * 0.75); + } + + #if html5 + var vid:FlxVideo; + + function playVideoHTML5(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FlxVideo(filePath); + + vid.scrollFactor.set(); + if (vid != null) + { + vid.zIndex = 0; + + vid.finishCallback = onLightsEnd; + + add(vid); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + #if hxvlc + var vid:FunkinVideoSprite; + + function playVideoNative(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FunkinVideoSprite(0, 0); + + vid.scrollFactor.set(); + + if (vid != null) + { + vid.zIndex = 0; + vid.bitmap.onEndReached.add(onLightsEnd); + + add(vid); + if (vid.load(filePath)) vid.play(); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // if (!introSound.paused) + // { + // #if html5 + // @:privateAccess + // vid.netStream.seek(introSound.time); + // #elseif hxvlc + // vid.bitmap.time = Std.int(introSound.time); + // #end + // } + } + + /** + * When the lights video finishes, it will close the substate + */ + function onLightsEnd():Void + { + if (vid != null) + { + #if hxvlc + vid.stop(); + #end + remove(vid); + vid.destroy(); + vid = null; + } + + FlxG.camera.zoom = 1; + + close(); + } +} diff --git a/source/funkin/ui/charSelect/Lock.hx b/source/funkin/ui/charSelect/Lock.hx new file mode 100644 index 00000000..982145d5 --- /dev/null +++ b/source/funkin/ui/charSelect/Lock.hx @@ -0,0 +1,34 @@ +package funkin.ui.charSelect; + +import flixel.util.FlxColor; +import flxanimate.effects.FlxTint; +import funkin.graphics.adobeanimate.FlxAtlasSprite; + +class Lock extends FlxAtlasSprite +{ + var colors:Array = [ + 0x31F2A5, 0x20ECCD, 0x24D9E8, + 0x20ECCD, 0x20C8D4, 0x209BDD, + 0x209BDD, 0x2362C9, 0x243FB9 + ]; // lock colors, in a nx3 matrix format + + public function new(x:Float = 0, y:Float = 0, index:Int) + { + super(x, y, Paths.animateAtlas("charSelect/lock")); + + var tint:FlxTint = new FlxTint(colors[index], 1); + + var arr:Array = ["lock", "lock top 1", "lock top 2", "lock top 3", "lock base fuck it"]; + + var func = function(name) { + var symbol = anim.symbolDictionary[name]; + if (symbol != null && symbol.timeline.get("color") != null) symbol.timeline.get("color").get(0).colorEffect = tint; + } + for (symbol in arr) + { + func(symbol); + } + + playAnimation("idle"); + } +} diff --git a/source/funkin/ui/charSelect/Nametag.hx b/source/funkin/ui/charSelect/Nametag.hx new file mode 100644 index 00000000..5bdb0e7e --- /dev/null +++ b/source/funkin/ui/charSelect/Nametag.hx @@ -0,0 +1,94 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; +import funkin.graphics.shaders.MosaicEffect; +import flixel.util.FlxTimer; + +class Nametag extends FlxSprite +{ + var midpointX(default, set):Float = 1008; + var midpointY(default, set):Float = 100; + var mosaicShader:MosaicEffect; + + public function new(?x:Float = 0, ?y:Float = 0) + { + super(x, y); + + mosaicShader = new MosaicEffect(); + shader = mosaicShader; + + switchChar("bf"); + } + + public function updatePosition():Void + { + var offsetX:Float = getMidpoint().x - midpointX; + var offsetY:Float = getMidpoint().y - midpointY; + + x -= offsetX; + y -= offsetY; + } + + public function switchChar(str:String):Void + { + shaderEffect(); + + new FlxTimer().start(4 / 30, _ -> { + var path:String = str; + switch str + { + case "bf": + path = "boyfriend"; + } + + loadGraphic(Paths.image('charSelect/' + path + "Nametag")); + updateHitbox(); + scale.x = scale.y = 0.77; + + updatePosition(); + shaderEffect(true); + }); + } + + function shaderEffect(fadeOut:Bool = false):Void + { + if (fadeOut) + { + setBlockTimer(0, 1, 1); + setBlockTimer(1, width / 27, height / 26); + setBlockTimer(2, width / 10, height / 10); + + setBlockTimer(3, 1, 1); + } + else + { + setBlockTimer(0, (width / 10), (height / 10)); + setBlockTimer(1, width / 73, height / 6); + setBlockTimer(2, width / 10, height / 10); + } + } + + function setBlockTimer(frame:Int, ?forceX:Float, ?forceY:Float):Void + { + var daX:Float = forceX ?? 10 * FlxG.random.int(1, 4); + var daY:Float = forceY ?? 10 * FlxG.random.int(1, 4); + + FlxTimer.wait(frame / 30, () -> { + mosaicShader.setBlockSize(daX, daY); + }); + } + + function set_midpointX(val:Float):Float + { + this.midpointX = val; + updatePosition(); + return val; + } + + function set_midpointY(val:Float):Float + { + this.midpointY = val; + updatePosition(); + return val; + } +} diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx new file mode 100644 index 00000000..bf7f13ad --- /dev/null +++ b/source/funkin/ui/credits/CreditsData.hx @@ -0,0 +1,34 @@ +package funkin.ui.credits; + +/** + * The members of the Funkin' Crew, organized by their roles. + */ +typedef CreditsData = +{ + var entries:Array; +} + +/** + * The members of a specific role on the Funkin' Crew. + */ +typedef CreditsDataRole = +{ + @:optional + var header:String; + + @:optional + @:default([]) + var body:Array; + + @:optional + @:default(false) + var appendBackers:Bool; +} + +/** + * A member of a specific person on the Funkin' Crew. + */ +typedef CreditsDataMember = +{ + var line:String; +} diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx new file mode 100644 index 00000000..844d0f4d --- /dev/null +++ b/source/funkin/ui/credits/CreditsDataHandler.hx @@ -0,0 +1,142 @@ +package funkin.ui.credits; + +import funkin.data.JsonFile; + +using StringTools; + +@:nullSafety +class CreditsDataHandler +{ + public static final BACKER_PUBLIC_URL:String = 'https://funkin.me/backers'; + + #if HARDCODED_CREDITS + static final CREDITS_DATA_PATH:String = "assets/exclude/data/credits.json"; + #else + static final CREDITS_DATA_PATH:String = "assets/data/credits.json"; + #end + + public static function debugPrint(data:Null):Void + { + if (data == null) + { + trace('CreditsData(NULL)'); + return; + } + + if (data.entries == null || data.entries.length == 0) + { + trace('CreditsData(EMPTY)'); + return; + } + + var entryCount = data.entries.length; + var lineCount = 0; + for (entry in data.entries) + { + lineCount += entry?.body?.length ?? 0; + } + + trace('CreditsData($entryCount entries containing $lineCount lines)'); + } + + /** + * If for some reason the full credits won't load, + * use this hardcoded data for the original Funkin' Crew. + * + * @return `CreditsData` + */ + public static inline function getFallback():CreditsData + { + return { + entries: [ + { + header: 'Founders', + body: [ + {line: 'ninjamuffin99'}, + {line: 'PhantomArcade'}, + {line: 'Kawai Sprite'}, + {line: 'evilsk8r'}, + ] + } + ] + }; + } + + public static function fetchBackerEntries():Array + { + // TODO: Implement a web request. + // We can't just grab the current Kickstarter data and include it in builds, + // because we don't want to deadname people who haven't logged into the portal yet. + // It can be async and paginated for performance! + return []; + } + + #if HARDCODED_CREDITS + /** + * The data for the credits. + * Hardcoded into game via a macro at compile time. + */ + public static final CREDITS_DATA:Null = #if macro null #else CreditsDataMacro.loadCreditsData() #end; + #else + + /** + * The data for the credits. + * Loaded dynamically from the game folder when needed. + * Nullable because data may fail to parse. + */ + public static var CREDITS_DATA(get, default):Null = null; + + static function get_CREDITS_DATA():Null + { + if (CREDITS_DATA == null) CREDITS_DATA = parseCreditsData(fetchCreditsData()); + + return CREDITS_DATA; + } + + static function fetchCreditsData():funkin.data.JsonFile + { + #if !macro + var rawJson:String = openfl.Assets.getText(CREDITS_DATA_PATH).trim(); + + return { + fileName: CREDITS_DATA_PATH, + contents: rawJson + }; + #else + return { + fileName: CREDITS_DATA_PATH, + contents: null + }; + #end + } + + static function parseCreditsData(file:JsonFile):Null + { + #if !macro + if (file.contents == null) return null; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + trace('[CREDITS] Parsing credits data from ${CREDITS_DATA_PATH}'); + parser.fromJson(file.contents, file.fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, file.fileName); + return null; + } + return parser.value; + #else + return null; + #end + } + + static function printErrors(errors:Array, id:String = ''):Void + { + trace('[CREDITS] Failed to parse credits data: ${id}'); + + for (error in errors) + funkin.data.DataError.printError(error); + } + #end +} diff --git a/source/funkin/ui/credits/CreditsDataMacro.hx b/source/funkin/ui/credits/CreditsDataMacro.hx new file mode 100644 index 00000000..c97770ee --- /dev/null +++ b/source/funkin/ui/credits/CreditsDataMacro.hx @@ -0,0 +1,67 @@ +package funkin.ui.credits; + +#if macro +import haxe.macro.Context; +#end + +@:access(funkin.ui.credits.CreditsDataHandler) +class CreditsDataMacro +{ + public static macro function loadCreditsData():haxe.macro.Expr.ExprOf + { + #if !display + trace('Hardcoding credits data...'); + var json = CreditsDataMacro.fetchJSON(); + + if (json == null) + { + Context.info('[WARN] Could not fetch JSON data for credits.', Context.currentPos()); + return macro $v{CreditsDataHandler.getFallback()}; + } + + var creditsData = CreditsDataMacro.parseJSON(json); + + if (creditsData == null) + { + Context.info('[WARN] Could not parse JSON data for credits.', Context.currentPos()); + return macro $v{CreditsDataHandler.getFallback()}; + } + + CreditsDataHandler.debugPrint(creditsData); + return macro $v{creditsData}; + // return macro $v{null}; + #else + // `#if display` is used for code completion. In this case we return + // a minimal value to keep code completion fast. + return macro $v{CreditsDataHandler.getFallback()}; + #end + } + + #if macro + static function fetchJSON():Null + { + return sys.io.File.getContent(CreditsDataHandler.CREDITS_DATA_PATH); + } + + /** + * Parse the JSON data for the credits. + * + * @param json The string data to parse. + * @return The parsed data. + */ + static function parseJSON(json:String):Null + { + try + { + // TODO: Use something with better validation but that still works at macro time. + return haxe.Json.parse(json); + } + catch (e) + { + trace('[ERROR] Failed to parse JSON data for credits.'); + trace(e); + return null; + } + } + #end +} diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx new file mode 100644 index 00000000..b3798565 --- /dev/null +++ b/source/funkin/ui/credits/CreditsState.hx @@ -0,0 +1,223 @@ +package funkin.ui.credits; + +import flixel.text.FlxText; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import flixel.FlxSprite; +import funkin.ui.mainmenu.MainMenuState; +import flixel.group.FlxSpriteGroup; + +/** + * The state used to display the credits scroll. + */ +class CreditsState extends MusicBeatState +{ + /** + * The height the credits should start at. + * Make this an instanced variable so it gets set by the constructor. + */ + final STARTING_HEIGHT = FlxG.height; + + /** + * The padding on each side of the screen. + */ + static final SCREEN_PAD = 24; + + /** + * The width of the screen the credits should maximally fill up. + * Make this an instanced variable so it gets set by the constructor. + */ + final FULL_WIDTH = FlxG.width - (SCREEN_PAD * 2); + + /** + * The font to use to display the text. + * To use a font from the `assets` folder, use `Paths.font(...)`. + * Choose something that will render Unicode properly. + */ + #if windows + static final CREDITS_FONT = 'Consolas'; + #elseif mac + static final CREDITS_FONT = 'Menlo'; + #else + static final CREDITS_FONT = "Courier New"; + #end + + /** + * The size of the font. + */ + static final CREDITS_FONT_SIZE = 24; + + static final CREDITS_HEADER_FONT_SIZE = 32; + + /** + * The color of the text itself. + */ + static final CREDITS_FONT_COLOR = FlxColor.WHITE; + + /** + * The color of the text's outline. + */ + static final CREDITS_FONT_STROKE_COLOR = FlxColor.BLACK; + + /** + * The speed the credits scroll at, in pixels per second. + */ + static final CREDITS_SCROLL_BASE_SPEED = 100.0; + + /** + * The speed the credits scroll at while the button is held, in pixels per second. + */ + static final CREDITS_SCROLL_FAST_SPEED = CREDITS_SCROLL_BASE_SPEED * 4.0; + + /** + * The actual sprites and text used to display the credits. + */ + var creditsGroup:FlxSpriteGroup; + + var scrollPaused:Bool = false; + + public function new() + { + super(); + } + + public override function create():Void + { + super.create(); + + // Background + var bg = new FlxSprite(Paths.image('menuDesat')); + bg.scrollFactor.x = 0; + bg.scrollFactor.y = 0; + bg.setGraphicSize(Std.int(FlxG.width)); + bg.updateHitbox(); + bg.x = 0; + bg.y = 0; + bg.alpha = 0.1; + bg.visible = true; + bg.color = 0xFFB57EDC; // Lavender + // add(bg); + + // TODO: Once we need to display Kickstarter backers, + // make this use a recycled pool so we don't kill peformance. + creditsGroup = new FlxSpriteGroup(); + creditsGroup.x = SCREEN_PAD; + creditsGroup.y = STARTING_HEIGHT; + + buildCreditsGroup(); + + add(creditsGroup); + + // Music + FunkinSound.playMusic('freeplayRandom', + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: true, + loop: true + }); + FlxG.sound.music.fadeIn(6, 0, 0.8); + } + + function buildCreditsGroup():Void + { + var y:Float = 0; + + for (entry in CreditsDataHandler.CREDITS_DATA.entries) + { + if (entry.header != null) + { + var header = buildCreditsLine(entry.header, y, true, CreditsSide.Left); + header.bold = true; + creditsGroup.add(header); + y += CREDITS_HEADER_FONT_SIZE + (header.textField.numLines * CREDITS_HEADER_FONT_SIZE); + } + + for (line in entry?.body ?? []) + { + var entry = buildCreditsLine(line.line, y, false, CreditsSide.Left); + creditsGroup.add(entry); + y += CREDITS_FONT_SIZE * entry.textField.numLines; + } + + if (entry.appendBackers) + { + var backers = CreditsDataHandler.fetchBackerEntries(); + for (backer in backers) + { + creditsGroup.add(buildCreditsLine(backer, y, false, CreditsSide.Left)); + y += CREDITS_FONT_SIZE; + } + } + + // Padding between each role. + y += CREDITS_FONT_SIZE * 2.5; + } + } + + function buildCreditsLine(text:String, yPos:Float, header:Bool, side:CreditsSide = CreditsSide.Center):FlxText + { + // CreditsSide.Center: Full screen width + // CreditsSide.Left: Left half of screen + // CreditsSide.Right: Right half of screen + var xPos = (side == CreditsSide.Right) ? (FULL_WIDTH / 2) : 0; + var width = (side == CreditsSide.Center) ? FULL_WIDTH : (FULL_WIDTH / 2); + var size = header ? CREDITS_HEADER_FONT_SIZE : CREDITS_FONT_SIZE; + + var creditsLine:FlxText = new FlxText(xPos, yPos, width, text); + creditsLine.setFormat(CREDITS_FONT, size, CREDITS_FONT_COLOR, FlxTextAlign.LEFT, FlxTextBorderStyle.OUTLINE, CREDITS_FONT_STROKE_COLOR, true); + + return creditsLine; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (!scrollPaused) + { + // TODO: Replace with whatever the special note button is. + if (controls.ACCEPT || FlxG.keys.pressed.SPACE) + { + // Move the whole group. + creditsGroup.y -= CREDITS_SCROLL_FAST_SPEED * elapsed; + } + else + { + // Move the whole group. + creditsGroup.y -= CREDITS_SCROLL_BASE_SPEED * elapsed; + } + } + + if (controls.BACK || hasEnded()) + { + exit(); + } + else if (controls.PAUSE) + { + // scrollPaused = !scrollPaused; + } + } + + function hasEnded():Bool + { + return creditsGroup.y < -creditsGroup.height; + } + + function exit():Void + { + FlxG.switchState(() -> new MainMenuState()); + } + + public override function destroy():Void + { + super.destroy(); + } +} + +enum CreditsSide +{ + Left; + Center; + Right; +} diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx new file mode 100644 index 00000000..1b7d990c --- /dev/null +++ b/source/funkin/ui/debug/DebugMenuSubState.hx @@ -0,0 +1,153 @@ +package funkin.ui.debug; + +import flixel.math.FlxPoint; +import flixel.FlxObject; +import flixel.FlxSprite; +import funkin.ui.MusicBeatSubState; +import funkin.audio.FunkinSound; +import funkin.ui.TextMenuList; +import funkin.ui.debug.charting.ChartEditorState; +import funkin.util.logging.CrashHandler; +import flixel.addons.transition.FlxTransitionableState; +import funkin.util.FileUtil; + +class DebugMenuSubState extends MusicBeatSubState +{ + var items:TextMenuList; + + /** + * Camera focus point + */ + var camFocusPoint:FlxObject; + + override function create():Void + { + FlxTransitionableState.skipNextTransIn = true; + super.create(); + + bgColor = 0x00000000; + + // Create an object for the camera to track. + camFocusPoint = new FlxObject(0, 0); + add(camFocusPoint); + + // Follow the camera focus as we scroll. + FlxG.camera.follow(camFocusPoint, null, 0.06); + + // Create the green background. + var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + menuBG.color = 0xFF4CAF50; + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + add(menuBG); + + // Create the list for menu items. + items = new TextMenuList(); + // Move the camera when the menu is scrolled. + items.onChange.add(onMenuChange); + add(items); + + FlxTransitionableState.skipNextTransIn = true; + + // Create each menu item. + // Call onMenuChange when the first item is created to move the camera . + #if FEATURE_CHART_EDITOR + createItem("CHART EDITOR", openChartEditor); + #end + createItem("ANIMATION EDITOR", openAnimationEditor); + #if FEATURE_STAGE_EDITOR + createItem("STAGE EDITOR", openStageEditor); + #end + #if FEATURE_RESULTS_DEBUG + createItem("RESULTS SCREEN DEBUG", openTestResultsScreen); + #end + // createItem("Input Offset Testing", openInputOffsetTesting); + // createItem("CHARACTER SELECT", openCharSelect, true); + // createItem("TEST STICKERS", testStickers); + #if sys + createItem("OPEN CRASH LOG FOLDER", openLogFolder); + #end + onMenuChange(items.members[0]); + FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y + 500)); + } + + function onMenuChange(selected:TextMenuItem) + { + camFocusPoint.setPosition(selected.x + selected.width / 2, selected.y + selected.height / 2); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (controls.BACK) + { + FunkinSound.playOnce(Paths.sound('cancelMenu')); + exitDebugMenu(); + } + } + + function createItem(name:String, callback:Void->Void, fireInstantly = false):TextMenuItem + { + var item = items.createItem(0, 100 + items.length * 100, name, BOLD, callback); + item.fireInstantly = fireInstantly; + item.screenCenter(X); + return item; + } + + function openChartEditor() + { + FlxTransitionableState.skipNextTransIn = true; + + FlxG.switchState(() -> new ChartEditorState()); + } + + function openInputOffsetTesting() + { + openSubState(new funkin.ui.debug.latency.LatencyState()); + trace('Input Offset Testing'); + } + + function openCharSelect() + { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + + function openAnimationEditor() + { + FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState()); + trace('Animation Editor'); + } + + function testStickers() + { + openSubState(new funkin.ui.transition.stickers.StickerSubState({})); + trace('opened stickers'); + } + + function openStageEditor() + { + trace('Stage Editor'); + FlxG.switchState(() -> new funkin.ui.debug.stageeditor.StageEditorState()); + } + + function openTestResultsScreen():Void + { + FlxG.switchState(() -> new funkin.ui.debug.results.ResultsDebugSubState()); + } + + #if sys + function openLogFolder() + { + FileUtil.openFolder(CrashHandler.LOG_FOLDER); + } + #end + + function exitDebugMenu() + { + // TODO: Add a transition? + this.close(); + } +} diff --git a/source/funkin/ui/debug/MemoryCounter.hx b/source/funkin/ui/debug/MemoryCounter.hx new file mode 100644 index 00000000..18b3961b --- /dev/null +++ b/source/funkin/ui/debug/MemoryCounter.hx @@ -0,0 +1,44 @@ +package funkin.ui.debug; + +import funkin.util.MemoryUtil; +import openfl.text.TextFormat; +import openfl.text.TextField; + +class MemoryCounter extends TextField +{ + var memPeak:Float = 0; + + static final BYTES_PER_MEG:Float = 1024 * 1024; + static final ROUND_TO:Float = 1 / 100; + + public function new(x:Float = 10, y:Float = 10, color:Int = 0x000000) + { + super(); + + this.x = x; + this.y = y; + this.width = 500; + this.selectable = false; + this.mouseEnabled = false; + defaultTextFormat = new TextFormat("_sans", 12, color); + text = "RAM: "; + + #if flash + addEventListener(Event.ENTER_FRAME, function(e) { + var time = Lib.getTimer(); + __enterFrame(time - currentTime); + }); + #end + } + + // Event Handlers + @:noCompletion + #if !flash override #end function __enterFrame(deltaTime:Float):Void + { + var mem:Float = Math.fround(MemoryUtil.getMemoryUsed() / BYTES_PER_MEG / ROUND_TO) * ROUND_TO; + + if (mem > memPeak) memPeak = mem; + + text = 'RAM: ${mem}mb / ${memPeak}mb'; + } +} diff --git a/source/funkin/ui/debug/WaveformTestState.hx b/source/funkin/ui/debug/WaveformTestState.hx new file mode 100644 index 00000000..fb5817bc --- /dev/null +++ b/source/funkin/ui/debug/WaveformTestState.hx @@ -0,0 +1,190 @@ +package funkin.ui.debug; + +import flixel.math.FlxRect; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.graphics.rendering.MeshRender; + +class WaveformTestState extends MusicBeatState +{ + public function new() + { + super(); + } + + var waveformData:WaveformData; + var waveformData2:WaveformData; + + var waveformAudio:FunkinSound; + + // var waveformSprite:WaveformSprite; + // var waveformSprite2:WaveformSprite; + var timeMarker:FlxSprite; + + var polygonSprite:MeshRender; + var vertexCount:Int = 3; + + public override function create():Void + { + super.create(); + + var testSprite = new FlxSprite(0, 0); + testSprite.loadGraphic(Paths.image('funkay')); + testSprite.updateHitbox(); + testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(testSprite); + + waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect')); + + waveformData = WaveformDataParser.interpretFlxSound(waveformAudio); + + polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE); + + setPolygonVertices(vertexCount); + add(polygonSprite); + + // waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6")); + // waveformSprite.duration = 5.0 * 160; + // waveformSprite.width = FlxG.width * 160; + // waveformSprite.height = FlxG.height; // / 2; + // waveformSprite.amplitude = 2.0; + // waveformSprite.minWaveformSize = 25; + // waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height); + // add(waveformSprite); + // + // waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0); + // waveformSprite2.width = FlxG.width; + // waveformSprite2.height = FlxG.height / 2; + // waveformSprite2.y = FlxG.height / 2; + // add(waveformSprite2); + + timeMarker = new FlxSprite(0, FlxG.height * 1 / 6); + timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED); + add(timeMarker); + + // drawWaveform(time, duration); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.SPACE) + { + if (waveformAudio.isPlaying) + { + waveformAudio.stop(); + } + else + { + waveformAudio.play(); + } + } + + if (FlxG.keys.justPressed.ENTER) + { + // if (waveformSprite.orientation == HORIZONTAL) + // { + // // waveformSprite.orientation = VERTICAL; + // // waveformSprite2.orientation = VERTICAL; + // } + // else + // { + // // waveformSprite.orientation = HORIZONTAL; + // // waveformSprite2.orientation = HORIZONTAL; + // } + } + + if (waveformAudio.isPlaying) + { + // waveformSprite takes a time in fractional seconds, not milliseconds. + var timeSeconds = waveformAudio.time / 1000; + // waveformSprite.time = timeSeconds; + // waveformSprite2.time = timeSeconds; + } + + if (FlxG.keys.justPressed.UP) + { + vertexCount += 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration += 1.0; + // waveformSprite2.duration += 1.0; + } + if (FlxG.keys.justPressed.DOWN) + { + vertexCount -= 1; + setPolygonVertices(vertexCount); + // waveformSprite.duration -= 1.0; + // waveformSprite2.duration -= 1.0; + } + if (FlxG.keys.justPressed.LEFT) + { + // waveformSprite.time -= 1.0; + // waveformSprite2.time -= 1.0; + } + if (FlxG.keys.justPressed.RIGHT) + { + // waveformSprite.time += 1.0; + // waveformSprite2.time += 1.0; + } + } + + function setPolygonVertices(count:Int) + { + polygonSprite.clear(); + + var size = 100.0; + + // Build a polygon with count vertices. + + var vertices:Array> = []; + + var angle = 0.0; + + for (i in 0...count) + { + var x = Math.cos(angle) * size; + var y = Math.sin(angle) * size; + + vertices.push([x, y]); + + angle += 2 * Math.PI / count; + } + + trace('vertices: ${vertices}'); + + var centerVertex = polygonSprite.build_vertex(0, 0); + var firstVertex = -1; + var lastVertex = -1; + + for (vertex in vertices) + { + var x = vertex[0]; + var y = vertex[1]; + + var newVertex = polygonSprite.build_vertex(x, y); + + if (firstVertex == -1) + { + firstVertex = newVertex; + } + + if (lastVertex != -1) + { + polygonSprite.add_tri(centerVertex, lastVertex, newVertex); + } + + lastVertex = newVertex; + } + + polygonSprite.add_tri(centerVertex, lastVertex, firstVertex); + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/ui/debug/anim/DebugBoundingState.hx b/source/funkin/ui/debug/anim/DebugBoundingState.hx new file mode 100644 index 00000000..cb5528e4 --- /dev/null +++ b/source/funkin/ui/debug/anim/DebugBoundingState.hx @@ -0,0 +1,616 @@ +package funkin.ui.debug.anim; + +import flixel.addons.display.FlxBackdrop; +import flixel.addons.display.FlxGridOverlay; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.FlxState; +import flixel.graphics.frames.FlxFrame; +import flixel.group.FlxGroup; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.util.FlxColor; +import funkin.input.Cursor; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.ui.mainmenu.MainMenuState; +import funkin.util.MouseUtil; +import funkin.util.SerializerUtil; +import funkin.util.SortUtil; +import haxe.ui.components.DropDown; +import haxe.ui.containers.dialogs.CollapsibleDialog; +import haxe.ui.core.Screen; +import haxe.ui.events.UIEvent; +import haxe.ui.RuntimeComponentBuilder; +import lime.utils.Assets as LimeAssets; +import openfl.events.Event; +import openfl.events.IOErrorEvent; +import openfl.geom.Rectangle; +import openfl.net.FileReference; + +using flixel.util.FlxSpriteUtil; + +class DebugBoundingState extends FlxState +{ + /* + TODAY'S TO-DO + - Cleaner UI + */ + var bg:FlxBackdrop; + var fileInfo:FlxText; + + var txtGrp:FlxTypedGroup; + + var hudCam:FlxCamera; + + var curView:ANIMDEBUGVIEW = SPRITESHEET; + + var spriteSheetView:FlxGroup; + var offsetView:FlxGroup; + var dropDownSetup:Bool = false; + + var onionSkinChar:FlxSprite; + var txtOffsetShit:FlxText; + + var offsetEditorDialog:CollapsibleDialog; + var offsetAnimationDropdown:DropDown; + + var haxeUIFocused(get, default):Bool = false; + + var currentAnimationName(get, never):String; + + function get_currentAnimationName():String + { + return offsetAnimationDropdown?.value?.id ?? "idle"; + } + + function get_haxeUIFocused():Bool + { + // get the screen position, according to the HUD camera, temp default to FlxG.camera juuust in case? + var hudMousePos:FlxPoint = FlxG.mouse.getScreenPosition(hudCam ?? FlxG.camera); + return Screen.instance.hasSolidComponentUnderPoint(hudMousePos.x, hudMousePos.y); + } + + override function create() + { + Paths.setCurrentLevel('week1'); + + hudCam = new FlxCamera(); + hudCam.bgColor.alpha = 0; + + bg = new FlxBackdrop(FlxGridOverlay.createGrid(10, 10, FlxG.width, FlxG.height, true, 0xffe7e6e6, 0xffd9d5d5)); + add(bg); + + // we are setting this as the default draw camera only temporarily, to trick haxeui + FlxG.cameras.add(hudCam); + + var str = Paths.xml('ui/animation-editor/offset-editor-view'); + offsetEditorDialog = cast RuntimeComponentBuilder.fromAsset(str); + + // offsetEditorDialog.findComponent("btnViewSpriteSheet").onClick = _ -> curView = SPRITESHEET; + var viewDropdown:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); + viewDropdown.onChange = function(e:UIEvent) { + trace(e.type); + curView = cast e.data.curView; + trace(e.data); + // trace(e.data); + }; + + offsetAnimationDropdown = offsetEditorDialog.findComponent("animationDropdown", DropDown); + + offsetEditorDialog.cameras = [hudCam]; + + add(offsetEditorDialog); + offsetEditorDialog.showDialog(false); + + // Anchor to the left side by default + offsetEditorDialog.x = 16; + offsetEditorDialog.y = 16; + + // sets the default camera back to FlxG.camera, since we set it to hudCamera for haxeui stuf + FlxG.cameras.setDefaultDrawTarget(FlxG.camera, true); + FlxG.cameras.setDefaultDrawTarget(hudCam, false); + + initSpritesheetView(); + initOffsetView(); + + Cursor.show(); + + super.create(); + } + + var bf:FlxSprite; + var swagOutlines:FlxSprite; + + function initSpritesheetView():Void + { + spriteSheetView = new FlxGroup(); + add(spriteSheetView); + + var tex = Paths.getSparrowAtlas('characters/BOYFRIEND'); + // tex.frames[0].uv + + bf = new FlxSprite(); + bf.loadGraphic(tex.parent); + spriteSheetView.add(bf); + + swagOutlines = new FlxSprite().makeGraphic(tex.parent.width, tex.parent.height, FlxColor.TRANSPARENT); + + generateOutlines(tex.frames); + + txtGrp = new FlxTypedGroup(); + txtGrp.cameras = [hudCam]; + spriteSheetView.add(txtGrp); + + addInfo('boyfriend.xml', ""); + addInfo('Width', bf.width); + addInfo('Height', bf.height); + + spriteSheetView.add(swagOutlines); + } + + function generateOutlines(frameShit:Array):Void + { + // swagOutlines.width = frameShit[0].parent.width; + // swagOutlines.height = frameShit[0].parent.height; + swagOutlines.pixels.fillRect(new Rectangle(0, 0, swagOutlines.width, swagOutlines.height), 0x00000000); + + for (i in frameShit) + { + var lineStyle:LineStyle = {color: FlxColor.RED, thickness: 2}; + + var uvW:Float = (i.uv.width * i.parent.width) - (i.uv.x * i.parent.width); + var uvH:Float = (i.uv.height * i.parent.height) - (i.uv.y * i.parent.height); + + // trace(Std.int(i.uv.width * i.parent.width)); + swagOutlines.drawRect(i.uv.x * i.parent.width, i.uv.y * i.parent.height, uvW, uvH, FlxColor.TRANSPARENT, lineStyle); + // swagGraphic.setPosition(, ); + // trace(uvH); + } + } + + function initOffsetView():Void + { + offsetView = new FlxGroup(); + add(offsetView); + + onionSkinChar = new FlxSprite().makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.TRANSPARENT); + onionSkinChar.visible = false; + offsetView.add(onionSkinChar); + + txtOffsetShit = new FlxText(20, 20, 0, "", 20); + txtOffsetShit.setFormat(Paths.font("vcr.ttf"), 26, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + txtOffsetShit.cameras = [hudCam]; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + offsetView.add(txtOffsetShit); + + var characters:Array = CharacterDataParser.listCharacterIds(); + characters = characters.filter(function(charId:String) { + var char = CharacterDataParser.fetchCharacterData(charId); + return char.renderType != AnimateAtlas; + }); + characters.sort(SortUtil.alphabetically); + + var charDropdown:DropDown = offsetEditorDialog.findComponent('characterDropdown', DropDown); + for (char in characters) + { + charDropdown.dataSource.add({text: char}); + } + + charDropdown.onChange = function(e:UIEvent) { + loadAnimShit(e.data.text); + }; + } + + public var mouseOffset:FlxPoint = FlxPoint.get(0, 0); + public var oldPos:FlxPoint = FlxPoint.get(0, 0); + public var movingCharacter:Bool = false; + + function mouseOffsetMovement() + { + if (swagChar != null) + { + if (FlxG.mouse.justPressed && !haxeUIFocused) + { + movingCharacter = true; + mouseOffset.set(FlxG.mouse.x - -swagChar.animOffsets[0], FlxG.mouse.y - -swagChar.animOffsets[1]); + } + + if (!movingCharacter) return; + + if (FlxG.mouse.pressed) + { + swagChar.animOffsets = [(FlxG.mouse.x - mouseOffset.x) * -1, (FlxG.mouse.y - mouseOffset.y) * -1]; + + swagChar.animationOffsets.set(offsetAnimationDropdown.value.id, swagChar.animOffsets); + + txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + } + + if (FlxG.mouse.justReleased) + { + movingCharacter = false; + } + + if (FlxG.mouse.justReleased) + { + movingCharacter = false; + } + } + } + + function addInfo(str:String, value:Dynamic) + { + var swagText:FlxText = new FlxText(10, FlxG.height - 32); + swagText.setFormat(Paths.font("vcr.ttf"), 26, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + swagText.scrollFactor.set(); + + for (text in txtGrp.members) + { + text.y -= swagText.height; + } + txtGrp.add(swagText); + + swagText.text = str + ": " + Std.string(value); + } + + function clearInfo() + { + txtGrp.clear(); + } + + function checkLibrary(library:String) + { + trace(Assets.hasLibrary(library)); + if (Assets.getLibrary(library) == null) + { + @:privateAccess + if (!LimeAssets.libraryPaths.exists(library)) throw "Missing library: " + library; + + // var callback = callbacks.add("library:" + library); + Assets.loadLibrary(library).onComplete(function(_) { + trace('LOADED... awesomeness...'); + // callback(); + }); + } + } + + override function update(elapsed:Float) + { + if (FlxG.keys.justPressed.ONE) + { + var lv:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); + lv.selectedIndex = 0; + curView = SPRITESHEET; + } + + if (FlxG.keys.justReleased.TWO) + { + var lv:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); + lv.selectedIndex = 1; + curView = ANIMATIONS; + if (swagChar != null) + { + FlxG.camera.focusOn(swagChar.getMidpoint()); + FlxG.camera.zoom = 0.95; + } + } + + switch (curView) + { + case SPRITESHEET: + spriteSheetView.visible = true; + offsetView.visible = false; + offsetView.active = false; + offsetAnimationDropdown.visible = false; + case ANIMATIONS: + spriteSheetView.visible = false; + offsetView.visible = true; + offsetView.active = true; + offsetAnimationDropdown.visible = true; + offsetControls(); + mouseOffsetMovement(); + } + + if (FlxG.keys.justPressed.H) hudCam.visible = !hudCam.visible; + + if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); + + MouseUtil.mouseCamDrag(); + if (!haxeUIFocused) MouseUtil.mouseWheelZoom(); + + // bg.scale.x = FlxG.camera.zoom; + // bg.scale.y = FlxG.camera.zoom; + + bg.setGraphicSize(Std.int(bg.width / FlxG.camera.zoom)); + + super.update(elapsed); + } + + function offsetControls():Void + { + if (FlxG.keys.justPressed.RBRACKET || FlxG.keys.justPressed.E) + { + if (offsetAnimationDropdown.selectedIndex + 1 <= offsetAnimationDropdown.dataSource.size) + { + offsetAnimationDropdown.selectedIndex += 1; + } + else + { + offsetAnimationDropdown.selectedIndex = 0; + } + trace(offsetAnimationDropdown.selectedIndex); + trace(offsetAnimationDropdown.dataSource.size); + trace(offsetAnimationDropdown.value); + trace(currentAnimationName); + playCharacterAnimation(currentAnimationName, true); + } + if (FlxG.keys.justPressed.LBRACKET || FlxG.keys.justPressed.Q) + { + if (offsetAnimationDropdown.selectedIndex - 1 >= 0) + { + offsetAnimationDropdown.selectedIndex -= 1; + } + else + { + offsetAnimationDropdown.selectedIndex = offsetAnimationDropdown.dataSource.size - 1; + } + playCharacterAnimation(currentAnimationName, true); + } + + // Keyboards controls for general WASD "movement" + // modifies the animDrooffsetAnimationDropdownpDownMenu so that it's properly updated and shit + // and then it's just played and updated from the offsetAnimationDropdown callback, which is set in the loadAnimShit() function probabbly + if (FlxG.keys.justPressed.W || FlxG.keys.justPressed.S || FlxG.keys.justPressed.D || FlxG.keys.justPressed.A) + { + var suffix:String = ''; + var targetLabel:String = ''; + + if (FlxG.keys.pressed.SHIFT) suffix = 'miss'; + + if (FlxG.keys.justPressed.W) targetLabel = 'singUP$suffix'; + if (FlxG.keys.justPressed.S) targetLabel = 'singDOWN$suffix'; + if (FlxG.keys.justPressed.A) targetLabel = 'singLEFT$suffix'; + if (FlxG.keys.justPressed.D) targetLabel = 'singRIGHT$suffix'; + + if (targetLabel != currentAnimationName) + { + offsetAnimationDropdown.value = {id: targetLabel, text: targetLabel}; + + // Play the new animation if the IDs are the different. + // Override the onion skin. + playCharacterAnimation(currentAnimationName, true); + } + else + { + // Replay the current animation if the IDs are the same. + // Don't override the onion skin. + playCharacterAnimation(currentAnimationName, false); + } + } + + if (FlxG.keys.justPressed.F) + { + onionSkinChar.visible = !onionSkinChar.visible; + } + + if (FlxG.keys.justPressed.G) + { + swagChar.flipX = !swagChar.flipX; + } + + // Plays the idle animation + if (FlxG.keys.justPressed.SPACE) + { + offsetAnimationDropdown.value = {id: 'idle', text: 'idle'}; + + playCharacterAnimation(currentAnimationName, true); + } + + // Playback the animation + if (FlxG.keys.justPressed.ENTER) + { + playCharacterAnimation(currentAnimationName, false); + } + + if (FlxG.keys.justPressed.RIGHT || FlxG.keys.justPressed.LEFT || FlxG.keys.justPressed.UP || FlxG.keys.justPressed.DOWN) + { + var animName = currentAnimationName; + var coolValues:Array = swagChar.animationOffsets.get(animName).copy(); + + var multiplier:Int = 5; + + if (FlxG.keys.pressed.CONTROL) multiplier = 1; + + if (FlxG.keys.pressed.SHIFT) multiplier = 10; + + if (FlxG.keys.justPressed.RIGHT) coolValues[0] -= 1 * multiplier; + else if (FlxG.keys.justPressed.LEFT) coolValues[0] += 1 * multiplier; + else if (FlxG.keys.justPressed.UP) coolValues[1] += 1 * multiplier; + else if (FlxG.keys.justPressed.DOWN) coolValues[1] -= 1 * multiplier; + + swagChar.animationOffsets.set(currentAnimationName, coolValues); + swagChar.playAnimation(animName); + + txtOffsetShit.text = 'Offset: ' + coolValues; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + + trace(animName); + } + + if (FlxG.keys.justPressed.ESCAPE) + { + var outputString = FlxG.keys.pressed.CONTROL ? buildOutputStringOld() : buildOutputStringNew(); + saveOffsets(outputString, FlxG.keys.pressed.CONTROL ? swagChar.characterId + "Offsets.txt" : swagChar.characterId + ".json"); + } + } + + function buildOutputStringOld():String + { + var outputString:String = ""; + + for (i in swagChar.animationOffsets.keys()) + { + outputString += i + " " + swagChar.animationOffsets.get(i)[0] + " " + swagChar.animationOffsets.get(i)[1] + "\n"; + } + + outputString.trim(); + + return outputString; + } + + function buildOutputStringNew():String + { + var charData:CharacterData = Reflect.copy(swagChar._data); + + for (charDataAnim in charData.animations) + { + var animName:String = charDataAnim.name; + charDataAnim.offsets = swagChar.animationOffsets.get(animName); + } + + return SerializerUtil.toJSON(charData, true); + } + + var swagChar:BaseCharacter; + + /* + Called when animation dropdown is changed! + */ + function loadAnimShit(char:String) + { + if (swagChar != null) + { + offsetView.remove(swagChar); + swagChar.destroy(); + } + + swagChar = CharacterDataParser.fetchCharacter(char); + swagChar.x = 100; + swagChar.y = 100; + swagChar.debug = true; + offsetView.add(swagChar); + + if (swagChar == null || swagChar.frames == null) + { + trace('ERROR: Failed to load character ${char}!'); + } + + generateOutlines(swagChar.frames.frames); + bf.pixels = swagChar.pixels; + + clearInfo(); + addInfo(swagChar._data.assetPath, ""); + addInfo('Width', bf.width); + addInfo('Height', bf.height); + + characterAnimNames = []; + + for (i in swagChar.animationOffsets.keys()) + { + characterAnimNames.push(i); + trace(i); + trace(swagChar.animationOffsets[i]); + } + + offsetAnimationDropdown.dataSource.clear(); + + for (charAnim in characterAnimNames) + { + trace('Adding ${charAnim} to HaxeUI dropdown'); + offsetAnimationDropdown.dataSource.add({id: charAnim, text: charAnim}); + } + + offsetAnimationDropdown.selectedIndex = 0; + + trace('Added ${offsetAnimationDropdown.dataSource.size} to HaxeUI dropdown'); + + offsetAnimationDropdown.onChange = function(event:UIEvent) { + if (event.data != null) + { + trace('Selected animation ${event.data.id}'); + playCharacterAnimation(event.data.id, true); + } + } + + txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + dropDownSetup = true; + } + + private var characterAnimNames:Array; + + function playCharacterAnimation(str:String, setOnionSkin:Bool = true) + { + if (setOnionSkin) + { + // clears the canvas + onionSkinChar.pixels.fillRect(new Rectangle(0, 0, FlxG.width * 2, FlxG.height * 2), 0x00000000); + + onionSkinChar.stamp(swagChar, Std.int(swagChar.x), Std.int(swagChar.y)); + onionSkinChar.alpha = 0.6; + } + + // var animName = characterAnimNames[Std.parseInt(str)]; + var animName = str; + swagChar.playAnimation(animName, true); // trace(); + trace(swagChar.animationOffsets.get(animName)); + + txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + } + + var _file:FileReference; + + function saveOffsets(saveString:String, fileName:String) + { + if ((saveString != null) && (saveString.length > 0)) + { + _file = new FileReference(); + _file.addEventListener(Event.COMPLETE, onSaveComplete); + _file.addEventListener(Event.CANCEL, onSaveCancel); + _file.addEventListener(IOErrorEvent.IO_ERROR, onSaveError); + _file.save(saveString, fileName); + } + } + + function onSaveComplete(_):Void + { + _file.removeEventListener(Event.COMPLETE, onSaveComplete); + _file.removeEventListener(Event.CANCEL, onSaveCancel); + _file.removeEventListener(IOErrorEvent.IO_ERROR, onSaveError); + _file = null; + FlxG.log.notice("Successfully saved LEVEL DATA."); + } + + /** + * Called when the save file dialog is cancelled. + */ + function onSaveCancel(_):Void + { + _file.removeEventListener(Event.COMPLETE, onSaveComplete); + _file.removeEventListener(Event.CANCEL, onSaveCancel); + _file.removeEventListener(IOErrorEvent.IO_ERROR, onSaveError); + _file = null; + } + + /** + * Called if there is an error while saving the gameplay recording. + */ + function onSaveError(_):Void + { + _file.removeEventListener(Event.COMPLETE, onSaveComplete); + _file.removeEventListener(Event.CANCEL, onSaveCancel); + _file.removeEventListener(IOErrorEvent.IO_ERROR, onSaveError); + _file = null; + FlxG.log.error("Problem saving Level data"); + } +} + +enum abstract ANIMDEBUGVIEW(String) +{ + var SPRITESHEET; + var ANIMATIONS; +} diff --git a/source/funkin/ui/debug/anim/FlxAnimateTest.hx b/source/funkin/ui/debug/anim/FlxAnimateTest.hx new file mode 100644 index 00000000..cbf1c433 --- /dev/null +++ b/source/funkin/ui/debug/anim/FlxAnimateTest.hx @@ -0,0 +1,39 @@ +package funkin.ui.debug.anim; + +import flixel.FlxG; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.ui.MusicBeatState; + +/** + * A simple test of FlxAnimate. + * Delete this later? + */ +class FlxAnimateTest extends MusicBeatState +{ + var sprite:FlxAtlasSprite; + + public function new() + { + super(); + this.bgColor = 0xFF999999; + } + + public override function create():Void + { + super.create(); + + sprite = new FlxAtlasSprite(-100, 0, 'week3:assets/week3/images/philly/erect/picoFlowers'); + add(sprite); + sprite.playAnimation(sprite.anim.stageInstance.symbol.name, false, false, true); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.SPACE) ((sprite.anim.isPlaying) ? sprite.anim.pause() : sprite.playAnimation(null, false, false, true)); + + if (FlxG.keys.anyJustPressed([A, LEFT])) sprite.anim.curFrame--; + if (FlxG.keys.anyJustPressed([D, RIGHT])) sprite.anim.curFrame++; + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx new file mode 100644 index 00000000..ea1d21c9 --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -0,0 +1,6540 @@ +package funkin.ui.debug.charting; + +import flixel.addons.display.FlxSliceSprite; +import flixel.addons.display.FlxTiledSprite; +import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.FlxSubState; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup; +import flixel.input.gamepad.FlxGamepadInputID; +import flixel.input.keyboard.FlxKey; +import flixel.input.mouse.FlxMouseEvent; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import flixel.text.FlxText; +import flixel.util.FlxColor; +import flixel.util.FlxSort; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.audio.visualize.PolygonSpectogram; +import funkin.audio.VoicesGroup; +import funkin.audio.waveform.WaveformSprite; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongOffsets; +import funkin.data.song.SongData.NoteParamData; +import funkin.data.song.SongDataUtils; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.input.Cursor; +import funkin.input.TurboButtonHandler; +import funkin.input.TurboKeyHandler; +import funkin.modding.events.ScriptEvent; +import funkin.play.notes.notekind.NoteKindManager; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.components.HealthIcon; +import funkin.play.notes.NoteSprite; +import funkin.play.PlayStatePlaylist; +import funkin.play.song.Song; +import funkin.save.Save; +import funkin.ui.debug.charting.commands.AddEventsCommand; +import funkin.ui.debug.charting.commands.AddNotesCommand; +import funkin.ui.debug.charting.commands.ChartEditorCommand; +import funkin.ui.debug.charting.commands.CopyItemsCommand; +import funkin.ui.debug.charting.commands.CutItemsCommand; +import funkin.ui.debug.charting.commands.DeselectAllItemsCommand; +import funkin.ui.debug.charting.commands.DeselectItemsCommand; +import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; +import funkin.ui.debug.charting.commands.FlipNotesCommand; +import funkin.ui.debug.charting.commands.InvertSelectedItemsCommand; +import funkin.ui.debug.charting.commands.MoveEventsCommand; +import funkin.ui.debug.charting.commands.MoveItemsCommand; +import funkin.ui.debug.charting.commands.MoveNotesCommand; +import funkin.ui.debug.charting.commands.PasteItemsCommand; +import funkin.ui.debug.charting.commands.RemoveEventsCommand; +import funkin.ui.debug.charting.commands.RemoveItemsCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; +import funkin.ui.debug.charting.commands.SelectAllItemsCommand; +import funkin.ui.debug.charting.commands.SelectItemsCommand; +import funkin.ui.debug.charting.commands.SetItemSelectionCommand; +import funkin.ui.debug.charting.components.ChartEditorEventSprite; +import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite; +import funkin.ui.debug.charting.components.ChartEditorMeasureTicks; +import funkin.ui.debug.charting.components.ChartEditorNotePreview; +import funkin.ui.debug.charting.components.ChartEditorNoteSprite; +import funkin.ui.debug.charting.components.ChartEditorPlaybarHead; +import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite; +import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox; +import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox; +import funkin.ui.haxeui.components.CharacterPlayer; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.transition.LoadingState; +import funkin.util.Constants; +import funkin.util.FileUtil; +import funkin.util.MathUtil; +import funkin.util.logging.CrashHandler; +import funkin.util.SortUtil; +import funkin.util.WindowUtil; +import haxe.DynamicAccess; +import haxe.io.Bytes; +import haxe.io.Path; +import haxe.ui.backend.flixel.UIState; +import haxe.ui.components.Button; +import haxe.ui.components.Label; +import haxe.ui.components.Slider; +import haxe.ui.containers.dialogs.CollapsibleDialog; +import haxe.ui.containers.menus.Menu; +import haxe.ui.containers.menus.MenuBar; +import haxe.ui.containers.menus.MenuCheckBox; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Screen; +import haxe.ui.events.DragEvent; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; +import haxe.ui.focus.FocusManager; +import haxe.ui.Toolkit; +import openfl.display.BitmapData; + +using Lambda; + +/** + * A state dedicated to allowing the user to create and edit song charts. + * Built with HaxeUI for use by both developers and modders. + * + * Some functionality is split into handler classes to help maintain my sanity. + * + * @author EliteMasterEric + */ +// @:nullSafety + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/main-view.xml")) +class ChartEditorState extends UIState // UIState derives from MusicBeatState +{ + /** + * CONSTANTS + */ + // ============================== + // Layouts + public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty'); + + public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview'); + public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview'); + public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata'); + public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets'); + public static final CHART_EDITOR_TOOLBOX_NOTE_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/note-data'); + public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/event-data'); + public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay'); + public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties'); + + // Validation + public static final SUPPORTED_MUSIC_FORMATS:Array = #if sys ['ogg'] #else ['mp3'] #end; + + // Layout + + /** + * The base grid size for the chart editor. + */ + public static final GRID_SIZE:Int = 40; + + /** + * The width of the scroll area. + */ + public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = Std.int(GRID_SIZE); + + /** + * The height of the playhead, in pixels. + */ + public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8); + + /** + * The width of the border between grid squares, where the crosshair changes from "Place Notes" to "Select Notes". + */ + public static final GRID_SELECTION_BORDER_WIDTH:Int = 6; + + /** + * The height of the menu bar in the layout. + */ + public static final MENU_BAR_HEIGHT:Int = 32; + + /** + * The height of the playbar in the layout. + */ + public static final PLAYBAR_HEIGHT:Int = 48; + + /** + * The height of the note selection buttons above the grid. + */ + public static final NOTE_SELECT_BUTTON_HEIGHT:Int = 24; + + /** + * The amount of padding between the menu bar and the chart grid when fully scrolled up. + */ + public static final GRID_TOP_PAD:Int = NOTE_SELECT_BUTTON_HEIGHT + 12; + + /** + * The initial vertical position of the chart grid. + */ + public static final GRID_INITIAL_Y_POS:Int = MENU_BAR_HEIGHT + GRID_TOP_PAD; + + /** + * The X position of the note preview area. + */ + public static final NOTE_PREVIEW_X_POS:Int = 320; + + /** + * The Y position of the note preview area. + */ + public static final NOTE_PREVIEW_Y_POS:Int = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 4; + + /** + * The X position of the note grid. + */ + public static var GRID_X_POS(get, never):Float; + + static function get_GRID_X_POS():Float + { + return FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; + } + + // Colors + // Background color tint. + public static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF; + public static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; + public static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F; + public static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; + public static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231; + + // Timings + + /** + * Duration, in seconds, for the scroll easing animation. + */ + public static final SCROLL_EASE_DURATION:Float = 0.4; + + // Other + + /** + * Number of notes in each player's strumline. + */ + public static final STRUMLINE_SIZE:Int = 4; + + /** + * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked. + */ + public static final DRAG_THRESHOLD:Float = 16.0; + + /** + * Precisions of notes you can snap to. + */ + public static final SNAP_QUANTS:Array = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192]; + + /** + * The default note snapping value. + */ + public static final BASE_QUANT:Int = 16; + + /** + * The index of thet default note snapping value in the `SNAP_QUANTS` array. + */ + public static final BASE_QUANT_INDEX:Int = 3; + + /** + * The duration before the welcome music starts to fade back in after the user stops playing music in the chart editor. + */ + public static final WELCOME_MUSIC_FADE_IN_DELAY:Float = 30.0; + + /** + * The duration of the welcome music fade in. + */ + public static final WELCOME_MUSIC_FADE_IN_DURATION:Float = 10.0; + + /** + * A map of the keys for every live input style. + */ + public static final LIVE_INPUT_KEYS:Map> = [ + NumberKeys => [ + FIVE, SIX, SEVEN, EIGHT, + ONE, TWO, THREE, FOUR + ], + WASDKeys => [ + LEFT, DOWN, UP, RIGHT, + A, S, W, D + ], + None => [] + ]; + + /** + * INSTANCE DATA + */ + // ============================== + // Song Length + + /** + * The length of the current instrumental, in milliseconds. + */ + @:isVar var songLengthInMs(get, set):Float = 0; + + function get_songLengthInMs():Float + { + if (songLengthInMs <= 0) return 1000; + return songLengthInMs; + } + + function set_songLengthInMs(value:Float):Float + { + this.songLengthInMs = value; + + updateGridHeight(); + + return this.songLengthInMs; + } + + /** + * The length of the current instrumental, converted to steps. + * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does. + */ + var songLengthInSteps(get, set):Float; + + function get_songLengthInSteps():Float + { + return Conductor.instance.getTimeInSteps(songLengthInMs); + } + + function set_songLengthInSteps(value:Float):Float + { + // Getting a reasonable result from setting songLengthInSteps requires that Conductor.instance.mapBPMChanges be called first. + songLengthInMs = Conductor.instance.getStepTimeInMs(value); + return value; + } + + /** + * The length of the current instrumental, in PIXELS. + * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does. + */ + var songLengthInPixels(get, set):Int; + + function get_songLengthInPixels():Int + { + return Std.int(songLengthInSteps * GRID_SIZE); + } + + function set_songLengthInPixels(value:Int):Int + { + songLengthInSteps = value / GRID_SIZE; + return value; + } + + // Scroll Position + + /** + * The relative scroll position in the song, in pixels. + * One pixel is 1/40 of 1 step, and 1/160 of 1 beat. + */ + var scrollPositionInPixels(default, set):Float = -1.0; + + function set_scrollPositionInPixels(value:Float):Float + { + if (value < 0) + { + // If we're scrolling up, and we hit the top, + // but the playhead is in the middle, move the playhead up. + if (playheadPositionInPixels > 0) + { + var amount:Float = scrollPositionInPixels - value; + playheadPositionInPixels -= amount; + } + + value = 0; + } + + if (value > songLengthInPixels) value = songLengthInPixels; + + if (value == scrollPositionInPixels) return value; + + // Difference in pixels. + var diff:Float = value - scrollPositionInPixels; + + this.scrollPositionInPixels = value; + + // Move the grid sprite to the correct position. + if (gridTiledSprite != null && measureTicks != null) + { + if (isViewDownscroll) + { + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); + measureTicks.y = gridTiledSprite.y; + } + else + { + gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS); + measureTicks.y = gridTiledSprite.y; + + for (member in audioWaveforms.members) + { + member.time = scrollPositionInMs / Constants.MS_PER_SEC; + + // Doing this desyncs the waveforms from the grid. + // member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + } + } + } + + // Move the rendered notes to the correct position. + renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); + renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); + renderedEvents.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); + renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); + // Offset the selection box start position, if we are dragging. + if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff; + + // Update the note preview. + setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + refreshNotePreviewPlayheadPosition(); + + // Update the measure tick display. + if (measureTicks != null) measureTicks.y = gridTiledSprite?.y ?? 0.0; + return this.scrollPositionInPixels; + } + + /** + * The relative scroll position in the song, converted to steps. + * NOT dependant on BPM, because the size of a grid square does not change with BPM. + */ + var scrollPositionInSteps(get, set):Float; + + function get_scrollPositionInSteps():Float + { + return scrollPositionInPixels / GRID_SIZE; + } + + function set_scrollPositionInSteps(value:Float):Float + { + scrollPositionInPixels = value * GRID_SIZE; + return value; + } + + /** + * The relative scroll position in the song, converted to milliseconds. + * DEPENDANT on BPM, because the duration of a grid square changes with BPM. + */ + var scrollPositionInMs(get, set):Float; + + function get_scrollPositionInMs():Float + { + return Conductor.instance.getStepTimeInMs(scrollPositionInSteps); + } + + function set_scrollPositionInMs(value:Float):Float + { + scrollPositionInSteps = Conductor.instance.getTimeInSteps(value); + return value; + } + + // Playhead (on the grid) + + /** + * The position of the playhead, in pixels, relative to the `scrollPositionInPixels`. + * `0` means playhead is at the top of the grid. + * `40` means the playhead is 1 grid length below the base position. + * `-40` means the playhead is 1 grid length above the base position. + */ + var playheadPositionInPixels(default, set):Float = 0.0; + + function set_playheadPositionInPixels(value:Float):Float + { + // Make sure playhead doesn't go outside the song. + if (value + scrollPositionInPixels < 0) value = -scrollPositionInPixels; + if (value + scrollPositionInPixels > songLengthInPixels) value = songLengthInPixels - scrollPositionInPixels; + + this.playheadPositionInPixels = value; + + // Move the playhead sprite to the correct position. + gridPlayhead.y = this.playheadPositionInPixels + GRID_INITIAL_Y_POS; + + updatePlayheadGhostHoldNotes(); + refreshNotePreviewPlayheadPosition(); + + return this.playheadPositionInPixels; + } + + /** + * playheadPosition, converted to steps. + * NOT dependant on BPM, because the size of a grid square does not change with BPM. + */ + var playheadPositionInSteps(get, set):Float; + + function get_playheadPositionInSteps():Float + { + return playheadPositionInPixels / GRID_SIZE; + } + + function set_playheadPositionInSteps(value:Float):Float + { + playheadPositionInPixels = value * GRID_SIZE; + return value; + } + + /** + * playheadPosition, converted to milliseconds. + * DEPENDANT on BPM, because the duration of a grid square changes with BPM. + */ + var playheadPositionInMs(get, set):Float; + + function get_playheadPositionInMs():Float + { + return Conductor.instance.getStepTimeInMs(playheadPositionInSteps); + } + + function set_playheadPositionInMs(value:Float):Float + { + playheadPositionInSteps = Conductor.instance.getTimeInSteps(value); + + return value; + } + + // Playbar (at the bottom) + + /** + * Whether a skip button has been pressed on the playbar, and which one. + * `null` if no button has been pressed. + * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared. + */ + var playbarButtonPressed:Null = null; + + /** + * Whether the head of the playbar is currently being dragged with the mouse by the user. + */ + var playbarHeadDragging:Bool = false; + + /** + * Whether music was playing before we started dragging the playbar head. + * If so, then when we stop dragging the playbar head, we should resume song playback. + */ + var playbarHeadDraggingWasPlaying:Bool = false; + + // Tools Status + + /** + * The note kind to use for notes being placed in the chart. Defaults to `null`. + */ + var noteKindToPlace:Null = null; + + /** + * The note params to use for notes being placed in the chart. Defaults to `[]`. + */ + var noteParamsToPlace:Array = []; + + /** + * The event type to use for events being placed in the chart. Defaults to `''`. + */ + var eventKindToPlace:String = 'FocusCamera'; + + /** + * The event data to use for events being placed in the chart. + */ + var eventDataToPlace:DynamicAccess = {}; + + /** + * The internal index of what note snapping value is in use. + * Increment to make placement more preceise and decrement to make placement less precise. + */ + var noteSnapQuantIndex:Int = BASE_QUANT_INDEX; + + /** + * The current note snapping value. + * For example, `32` when snapping to 32nd notes. + */ + var noteSnapQuant(get, never):Int; + + function get_noteSnapQuant():Int + { + return SNAP_QUANTS[noteSnapQuantIndex]; + } + + /** + * The ratio of the current note snapping value to the default. + * For example, `32` becomes `0.5` when snapping to 16th notes. + */ + var noteSnapRatio(get, never):Float; + + function get_noteSnapRatio():Float + { + return BASE_QUANT / noteSnapQuant; + } + + /** + * The currently selected live input style. + */ + var currentLiveInputStyle:ChartEditorLiveInputStyle = None; + + /** + * If true, playtesting a chart will skip to the current playhead position. + */ + var playtestStartTime:Bool = false; + + /** + * If true, playtesting a chart will let you "gameover" / die when you lose ur health! + */ + var playtestPracticeMode:Bool = false; + + /** + * If true, playtesting a chart will make the computer do it for you! + */ + var playtestBotPlayMode:Bool = false; + + /** + * Enables or disables the "debugger" popup that appears when you run into a flixel error. + */ + var enabledDebuggerPopup:Bool = true; + + /** + * Whether song scripts should be enabled during playtesting. + * You should probably check the box if the song has custom mechanics. + */ + var playtestSongScripts:Bool = true; + + // Visuals + + /** + * Whether the current view is in downscroll mode. + */ + var isViewDownscroll(default, set):Bool = false; + + function set_isViewDownscroll(value:Bool):Bool + { + isViewDownscroll = value; + + // Make sure view is updated when we change view modes. + noteDisplayDirty = true; + notePreviewDirty = true; + notePreviewViewportBoundsDirty = true; + this.scrollPositionInPixels = this.scrollPositionInPixels; + // Characters have probably changed too. + healthIconsDirty = true; + + return isViewDownscroll; + } + + /** + * The current theme used by the editor. + * Dictates the appearance of many UI elements. + * Currently hardcoded to just Light and Dark. + */ + var currentTheme(default, set):ChartEditorTheme = ChartEditorTheme.Light; + + function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme + { + if (value == null || value == currentTheme) return currentTheme; + + currentTheme = value; + this.updateTheme(); + return value; + } + + /** + * The character sprite in the Player Preview window. + * `null` until accessed. + */ + var currentPlayerCharacterPlayer:Null = null; + + /** + * The character sprite in the Opponent Preview window. + * `null` until accessed. + */ + var currentOpponentCharacterPlayer:Null = null; + + // HaxeUI + + /** + * Whether the user is focused on an input in the Haxe UI, and inputs are being fed into it. + * If the user clicks off the input, focus will leave. + */ + var isHaxeUIFocused(get, never):Bool; + + function get_isHaxeUIFocused():Bool + { + return FocusManager.instance.focus != null; + } + + /** + * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI. + * If so, we can ignore certain mouse events underneath. + */ + var isCursorOverHaxeUI(get, never):Bool; + + function get_isCursorOverHaxeUI():Bool + { + return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.viewX, FlxG.mouse.viewY); + } + + /** + * The value of `isCursorOverHaxeUI` from the previous frame. + * This is useful because we may have just clicked a menu item, causing the menu to disappear. + */ + var wasCursorOverHaxeUI:Bool = false; + + /** + * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open. + */ + var isHaxeUIDialogOpen:Bool = false; + + /** + * The Dialog components representing the currently available tool windows. + * Dialogs are retained here even when collapsed or hidden. + */ + var activeToolboxes:Map = new Map(); + + /** + * The camera component we're using for this state. + */ + var uiCamera:FlxCamera; + + // Audio + + /** + * Whether to play a metronome sound while the playhead is moving, and what volume. + */ + var metronomeVolume:Float = 1.0; + + /** + * The volume to play the player's hitsounds at. + */ + var hitsoundVolumePlayer:Float = 1.0; + + /** + * The volume to play the opponent's hitsounds at. + */ + var hitsoundVolumeOpponent:Float = 1.0; + + /** + * Whether hitsounds are enabled for at least one character. + */ + var hitsoundsEnabled(get, never):Bool; + + function get_hitsoundsEnabled():Bool + { + return hitsoundVolumePlayer + hitsoundVolumeOpponent > 0; + } + + // Auto-save + + /** + * A timer used to auto-save the chart after a period of inactivity. + */ + var autoSaveTimer:Null = null; + + // Scrolling + + /** + * Whether the user's last mouse click was on the playhead scroll area. + */ + var gridPlayheadScrollAreaPressed:Bool = false; + + /** + * Where the user's last mouse click was on the note preview scroll area. + * `null` if the user isn't clicking on the note preview. + */ + var notePreviewScrollAreaStartPos:Null = null; + + /** + * The current process that is lerping the scroll position. + */ + var currentScrollEase:Null; + + /** + * The position where the user middle clicked to place a scroll anchor. + * Scroll each frame with speed based on the distance between the mouse and the scroll anchor. + * `null` if no scroll anchor is present. + */ + var scrollAnchorScreenPos:Null = null; + + // Note Placement + + /** + * The SongNoteData which is currently being placed. + * `null` if the user isn't currently placing a note. + * As the user drags, we will update this note's sustain length, and finalize the note when they release. + */ + var currentPlaceNoteData(default, set):Null = null; + + function set_currentPlaceNoteData(value:Null):Null + { + noteDisplayDirty = true; + + return currentPlaceNoteData = value; + } + + /** + * The SongNoteData which is currently being placed, for each column. + * `null` if the user isn't currently placing a note. + * As the user moves down, we will update this note's sustain length, and finalize the note when they release. + */ + var currentLiveInputPlaceNoteData:Array = []; + + // Note Movement + + /** + * The note sprite we are currently moving, if any. + */ + var dragTargetNote:Null = null; + + /** + * The song event sprite we are currently moving, if any. + */ + var dragTargetEvent:Null = null; + + /** + * The amount of vertical steps the note sprite has moved by since the user started dragging. + */ + var dragTargetCurrentStep:Float = 0; + + /** + * The amount of horizontal columns the note sprite has moved by since the user started dragging. + */ + var dragTargetCurrentColumn:Int = 0; + + // Hold Note Dragging + + /** + * The current length of the hold note we are dragging, in steps. + * Play a sound when this value changes. + */ + var dragLengthCurrent:Float = 0; + + /** + * The current length of the hold note we are placing with the playhead, in steps. + * Play a sound when this value changes. + */ + var playheadDragLengthCurrent:Array = []; + + /** + * Flip-flop to alternate between two stretching sounds. + */ + var stretchySounds:Bool = false; + + // Selection + + /** + * The notes which are currently in the user's selection. + */ + var currentNoteSelection(default, set):Array = []; + + function set_currentNoteSelection(value:Array):Array + { + // This value is true if all elements of the current selection are also in the new selection. + var isSuperset:Bool = currentNoteSelection.isSubset(value); + var isEqual:Bool = currentNoteSelection.isEqualUnordered(value); + + currentNoteSelection = value; + + if (!isEqual) + { + if (currentNoteSelection.length > 0 && isSuperset) + { + notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs)); + } + else + { + // The new selection removes elements from the old selection, so we have to redraw the note preview. + notePreviewDirty = true; + } + } + + return currentNoteSelection; + } + + /** + * The events which are currently in the user's selection. + */ + var currentEventSelection:Array = []; + + /** + * The position where the user clicked to start a selection. + * `null` if the user isn't currently selecting anything. + * The selection box extends from this point to the current mouse position. + */ + var selectionBoxStartPos:Null = null; + + // History + + /** + * The list of command previously performed. Used for undoing previous actions. + */ + var undoHistory:Array = []; + + /** + * The list of commands that have been undone. Used for redoing previous actions. + */ + var redoHistory:Array = []; + + // Dirty Flags + + /** + * Whether the note display render group has been modified and needs to be updated. + * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where. + */ + var noteDisplayDirty:Bool = true; + + var noteTooltipsDirty:Bool = true; + + /** + * Whether the selected charactesr have been modified and the health icons need to be updated. + */ + var healthIconsDirty:Bool = true; + + /** + * Whether the note preview graphic needs to be FULLY rebuilt. + */ + var notePreviewDirty(default, set):Bool = true; + + function set_notePreviewDirty(value:Bool):Bool + { + // trace('Note preview dirtied!'); + return notePreviewDirty = value; + } + + var notePreviewViewportBoundsDirty:Bool = true; + + /** + * Whether the chart has been modified since it was last saved. + * Used to determine whether to auto-save, etc. + */ + var saveDataDirty(default, set):Bool = false; + + function set_saveDataDirty(value:Bool):Bool + { + if (value == saveDataDirty) return value; + + if (value) + { + // Start the auto-save timer. + autoSaveTimer = new FlxTimer().start(Constants.AUTOSAVE_TIMER_DELAY_SEC, (_) -> autoSave()); + } + else + { + if (autoSaveTimer != null) + { + // Stop the auto-save timer. + autoSaveTimer.cancel(); + autoSaveTimer.destroy(); + autoSaveTimer = null; + } + } + + saveDataDirty = value; + applyWindowTitle(); + return saveDataDirty; + } + + var shouldShowBackupAvailableDialog(get, set):Bool; + + function get_shouldShowBackupAvailableDialog():Bool + { + return Save.instance.chartEditorHasBackup; + } + + function set_shouldShowBackupAvailableDialog(value:Bool):Bool + { + return Save.instance.chartEditorHasBackup = value; + } + + /** + * A list of previous working file paths. + * Also known as the "recent files" list. + * The first element is [null] if the current working file has not been saved anywhere yet. + */ + public var previousWorkingFilePaths(default, set):Array> = [null]; + + function set_previousWorkingFilePaths(value:Array>):Array> + { + // Called only when the WHOLE LIST is overridden. + previousWorkingFilePaths = value; + applyWindowTitle(); + populateOpenRecentMenu(); + applyCanQuickSave(); + return value; + } + + /** + * The current file path which the chart editor is working with. + * If `null`, the current chart has not been saved yet. + */ + public var currentWorkingFilePath(get, set):Null; + + function get_currentWorkingFilePath():Null + { + return previousWorkingFilePaths[0]; + } + + function set_currentWorkingFilePath(value:Null):Null + { + if (value == previousWorkingFilePaths[0]) return value; + + if (previousWorkingFilePaths.contains(null)) + { + // Filter all instances of `null` from the array. + previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null):Bool { + return x != null; + }); + } + + if (previousWorkingFilePaths.contains(value)) + { + // Move the path to the front of the list. + previousWorkingFilePaths.remove(value); + previousWorkingFilePaths.unshift(value); + } + else + { + // Add the path to the front of the list. + previousWorkingFilePaths.unshift(value); + } + + while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES) + { + // Remove the last path in the list. + previousWorkingFilePaths.pop(); + } + + populateOpenRecentMenu(); + applyWindowTitle(); + + return value; + } + + /** + * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. + * This happens when we add/remove difficulties. + */ + var difficultySelectDirty:Bool = true; + + /** + * Whether the character select view in the toolbox has been modified and needs to be updated. + * This happens when we add/remove characters. + */ + var characterSelectDirty:Bool = true; + + /** + * Whether the player preview toolbox have been modified and need to be updated. + * This happens when we switch characters. + */ + var playerPreviewDirty:Bool = true; + + /** + * Whether the opponent preview toolbox have been modified and need to be updated. + * This happens when we switch characters. + */ + var opponentPreviewDirty:Bool = true; + + /** + * Whether the undo/redo histories have changed since the last time the UI was updated. + */ + var commandHistoryDirty:Bool = true; + + /** + * If true, we are currently in the process of quitting the chart editor. + * Skip any update functions as most of them will call a crash. + */ + var criticalFailure:Bool = false; + + // Input + + /** + * Handler used to track how long the user has been holding the undo keybind. + */ + var undoKeyHandler:TurboKeyHandler = TurboKeyHandler.build([FlxKey.CONTROL, FlxKey.Z]); + + /** + * Variable used to track how long the user has been holding the redo keybind. + */ + var redoKeyHandler:TurboKeyHandler = TurboKeyHandler.build([FlxKey.CONTROL, FlxKey.Y]); + + /** + * Variable used to track how long the user has been holding the up keybind. + */ + var upKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.UP); + + /** + * Variable used to track how long the user has been holding the down keybind. + */ + var downKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.DOWN); + + /** + * Variable used to track how long the user has been holding the W keybind. + */ + var wKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.W); + + /** + * Variable used to track how long the user has been holding the S keybind. + */ + var sKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.S); + + /** + * Variable used to track how long the user has been holding the page-up keybind. + */ + var pageUpKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEUP); + + /** + * Variable used to track how long the user has been holding the page-down keybind. + */ + var pageDownKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEDOWN); + + /** + * Variable used to track how long the user has been holding up on the dpad. + */ + var dpadUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_UP); + + /** + * Variable used to track how long the user has been holding down on the dpad. + */ + var dpadDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_DOWN); + + /** + * Variable used to track how long the user has been holding left on the dpad. + */ + var dpadLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_LEFT); + + /** + * Variable used to track how long the user has been holding right on the dpad. + */ + var dpadRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.DPAD_RIGHT); + + /** + * Variable used to track how long the user has been holding up on the left stick. + */ + var leftStickUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_UP); + + /** + * Variable used to track how long the user has been holding down on the left stick. + */ + var leftStickDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_DOWN); + + /** + * Variable used to track how long the user has been holding left on the left stick. + */ + var leftStickLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_LEFT); + + /** + * Variable used to track how long the user has been holding right on the left stick. + */ + var leftStickRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.LEFT_STICK_DIGITAL_RIGHT); + + /** + * Variable used to track how long the user has been holding up on the right stick. + */ + var rightStickUpGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_UP); + + /** + * Variable used to track how long the user has been holding down on the right stick. + */ + var rightStickDownGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_DOWN); + + /** + * Variable used to track how long the user has been holding left on the right stick. + */ + var rightStickLeftGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_LEFT); + + /** + * Variable used to track how long the user has been holding right on the right stick. + */ + var rightStickRightGamepadHandler:TurboButtonHandler = TurboButtonHandler.build(FlxGamepadInputID.RIGHT_STICK_DIGITAL_RIGHT); + + /** + * AUDIO AND SOUND DATA + */ + // ============================== + + /** + * The chill audio track that plays in the chart editor. + * Plays when the main music is NOT being played. + */ + var welcomeMusic:FunkinSound = new FunkinSound(); + + /** + * The audio track for the instrumental. + * Replaced when switching instrumentals. + * `null` until an instrumental track is loaded. + */ + var audioInstTrack:Null = null; + + /** + * The raw byte data for the instrumental audio tracks. + * Key is the instrumental name. + * `null` until an instrumental track is loaded. + */ + var audioInstTrackData:Map = []; + + /** + * The audio track for the vocals. + * `null` until vocal track(s) are loaded. + * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. + */ + var audioVocalTrackGroup:VoicesGroup = new VoicesGroup(); + + /** + * The audio waveform visualization for the inst/vocals. + * `null` until vocal track(s) are loaded. + * When switching characters, the elements will be swapped to match the new character. + */ + var audioWaveforms:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + /** + * A map of the audio tracks for each character's vocals. + * - Keys are `characterId-variation` (with `characterId` being the default variation). + * - Values are the byte data for the audio track. + */ + var audioVocalTrackData:Map = []; + + /** + * CHART DATA + */ + // ============================== + + /** + * The song metadata. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant metadata, ready to be serialized to JSON. + */ + var songMetadata:Map = []; + + /** + * Retrieves the list of variations for the current song. + */ + var availableVariations(get, never):Array; + + function get_availableVariations():Array + { + var variations:Array = [for (x in songMetadata.keys()) x]; + variations.sort(SortUtil.defaultThenAlphabetically.bind('default')); + return variations; + } + + /** + * Retrieves the list of difficulties for the current variation of the current song. + * ONLY CONTAINS DIFFICULTIES FOR THE CURRENT VARIATION so if on the default variation, erect/nightmare won't be included. + */ + var availableDifficulties(get, never):Array; + + function get_availableDifficulties():Array + { + var m:Null = songMetadata.get(selectedVariation); + return m?.playData?.difficulties ?? [Constants.DEFAULT_DIFFICULTY]; + } + + /** + * Retrieves the list of difficulties for ALL variations of the current song. + */ + var allDifficulties(get, never):Array; + + function get_allDifficulties():Array + { + var result:Array> = [ + for (x in availableVariations) + { + var m:Null = songMetadata.get(x); + m?.playData?.difficulties ?? []; + } + ]; + return result.flatten(); + } + + /** + * The song chart data. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant chart data, ready to be serialized to JSON. + */ + var songChartData:Map = []; + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongMetadata(get, set):SongMetadata; + + function get_currentSongMetadata():SongMetadata + { + var result:Null = songMetadata.get(selectedVariation); + if (result == null) + { + result = new SongMetadata('Default Song Name', Constants.DEFAULT_ARTIST, selectedVariation); + songMetadata.set(selectedVariation, result); + } + return result; + } + + function set_currentSongMetadata(value:SongMetadata):SongMetadata + { + songMetadata.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongChartData(get, set):SongChartData; + + function get_currentSongChartData():SongChartData + { + var result:Null = songChartData.get(selectedVariation); + if (result == null) + { + result = new SongChartData([Constants.DEFAULT_DIFFICULTY => 1.0], [], [Constants.DEFAULT_DIFFICULTY => []]); + songChartData.set(selectedVariation, result); + } + return result; + } + + function set_currentSongChartData(value:SongChartData):SongChartData + { + songChartData.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get (and set) the scroll speed for the current difficulty. + */ + var currentSongChartScrollSpeed(get, set):Float; + + function get_currentSongChartScrollSpeed():Float + { + var result:Null = currentSongChartData.scrollSpeed.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0); + return 1.0; + } + return result; + } + + function set_currentSongChartScrollSpeed(value:Float):Float + { + currentSongChartData.scrollSpeed.set(selectedDifficulty, value); + return value; + } + + /** + * Convenience property to get the note data for the current difficulty. + */ + var currentSongChartNoteData(get, set):Array; + + function get_currentSongChartNoteData():Array + { + var result:Null> = currentSongChartData.notes.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + result = []; + trace('Initializing blank chart for difficulty ' + selectedDifficulty); + currentSongChartData.notes.set(selectedDifficulty, result); + return result; + } + return result; + } + + function set_currentSongChartNoteData(value:Array):Array + { + currentSongChartData.notes.set(selectedDifficulty, value); + return value; + } + + /** + * Convenience property to get the event data for the current difficulty. + */ + var currentSongChartEventData(get, set):Array; + + function get_currentSongChartEventData():Array + { + if (currentSongChartData.events == null) + { + // Initialize to the default value if not set. + currentSongChartData.events = []; + } + return currentSongChartData.events; + } + + function set_currentSongChartEventData(value:Array):Array + { + return currentSongChartData.events = value; + } + + /** + * Convenience property to get the rating for this difficulty in the Freeplay menu. + */ + var currentSongChartDifficultyRating(get, set):Int; + + function get_currentSongChartDifficultyRating():Int + { + var result:Null = currentSongMetadata.playData.ratings.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.ratings.set(selectedDifficulty, 0); + return 0; + } + return result; + } + + function set_currentSongChartDifficultyRating(value:Int):Int + { + currentSongMetadata.playData.ratings.set(selectedDifficulty, value); + return value; + } + + var currentSongNoteStyle(get, set):String; + + function get_currentSongNoteStyle():String + { + if (currentSongMetadata.playData.noteStyle == null + || currentSongMetadata.playData.noteStyle == '' + || currentSongMetadata.playData.noteStyle == 'item') + { + // Initialize to the default value if not set. + currentSongMetadata.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE; + } + return currentSongMetadata.playData.noteStyle; + } + + function set_currentSongNoteStyle(value:String):String + { + return currentSongMetadata.playData.noteStyle = value; + } + + var currentSongFreeplayPreviewStart(get, set):Int; + + function get_currentSongFreeplayPreviewStart():Int + { + return currentSongMetadata.playData.previewStart; + } + + function set_currentSongFreeplayPreviewStart(value:Int):Int + { + return currentSongMetadata.playData.previewStart = value; + } + + var currentSongFreeplayPreviewEnd(get, set):Int; + + function get_currentSongFreeplayPreviewEnd():Int + { + return currentSongMetadata.playData.previewEnd; + } + + function set_currentSongFreeplayPreviewEnd(value:Int):Int + { + return currentSongMetadata.playData.previewEnd = value; + } + + var currentSongStage(get, set):String; + + function get_currentSongStage():String + { + if (currentSongMetadata.playData.stage == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.stage = 'mainStage'; + } + return currentSongMetadata.playData.stage; + } + + function set_currentSongStage(value:String):String + { + return currentSongMetadata.playData.stage = value; + } + + var currentSongName(get, set):String; + + function get_currentSongName():String + { + if (currentSongMetadata.songName == null) + { + // Initialize to the default value if not set. + currentSongMetadata.songName = 'New Song'; + } + return currentSongMetadata.songName; + } + + function set_currentSongName(value:String):String + { + return currentSongMetadata.songName = value; + } + + var currentSongId(get, never):String; + + function get_currentSongId():String + { + return currentSongName.toLowerKebabCase().replace(' ', '-').sanitize(); + } + + var currentSongArtist(get, set):String; + + function get_currentSongArtist():String + { + if (currentSongMetadata.artist == null) + { + // Initialize to the default value if not set. + currentSongMetadata.artist = 'Unknown'; + } + return currentSongMetadata.artist; + } + + function set_currentSongArtist(value:String):String + { + return currentSongMetadata.artist = value; + } + + /** + * Convenience property to get the player charId for the current variation. + */ + var currentPlayerChar(get, set):String; + + function get_currentPlayerChar():String + { + if (currentSongMetadata.playData.characters.player == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.characters.player = Constants.DEFAULT_CHARACTER; + } + return currentSongMetadata.playData.characters.player; + } + + function set_currentPlayerChar(value:String):String + { + return currentSongMetadata.playData.characters.player = value; + } + + /** + * Convenience property to get the opponent charId for the current variation. + */ + var currentOpponentChar(get, set):String; + + function get_currentOpponentChar():String + { + if (currentSongMetadata.playData.characters.opponent == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.characters.opponent = Constants.DEFAULT_CHARACTER; + } + return currentSongMetadata.playData.characters.opponent; + } + + function set_currentOpponentChar(value:String):String + { + return currentSongMetadata.playData.characters.opponent = value; + } + + /** + * Convenience property to get the song offset data for the current variation. + */ + var currentSongOffsets(get, set):SongOffsets; + + function get_currentSongOffsets():SongOffsets + { + if (currentSongMetadata.offsets == null) + { + // Initialize to the default value if not set. + currentSongMetadata.offsets = new SongOffsets(); + } + return currentSongMetadata.offsets; + } + + function set_currentSongOffsets(value:SongOffsets):SongOffsets + { + return currentSongMetadata.offsets = value; + } + + var currentInstrumentalOffset(get, set):Float; + + function get_currentInstrumentalOffset():Float + { + // TODO: Apply for alt instrumentals. + return currentSongOffsets.getInstrumentalOffset(); + } + + function set_currentInstrumentalOffset(value:Float):Float + { + // TODO: Apply for alt instrumentals. + currentSongOffsets.setInstrumentalOffset(value); + return value; + } + + var currentVocalOffsetPlayer(get, set):Float; + + function get_currentVocalOffsetPlayer():Float + { + return currentSongOffsets.getVocalOffset(currentPlayerChar); + } + + function set_currentVocalOffsetPlayer(value:Float):Float + { + currentSongOffsets.setVocalOffset(currentPlayerChar, value); + return value; + } + + var currentVocalOffsetOpponent(get, set):Float; + + function get_currentVocalOffsetOpponent():Float + { + return currentSongOffsets.getVocalOffset(currentOpponentChar); + } + + function set_currentVocalOffsetOpponent(value:Float):Float + { + currentSongOffsets.setVocalOffset(currentOpponentChar, value); + return value; + } + + /** + * The variation ID for the difficulty which is currently being edited. + */ + var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION; + + /** + * Setter called when we are switching variations. + * We will likely need to switch instrumentals as well. + */ + function set_selectedVariation(value:String):String + { + // Don't update if we're already on the variation. + if (selectedVariation == value) return selectedVariation; + selectedVariation = value; + + // Make sure view is updated when the variation changes. + noteDisplayDirty = true; + notePreviewDirty = true; + noteTooltipsDirty = true; + notePreviewViewportBoundsDirty = true; + + switchToCurrentInstrumental(); + postLoadInstrumental(); + + return selectedVariation; + } + + /** + * The difficulty ID for the difficulty which is currently being edited. + */ + var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; + + function set_selectedDifficulty(value:String):String + { + if (value == null) value = availableDifficulties[0] ?? Constants.DEFAULT_DIFFICULTY; + + selectedDifficulty = value; + + // Make sure view is updated when the difficulty changes. + noteDisplayDirty = true; + notePreviewDirty = true; + noteTooltipsDirty = true; + notePreviewViewportBoundsDirty = true; + + return selectedDifficulty; + } + + /** + * The instrumental ID which is currently selected. + */ + var currentInstrumentalId(get, set):String; + + function get_currentInstrumentalId():String + { + var instId:Null = currentSongMetadata.playData.characters.instrumental; + if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation; + return instId; + } + + function set_currentInstrumentalId(value:String):String + { + return currentSongMetadata.playData.characters.instrumental = value; + } + + /** + * HAXEUI COMPONENTS + */ + // ============================== + + /** + * The layout containing the playbar. + * Constructed manually and added to the layout so we can control its position. + */ + var playbarHeadLayout:Null = null; + + // NOTE: All the components below are automatically assigned via HaxeUI macros. + + /** + * The menubar at the top of the screen. + */ + var menubar:MenuBar; + + /** + * The `File -> New Chart` menu item. + */ + var menubarItemNewChart:MenuItem; + + /** + * The `File -> Open Chart` menu item. + */ + var menubarItemOpenChart:MenuItem; + + /** + * The `File -> Open Recent` menu. + */ + var menubarOpenRecent:Menu; + + /** + * The `File -> Save Chart` menu item. + */ + var menubarItemSaveChart:MenuItem; + + /** + * The `File -> Save Chart As` menu item. + */ + var menubarItemSaveChartAs:MenuItem; + + /** + * The `File -> Preferences` menu item. + */ + var menubarItemPreferences:MenuItem; + + /** + * The `File -> Exit` menu item. + */ + var menubarItemExit:MenuItem; + + /** + * The `Edit -> Undo` menu item. + */ + var menubarItemUndo:MenuItem; + + /** + * The `Edit -> Redo` menu item. + */ + var menubarItemRedo:MenuItem; + + /** + * The `Edit -> Cut` menu item. + */ + var menubarItemCut:MenuItem; + + /** + * The `Edit -> Copy` menu item. + */ + var menubarItemCopy:MenuItem; + + /** + * The `Edit -> Paste` menu item. + */ + var menubarItemPaste:MenuItem; + + /** + * The `Edit -> Paste Unsnapped` menu item. + */ + var menubarItemPasteUnsnapped:MenuItem; + + /** + * The `Edit -> Delete` menu item. + */ + var menubarItemDelete:MenuItem; + + /** + * The `Edit -> Flip Notes` menu item. + */ + var menubarItemFlipNotes:MenuItem; + + /** + * The `Edit -> Select All` menu item. + */ + var menubarItemSelectAll:MenuItem; + + /** + * The `Edit -> Select Inverse` menu item. + */ + var menubarItemSelectInverse:MenuItem; + + /** + * The `Edit -> Select None` menu item. + */ + var menubarItemSelectNone:MenuItem; + + /** + * The `Edit -> Select Region` menu item. + */ + var menubarItemSelectRegion:MenuItem; + + /** + * The `Edit -> Select Before Cursor` menu item. + */ + var menubarItemSelectBeforeCursor:MenuItem; + + /** + * The `Edit -> Select After Cursor` menu item. + */ + var menubarItemSelectAfterCursor:MenuItem; + + /** + * The `Edit -> Decrease Note Snap Precision` menu item. + */ + var menuBarItemNoteSnapDecrease:MenuItem; + + /** + * The `Edit -> Decrease Note Snap Precision` menu item. + */ + var menuBarItemNoteSnapIncrease:MenuItem; + + /** + * The `View -> Downscroll` menu item. + */ + var menubarItemDownscroll:MenuCheckBox; + + /** + * The `View -> Increase Difficulty` menu item. + */ + var menubarItemDifficultyUp:MenuItem; + + /** + * The `View -> Decrease Difficulty` menu item. + */ + var menubarItemDifficultyDown:MenuItem; + + /** + * The `Audio -> Play/Pause` menu item. + */ + var menubarItemPlayPause:MenuItem; + + /** + * The `Audio -> Load Instrumental` menu item. + */ + var menubarItemLoadInstrumental:MenuItem; + + /** + * The `Audio -> Load Vocals` menu item. + */ + var menubarItemLoadVocals:MenuItem; + + /** + * The `Audio -> Metronome Volume` label. + */ + var menubarLabelVolumeMetronome:Label; + + /** + * The `Audio -> Metronome Volume` slider. + */ + var menubarItemVolumeMetronome:Slider; + + /** + * The `Audio -> Play Theme Music` menu checkbox. + */ + var menubarItemThemeMusic:MenuCheckBox; + + /** + * The `Audio -> Player Hitsound Volume` label. + */ + var menubarLabelVolumeHitsoundPlayer:Label; + + /** + * The `Audio -> Enemy Hitsound Volume` label. + */ + var menubarLabelVolumeHitsoundOpponent:Label; + + /** + * The `Audio -> Player Hitsound Volume` slider. + */ + var menubarItemVolumeHitsoundPlayer:Slider; + + /** + * The `Audio -> Enemy Hitsound Volume` slider. + */ + var menubarItemVolumeHitsoundOpponent:Slider; + + /** + * The `Audio -> Instrumental Volume` label. + */ + var menubarLabelVolumeInstrumental:Label; + + /** + * The `Audio -> Instrumental Volume` slider. + */ + var menubarItemVolumeInstrumental:Slider; + + /** + * The `Audio -> Player Volume` label. + */ + var menubarLabelVolumeVocalsPlayer:Label; + + /** + * The `Audio -> Enemy Volume` label. + */ + var menubarLabelVolumeVocalsOpponent:Label; + + /** + * The `Audio -> Player Volume` slider. + */ + var menubarItemVolumeVocalsPlayer:Slider; + + /** + * The `Audio -> Enemy Volume` slider. + */ + var menubarItemVolumeVocalsOpponent:Slider; + + /** + * The `Audio -> Playback Speed` label. + */ + var menubarLabelPlaybackSpeed:Label; + + /** + * The `Audio -> Playback Speed` slider. + */ + var menubarItemPlaybackSpeed:Slider; + + /** + * The label by the playbar telling the song position. + */ + var playbarSongPos:Label; + + /** + * The label by the playbar telling the song time remaining. + */ + var playbarSongRemaining:Label; + + /** + * The label by the playbar telling the note snap. + */ + var playbarNoteSnap:Label; + + /** + * The button by the playbar to jump to the start of the song. + */ + var playbarStart:Button; + + /** + * The button by the playbar to jump backwards in the song. + */ + var playbarBack:Button; + + /** + * The button by the playbar to play or pause the song. + */ + var playbarPlay:Button; + + /** + * The button by the playbar to jump forwards in the song. + */ + var playbarForward:Button; + + /** + * The button by the playbar to jump to the end of the song. + */ + var playbarEnd:Button; + + /** + * The button above the grid that selects all notes on the opponent's side. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectOpponent:Button; + + /** + * The button above the grid that selects all notes on the player's side. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectPlayer:Button; + + /** + * The button above the grid that selects all song events. + * Constructed manually and added to the layout so we can control its position. + */ + var buttonSelectEvent:Button; + + /** + * The slider above the grid that sets the volume of the player's sounds. + * Constructed manually and added to the layout so we can control its position. + */ + var sliderVolumePlayer:Slider; + + /** + * The slider above the grid that sets the volume of the opponent's sounds. + * Constructed manually and added to the layout so we can control its position. + */ + var sliderVolumeOpponent:Slider; + + /** + * RENDER OBJECTS + */ + // ============================== + + /** + * The group containing the visulizers! */ + var visulizerGrps:FlxTypedGroup = null; + + /** + * The IMAGE used for the grid. Updated by ChartEditorThemeHandler. + */ + var gridBitmap:Null = null; + + /** + * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler. + * Used two ways: + * 1. A sprite is given this bitmap and placed over selected notes. + * 2. The image is split and used for a 9-slice sprite for the selection box. + */ + var selectionSquareBitmap:Null = null; + + /** + * The IMAGE used for the note preview bitmap. Updated by ChartEditorThemeHandler. + * The image is split and used for a 9-slice sprite for the box over the note preview. + */ + var notePreviewViewportBitmap:Null = null; + + /** + * The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler. + */ + var measureTickBitmap:Null = null; + + /** + * The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler. + */ + var offsetTickBitmap:Null = null; + + /** + * The tiled sprite used to display the grid. + * The height is the length of the song, and scrolling is done by simply the sprite. + */ + var gridTiledSprite:Null = null; + + /** + * The measure ticks area. Includes the numbers and the background sprite. + */ + var measureTicks:Null = null; + + /** + * The playhead representing the current position in the song. + * Can move around on the grid independently of the view. + */ + var gridPlayhead:FlxSpriteGroup = new FlxSpriteGroup(); + + /** + * A sprite used to indicate the note that will be placed on click. + */ + var gridGhostNote:Null = null; + + /** + * A sprite used to indicate the hold note that will be placed on click. + */ + var gridGhostHoldNote:Null = null; + + /** + * A sprite used to indicate the hold note that will be placed on button release. + */ + var gridPlayheadGhostHoldNotes:Array = []; + + /** + * A sprite used to indicate the event that will be placed on click. + */ + var gridGhostEvent:Null = null; + + /** + * The sprite used to display the note preview area. + * We move this up and down to scroll the preview. + */ + var notePreview:Null = null; + + /** + * The rectangular sprite used for representing the current viewport on the note preview. + * We move this up and down and resize it to represent the visible area. + */ + var notePreviewViewport:Null = null; + + /** + * The thin sprite used for representing the playhead on the note preview. + * We move this up and down to represent the current position. + */ + var notePreviewPlayhead:Null = null; + + /** + * The rectangular sprite used for rendering the selection box. + * Uses a 9-slice to stretch the selection box to the correct size without warping. + */ + var selectionBoxSprite:Null = null; + + /** + * The opponent's health icon. + */ + var healthIconDad:Null = null; + + /** + * The player's health icon. + */ + var healthIconBF:Null = null; + + /** + * The text that pop's up when copying something + */ + var txtCopyNotif:Null = null; + + /** + * The purple background sprite. + */ + var menuBG:Null = null; + + /** + * The sprite group containing the note graphics. + * Only displays a subset of the data from `currentSongChartNoteData`, + * and kills notes that are off-screen to be recycled later. + */ + var renderedNotes:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + /** + * The sprite group containing the hold note graphics. + * Only displays a subset of the data from `currentSongChartNoteData`, + * and kills notes that are off-screen to be recycled later. + */ + var renderedHoldNotes:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + /** + * The sprite group containing the song events. + * Only displays a subset of the data from `currentSongChartEventData`, + * and kills events that are off-screen to be recycled later. + */ + var renderedEvents:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + var renderedSelectionSquares:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + /** + * LIFE CYCLE FUNCTIONS + */ + // ============================== + + /** + * The params which were passed in when the Chart Editor was initialized. + */ + var params:Null; + + public function new(?params:ChartEditorParams) + { + super(); + this.params = params; + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + super.dispatchEvent(event); + + // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it. + if (currentPlayerCharacterPlayer != null) + { + switch (event.type) + { + case UPDATE: + currentPlayerCharacterPlayer.onUpdate(cast event); + case SONG_BEAT_HIT: + currentPlayerCharacterPlayer.onBeatHit(cast event); + case SONG_STEP_HIT: + currentPlayerCharacterPlayer.onStepHit(cast event); + case NOTE_HIT: + currentPlayerCharacterPlayer.onNoteHit(cast event); + default: // Continue + } + } + + if (currentOpponentCharacterPlayer != null) + { + switch (event.type) + { + case UPDATE: + currentOpponentCharacterPlayer.onUpdate(cast event); + case SONG_BEAT_HIT: + currentOpponentCharacterPlayer.onBeatHit(cast event); + case SONG_STEP_HIT: + currentOpponentCharacterPlayer.onStepHit(cast event); + case NOTE_HIT: + currentOpponentCharacterPlayer.onNoteHit(cast event); + default: // Continue + } + } + } + + override function create():Void + { + // super.create() must be called first, the HaxeUI components get created here. + super.create(); + // Set the z-index of the HaxeUI. + this.root.zIndex = 100; + + // Get rid of any music from the previous state. + if (FlxG.sound.music != null) FlxG.sound.music.stop(); + + // Play the welcome music. + setupWelcomeMusic(); + + // Show the mouse cursor. + Cursor.show(); + + loadPreferences(); + + uiCamera = new FunkinCamera('chartEditorUI'); + FlxG.cameras.reset(uiCamera); + + buildDefaultSongData(); + + buildBackground(); + + this.updateTheme(); + + buildGrid(); + buildMeasureTicks(); + buildNotePreview(); + + buildAdditionalUI(); + populateOpenRecentMenu(); + this.applyPlatformShortcutText(); + + // Setup the onClick listeners for the UI after it's been created. + setupUIListeners(); + setupContextMenu(); + setupTurboKeyHandlers(); + + setupAutoSave(); + + refresh(); + + if (params != null && params.fnfcTargetPath != null) + { + // Chart editor was opened from the command line. Open the FNFC file now! + var result:Null> = this.loadFromFNFCPath(params.fnfcTargetPath); + if (result != null) + { + if (result.length == 0) + { + this.success('Loaded Chart', 'Loaded chart (${params.fnfcTargetPath})'); + } + else + { + this.warning('Loaded Chart', 'Loaded chart with issues (${params.fnfcTargetPath})\n${result.join("\n")}'); + } + } + else + { + this.error('Failure', 'Failed to load chart (${params.fnfcTargetPath})'); + + // Song failed to load, open the Welcome dialog so we aren't in a broken state. + var welcomeDialog = this.openWelcomeDialog(false); + if (shouldShowBackupAvailableDialog) + { + this.openBackupAvailableDialog(welcomeDialog); + } + } + } + else if (params != null && params.targetSongId != null) + { + this.loadSongAsTemplate(params.targetSongId); + } + else + { + var welcomeDialog = this.openWelcomeDialog(false); + if (shouldShowBackupAvailableDialog) + { + this.openBackupAvailableDialog(welcomeDialog); + } + } + + #if FEATURE_DISCORD_RPC + updateDiscordRPC(); + #end + } + + #if FEATURE_DISCORD_RPC + function updateDiscordRPC():Void + { + funkin.api.discord.DiscordClient.instance.setPresence( + { + // TODO: Make this display the song name and update when it changes. + // state: '${currentSongName} [${selectedDifficulty}]', + state: null, + details: 'Chart Editor [Charting]' + }); + } + #end + + function setupWelcomeMusic() + { + this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop')); + FlxG.sound.list.add(this.welcomeMusic); + this.welcomeMusic.looped = true; + } + + public function loadPreferences():Void + { + var save:Save = Save.instance; + + if (previousWorkingFilePaths[0] == null) + { + previousWorkingFilePaths = [null].concat(save.chartEditorPreviousFiles); + } + else + { + previousWorkingFilePaths = [currentWorkingFilePath].concat(save.chartEditorPreviousFiles); + } + noteSnapQuantIndex = save.chartEditorNoteQuant; + currentLiveInputStyle = save.chartEditorLiveInputStyle; + isViewDownscroll = save.chartEditorDownscroll; + playtestStartTime = save.chartEditorPlaytestStartTime; + currentTheme = save.chartEditorTheme; + metronomeVolume = save.chartEditorMetronomeVolume; + hitsoundVolumePlayer = save.chartEditorHitsoundVolumePlayer; + hitsoundVolumePlayer = save.chartEditorHitsoundVolumeOpponent; + this.welcomeMusic.active = save.chartEditorThemeMusic; + + // audioInstTrack.volume = save.chartEditorInstVolume; + // audioInstTrack.pitch = save.chartEditorPlaybackSpeed; + // audioVocalTrackGroup.volume = save.chartEditorVoicesVolume; + // audioVocalTrackGroup.pitch = save.chartEditorPlaybackSpeed; + } + + public function writePreferences(hasBackup:Bool):Void + { + var save:Save = Save.instance; + + // Can't use filter() because of null safety checking! + var filteredWorkingFilePaths:Array = []; + for (chartPath in previousWorkingFilePaths) + if (chartPath != null) filteredWorkingFilePaths.push(chartPath); + save.chartEditorPreviousFiles = filteredWorkingFilePaths; + + if (hasBackup) trace('Queuing backup prompt for next time!'); + save.chartEditorHasBackup = hasBackup; + + save.chartEditorNoteQuant = noteSnapQuantIndex; + save.chartEditorLiveInputStyle = currentLiveInputStyle; + save.chartEditorDownscroll = isViewDownscroll; + save.chartEditorPlaytestStartTime = playtestStartTime; + save.chartEditorTheme = currentTheme; + save.chartEditorMetronomeVolume = metronomeVolume; + save.chartEditorHitsoundVolumePlayer = hitsoundVolumePlayer; + save.chartEditorHitsoundVolumeOpponent = hitsoundVolumeOpponent; + save.chartEditorThemeMusic = this.welcomeMusic.active; + + // save.chartEditorInstVolume = audioInstTrack.volume; + // save.chartEditorVoicesVolume = audioVocalTrackGroup.volume; + // save.chartEditorPlaybackSpeed = audioInstTrack.pitch; + } + + public function populateOpenRecentMenu():Void + { + if (menubarOpenRecent == null) return; + + #if sys + menubarOpenRecent.removeAllComponents(); + + for (chartPath in previousWorkingFilePaths) + { + if (chartPath == null) continue; + + var menuItemRecentChart:MenuItem = new MenuItem(); + menuItemRecentChart.text = chartPath; + menuItemRecentChart.onClick = function(_event) { + // Load chart from file + var result:Null> = this.loadFromFNFCPath(chartPath); + if (result != null) + { + if (result.length == 0) + { + this.success('Loaded Chart', 'Loaded chart (${chartPath.toString()})'); + } + else + { + this.warning('Loaded Chart', 'Loaded chart with issues (${chartPath.toString()})\n${result.join("\n")}'); + } + } + else + { + this.error('Failure', 'Failed to load chart (${chartPath.toString()})'); + } + } + + if (!FileUtil.fileExists(chartPath)) + { + trace('Previously loaded chart file (${chartPath.toString()}) does not exist, disabling link...'); + menuItemRecentChart.disabled = true; + } + else + { + menuItemRecentChart.disabled = false; + } + + menubarOpenRecent.addComponent(menuItemRecentChart); + } + #else + menubarOpenRecent.hide(); + #end + } + + var bgMusicTimer:FlxTimer; + + function fadeInWelcomeMusic(?extraWait:Float = 0, ?fadeInTime:Float = 5):Void + { + if (!this.welcomeMusic.active) + { + stopWelcomeMusic(); + return; + } + + bgMusicTimer = new FlxTimer().start(extraWait, (_) -> { + this.welcomeMusic.volume = 0; + if (this.welcomeMusic.active) + { + this.welcomeMusic.play(); + this.welcomeMusic.fadeIn(fadeInTime, 0, 1.0); + } + }); + } + + function stopWelcomeMusic():Void + { + if (bgMusicTimer != null) bgMusicTimer.cancel(); + // this.welcomeMusic.fadeOut(4, 0); + this.welcomeMusic.pause(); + } + + function buildDefaultSongData():Void + { + selectedVariation = Constants.DEFAULT_VARIATION; + selectedDifficulty = Constants.DEFAULT_DIFFICULTY; + + // Initialize the song metadata. + songMetadata = new Map(); + + // Initialize the song chart data. + songChartData = new Map(); + } + + /** + * Builds and displays the background sprite. + */ + function buildBackground():Void + { + menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + add(menuBG); + + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + menuBG.zIndex = -100; + } + + var oppSpectogram:PolygonSpectogram; + + /** + * Builds and displays the chart editor grid, including the playhead and cursor. + */ + function buildGrid():Void + { + if (gridBitmap == null) throw 'ERROR: Tried to build grid, but gridBitmap is null! Check ChartEditorThemeHandler.updateTheme().'; + + gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); + gridTiledSprite.x = GRID_X_POS; // Center the grid. + gridTiledSprite.y = GRID_INITIAL_Y_POS; // Push down to account for the menu bar. + add(gridTiledSprite); + gridTiledSprite.zIndex = 10; + + gridGhostNote = new ChartEditorNoteSprite(this); + gridGhostNote.alpha = 0.6; + gridGhostNote.noteData = new SongNoteData(0, 0, 0, "", []); + gridGhostNote.visible = false; + add(gridGhostNote); + gridGhostNote.zIndex = 11; + + gridGhostHoldNote = new ChartEditorHoldNoteSprite(this); + gridGhostHoldNote.alpha = 0.6; + gridGhostHoldNote.noteData = null; + gridGhostHoldNote.visible = false; + add(gridGhostHoldNote); + gridGhostHoldNote.zIndex = 11; + + gridGhostEvent = new ChartEditorEventSprite(this, true); + gridGhostEvent.alpha = 0.6; + gridGhostEvent.eventData = new SongEventData(-1, '', {}); + gridGhostEvent.visible = false; + add(gridGhostEvent); + gridGhostEvent.zIndex = 12; + + buildNoteGroup(); + + // The playhead that show the current position in the song. + add(gridPlayhead); + gridPlayhead.zIndex = 30; + + var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); + var playheadBaseYPos:Float = GRID_INITIAL_Y_POS; + gridPlayhead.setPosition(GRID_X_POS, playheadBaseYPos); + var playheadSprite:FunkinSprite = new FunkinSprite().makeSolidColor(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); + playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; + playheadSprite.y = 0; + gridPlayhead.add(playheadSprite); + + var playheadBlock:FlxSprite = ChartEditorThemeHandler.buildPlayheadBlock(); + playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH; + playheadBlock.y = -PLAYHEAD_HEIGHT / 2; + gridPlayhead.add(playheadBlock); + + // Character icons. + healthIconDad = new HealthIcon(currentSongMetadata.playData.characters.opponent); + healthIconDad.autoUpdate = false; + healthIconDad.size.set(0.5, 0.5); + add(healthIconDad); + healthIconDad.zIndex = 30; + + healthIconBF = new HealthIcon(currentSongMetadata.playData.characters.player); + healthIconBF.autoUpdate = false; + healthIconBF.size.set(0.5, 0.5); + healthIconBF.flipX = true; + add(healthIconBF); + healthIconBF.zIndex = 30; + + add(audioWaveforms); + } + + function buildMeasureTicks():Void + { + measureTicks = new ChartEditorMeasureTicks(this); + var measureTicksWidth = (GRID_SIZE); + measureTicks.x = gridTiledSprite.x - measureTicksWidth; + measureTicks.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; + measureTicks.zIndex = 20; + + add(measureTicks); + } + + function buildNotePreview():Void + { + var playbarHeightWithPad = PLAYBAR_HEIGHT + 10; + var notePreviewHeight:Int = FlxG.height - NOTE_PREVIEW_Y_POS - playbarHeightWithPad; + notePreview = new ChartEditorNotePreview(notePreviewHeight); + notePreview.x = NOTE_PREVIEW_X_POS; + notePreview.y = NOTE_PREVIEW_Y_POS; + add(notePreview); + + if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().'; + + notePreviewViewport.scrollFactor.set(0, 0); + add(notePreviewViewport); + notePreviewViewport.zIndex = 30; + + notePreviewPlayhead = new FlxSprite().makeGraphic(2, 2, 0xFFFF0000); + notePreviewPlayhead.scrollFactor.set(0, 0); + notePreviewPlayhead.scale.set(notePreview.width / 2, 0.5); // Setting width does nothing. + notePreviewPlayhead.updateHitbox(); + notePreviewPlayhead.x = notePreview.x; + notePreviewPlayhead.y = notePreview.y; + add(notePreviewPlayhead); + notePreviewPlayhead.zIndex = 31; + + setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + } + + function setSelectionBoxBounds(bounds:FlxRect = null):Void + { + if (selectionBoxSprite == null) + throw 'ERROR: Tried to set selection box bounds, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().'; + + if (bounds == null) + { + selectionBoxSprite.visible = false; + selectionBoxSprite.x = -9999; + selectionBoxSprite.y = -9999; + } + else + { + selectionBoxSprite.visible = true; + selectionBoxSprite.x = bounds.x; + selectionBoxSprite.y = bounds.y; + selectionBoxSprite.width = bounds.width; + selectionBoxSprite.height = bounds.height; + } + } + + /** + * Automatically goes through and calls render on everything you added. + */ + override public function draw():Void + { + super.draw(); + } + + function calculateNotePreviewViewportBounds():FlxRect + { + var bounds:FlxRect = new FlxRect(); + + // Return 0, 0, 0, 0 if the note preview doesn't exist for some reason. + if (notePreview == null) return bounds; + + // Horizontal position and width are constant. + bounds.x = notePreview.x; + bounds.width = notePreview.width; + + // Vertical position depends on scroll position. + bounds.y = notePreview.y + (notePreview.height * (scrollPositionInPixels / songLengthInPixels)); + + // Height depends on the viewport size. + bounds.height = notePreview.height * (FlxG.height / songLengthInPixels); + + // Make sure the viewport doesn't go off the top or bottom of the note preview. + if (bounds.y < notePreview.y) + { + bounds.height -= notePreview.y - bounds.y; + bounds.y = notePreview.y; + } + else if (bounds.y + bounds.height > notePreview.y + notePreview.height) + { + bounds.height -= (bounds.y + bounds.height) - (notePreview.y + notePreview.height); + } + + var MIN_HEIGHT:Int = 8; + if (bounds.height < MIN_HEIGHT) + { + bounds.y -= MIN_HEIGHT - bounds.height; + bounds.height = MIN_HEIGHT; + } + + // trace('Note preview viewport bounds: ' + bounds.toString()); + + return bounds; + } + + function setNotePreviewViewportBounds(bounds:FlxRect = null):Void + { + if (notePreviewViewport == null) + { + trace('[WARN] Tried to set note preview viewport bounds, but notePreviewViewport is null!'); + return; + } + + if (bounds == null) + { + notePreviewViewport.visible = false; + notePreviewViewport.x = -9999; + notePreviewViewport.y = -9999; + } + else + { + notePreviewViewport.visible = true; + notePreviewViewport.x = bounds.x; + notePreviewViewport.y = bounds.y; + notePreviewViewport.width = bounds.width; + notePreviewViewport.height = bounds.height; + } + } + + function refreshNotePreviewPlayheadPosition():Void + { + if (notePreviewPlayhead == null) return; + + notePreviewPlayhead.y = notePreview.y + (notePreview.height * ((scrollPositionInPixels + playheadPositionInPixels) / songLengthInPixels)); + } + + /** + * Builds the group that will hold all the notes. + */ + function buildNoteGroup():Void + { + if (gridTiledSprite == null) throw 'ERROR: Tried to build note groups, but gridTiledSprite is null! Check ChartEditorState.buildGrid().'; + + renderedHoldNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedHoldNotes); + renderedHoldNotes.zIndex = 24; + + renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedNotes); + renderedNotes.zIndex = 25; + + renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedEvents); + renderedEvents.zIndex = 25; + + renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedSelectionSquares); + renderedSelectionSquares.zIndex = 26; + } + + function buildAdditionalUI():Void + { + playbarHeadLayout = new ChartEditorPlaybarHead(); + + playbarHeadLayout.zIndex = 110; + playbarHeadLayout.width = FlxG.width - 8; + playbarHeadLayout.height = 10; + playbarHeadLayout.x = 4; + playbarHeadLayout.y = FlxG.height - 48 - 8; + + playbarHeadLayout.playbarHead.allowFocus = false; + playbarHeadLayout.playbarHead.width = FlxG.width; + playbarHeadLayout.playbarHead.height = 10; + playbarHeadLayout.playbarHead.styleString = 'padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;'; + playbarHeadLayout.playbarHead.min = 0; + + playbarHeadLayout.playbarHead.onDragStart = function(_:DragEvent) { + playbarHeadDragging = true; + + // If we were dragging the playhead while the song was playing, resume playing. + if (audioInstTrack != null && audioInstTrack.isPlaying) + { + playbarHeadDraggingWasPlaying = true; + stopAudioPlayback(); + } + else + { + playbarHeadDraggingWasPlaying = false; + } + } + + playbarHeadLayout.playbarHead.onDrag = function(d:DragEvent) { + if (playbarHeadDragging) + { + // Update the conductor and audio tracks to match. + currentScrollEase = d.value; + easeSongToScrollPosition(currentScrollEase); + } + } + + playbarHeadLayout.playbarHead.onDragEnd = function(_:DragEvent) { + playbarHeadDragging = false; + + // If we were dragging the playhead while the song was playing, resume playing. + if (playbarHeadDraggingWasPlaying) + { + playbarHeadDraggingWasPlaying = false; + + // Disabled code to resume song playback on drag. + startAudioPlayback(); + } + } + + add(playbarHeadLayout); + + // Little text that shows up when you copy something. + txtCopyNotif = new FlxText(0, 0, 0, '', 24); + txtCopyNotif.setBorderStyle(OUTLINE, 0xFF074809, 1); + txtCopyNotif.color = 0xFF52FF77; + txtCopyNotif.zIndex = 120; + add(txtCopyNotif); + + if (!Preferences.debugDisplay) menubar.paddingLeft = null; + + this.setupNotifications(); + + // Setup character dropdowns. + FlxMouseEvent.add(healthIconDad, function(_) { + if (!isCursorOverHaxeUI) + { + this.openCharacterDropdown(CharacterType.DAD, true); + } + }); + + FlxMouseEvent.add(healthIconBF, function(_) { + if (!isCursorOverHaxeUI) + { + this.openCharacterDropdown(CharacterType.BF, true); + } + }); + + buttonSelectOpponent = new Button(); + buttonSelectOpponent.allowFocus = false; + buttonSelectOpponent.text = "Opponent"; // Default text. + buttonSelectOpponent.x = GRID_X_POS; + buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 8; + buttonSelectOpponent.width = GRID_SIZE * 4; + buttonSelectOpponent.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectOpponent.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection."; + buttonSelectOpponent.zIndex = 110; + add(buttonSelectOpponent); + + buttonSelectOpponent.onClick = (_) -> { + var notesToSelect:Array = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, STRUMLINE_SIZE, STRUMLINE_SIZE * 2 - 1); + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand(notesToSelect, [])); + } + else + { + performCommand(new SetItemSelectionCommand(notesToSelect, [])); + } + } + + buttonSelectPlayer = new Button(); + buttonSelectPlayer.allowFocus = false; + buttonSelectPlayer.text = "Player"; // Default text. + buttonSelectPlayer.x = buttonSelectOpponent.x + buttonSelectOpponent.width; + buttonSelectPlayer.y = buttonSelectOpponent.y; + buttonSelectPlayer.width = GRID_SIZE * 4; + buttonSelectPlayer.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectPlayer.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection."; + buttonSelectPlayer.zIndex = 110; + add(buttonSelectPlayer); + + buttonSelectPlayer.onClick = (_) -> { + var notesToSelect:Array = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, 0, STRUMLINE_SIZE - 1); + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand(notesToSelect, [])); + } + else + { + performCommand(new SetItemSelectionCommand(notesToSelect, [])); + } + } + + buttonSelectEvent = new Button(); + buttonSelectEvent.allowFocus = false; + buttonSelectEvent.icon = Paths.image('ui/chart-editor/events/Default'); + buttonSelectEvent.iconPosition = "top"; + buttonSelectEvent.x = buttonSelectPlayer.x + buttonSelectPlayer.width; + buttonSelectEvent.y = buttonSelectPlayer.y; + buttonSelectEvent.width = GRID_SIZE; + buttonSelectEvent.height = NOTE_SELECT_BUTTON_HEIGHT; + buttonSelectEvent.tooltip = "Click to set selection to all events.\nShift-click to add all events to selection."; + buttonSelectEvent.zIndex = 110; + add(buttonSelectEvent); + + buttonSelectEvent.onClick = (_) -> { + if (FlxG.keys.pressed.SHIFT) + { + performCommand(new SelectItemsCommand([], currentSongChartEventData)); + } + else + { + performCommand(new SetItemSelectionCommand([], currentSongChartEventData)); + } + } + } + + /** + * Sets up the onClick listeners for the UI. + */ + function setupUIListeners():Void + { + // Add functionality to the playbar. + + playbarStart.onClick = _ -> playbarButtonPressed = 'playbarStart'; + playbarBack.onClick = _ -> playbarButtonPressed = 'playbarBack'; + playbarPlay.onClick = _ -> toggleAudioPlayback(); + playbarForward.onClick = _ -> playbarButtonPressed = 'playbarForward'; + playbarEnd.onClick = _ -> playbarButtonPressed = 'playbarEnd'; + + // Cycle note snap quant. + playbarNoteSnap.onRightClick = _ -> { + noteSnapQuantIndex--; + if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1; + }; + playbarNoteSnap.onClick = _ -> { + if (FlxG.keys.pressed.SHIFT) + { + noteSnapQuantIndex = BASE_QUANT_INDEX; + } + else + { + noteSnapQuantIndex++; + if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0; + } + }; + + playbarBPM.onClick = _ -> { + if (pressingControl()) + { + this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, true); + } + else + { + Conductor.instance.currentTimeChange.bpm += 1; + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + } + } + + playbarBPM.onRightClick = _ -> { + Conductor.instance.currentTimeChange.bpm -= 1; + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + } + + playbarDifficulty.onClick = _ -> { + if (pressingControl()) + { + this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, true); + } + else + { + incrementDifficulty(-1); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + } + + playbarDifficulty.onRightClick = _ -> { + incrementDifficulty(1); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + + // Add functionality to the menu items. + + // File + menubarItemNewChart.onClick = _ -> this.openWelcomeDialog(true); + menubarItemOpenChart.onClick = _ -> this.openBrowseFNFC(true); + menubarItemSaveChart.onClick = _ -> { + if (currentWorkingFilePath != null) + { + this.exportAllSongData(true, currentWorkingFilePath); + } + else + { + this.exportAllSongData(false, null); + } + }; + menubarItemSaveChartAs.onClick = _ -> this.exportAllSongData(false, null); + menubarItemExit.onClick = _ -> quitChartEditor(); + + // Edit + menubarItemUndo.onClick = _ -> undoLastCommand(); + menubarItemRedo.onClick = _ -> redoLastCommand(); + menubarItemCopy.onClick = function(_) { + copySelection(); + }; + menubarItemCut.onClick = _ -> performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); + + menubarItemPaste.onClick = _ -> { + var targetMs:Float = scrollPositionInMs + playheadPositionInMs; + var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs); + var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio; + var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep); + performCommand(new PasteItemsCommand(targetSnappedMs)); + }; + + menubarItemPasteUnsnapped.onClick = _ -> { + var targetMs:Float = scrollPositionInMs + playheadPositionInMs; + performCommand(new PasteItemsCommand(targetMs)); + }; + + menubarItemDelete.onClick = _ -> { + if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) + { + performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection)); + } + else if (currentNoteSelection.length > 0) + { + performCommand(new RemoveNotesCommand(currentNoteSelection)); + } + else if (currentEventSelection.length > 0) + { + performCommand(new RemoveEventsCommand(currentEventSelection)); + } + else + { + // Do nothing??? + } + }; + + menubarItemFlipNotes.onClick = _ -> performCommand(new FlipNotesCommand(currentNoteSelection)); + + menubarItemSelectAllNotes.onClick = _ -> performCommand(new SelectAllItemsCommand(true, false)); + + menubarItemSelectAllEvents.onClick = _ -> performCommand(new SelectAllItemsCommand(false, true)); + + menubarItemSelectInverse.onClick = _ -> performCommand(new InvertSelectedItemsCommand()); + + menubarItemSelectNone.onClick = _ -> performCommand(new DeselectAllItemsCommand()); + + menubarItemPlaytestFull.onClick = _ -> testSongInPlayState(false); + menubarItemPlaytestMinimal.onClick = _ -> testSongInPlayState(true); + + menuBarItemNoteSnapDecrease.onClick = _ -> { + noteSnapQuantIndex--; + if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1; + }; + menuBarItemNoteSnapIncrease.onClick = _ -> { + noteSnapQuantIndex++; + if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0; + }; + + menuBarItemInputStyleNone.onClick = function(event:UIEvent) { + currentLiveInputStyle = None; + }; + menuBarItemInputStyleNone.selected = currentLiveInputStyle == None; + menuBarItemInputStyleNumberKeys.onClick = function(event:UIEvent) { + currentLiveInputStyle = NumberKeys; + }; + menuBarItemInputStyleNumberKeys.selected = currentLiveInputStyle == NumberKeys; + menuBarItemInputStyleWASD.onClick = function(event:UIEvent) { + currentLiveInputStyle = WASDKeys; + }; + menuBarItemInputStyleWASD.selected = currentLiveInputStyle == WASDKeys; + + menubarItemAbout.onClick = _ -> this.openAboutDialog(); + menubarItemWelcomeDialog.onClick = _ -> this.openWelcomeDialog(true); + + #if sys + menubarItemGoToBackupsFolder.onClick = _ -> this.openBackupsFolder(); + #else + // Disable the menu item if we're not on a desktop platform. + menubarItemGoToBackupsFolder.disabled = true; + #end + + menubarItemUserGuide.onClick = _ -> this.openUserGuideDialog(); + + menubarItemDownscroll.onClick = event -> isViewDownscroll = event.value; + menubarItemDownscroll.selected = isViewDownscroll; + + menubarItemDifficultyUp.onClick = _ -> incrementDifficulty(1); + menubarItemDifficultyDown.onClick = _ -> incrementDifficulty(-1); + + menuBarItemThemeLight.onChange = function(event:UIEvent) { + if (event.target.value) currentTheme = ChartEditorTheme.Light; + }; + menuBarItemThemeLight.selected = currentTheme == ChartEditorTheme.Light; + + menuBarItemThemeDark.onChange = function(event:UIEvent) { + if (event.target.value) currentTheme = ChartEditorTheme.Dark; + }; + menuBarItemThemeDark.selected = currentTheme == ChartEditorTheme.Dark; + + menubarItemPlayPause.onClick = _ -> toggleAudioPlayback(); + + menubarItemLoadInstrumental.onClick = _ -> { + var dialog = this.openUploadInstDialog(true); + // Ensure instrumental and vocals are reloaded properly. + dialog.onDialogClosed = function(_) { + this.isHaxeUIDialogOpen = false; + this.switchToCurrentInstrumental(); + this.postLoadInstrumental(); + } + }; + + menubarItemLoadVocals.onClick = _ -> { + var dialog = this.openUploadVocalsDialog(true); + // Ensure instrumental and vocals are reloaded properly. + dialog.onDialogClosed = function(_) { + this.isHaxeUIDialogOpen = false; + this.switchToCurrentInstrumental(); + this.postLoadInstrumental(); + } + }; + + menubarItemVolumeMetronome.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + metronomeVolume = volume; + menubarLabelVolumeMetronome.text = 'Metronome - ${Std.int(event.value)}%'; + }; + menubarItemVolumeMetronome.value = Std.int(metronomeVolume * 100); + + menubarItemThemeMusic.onChange = event -> { + this.welcomeMusic.active = event.value; + fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); + }; + menubarItemThemeMusic.selected = this.welcomeMusic.active; + + menubarItemVolumeHitsoundPlayer.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + hitsoundVolumePlayer = volume; + menubarLabelVolumeHitsoundPlayer.text = 'Player - ${Std.int(event.value)}%'; + }; + menubarItemVolumeHitsoundPlayer.value = Std.int(hitsoundVolumePlayer * 100); + + menubarItemVolumeHitsoundOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + hitsoundVolumeOpponent = volume; + menubarLabelVolumeHitsoundOpponent.text = 'Enemy - ${Std.int(event.value)}%'; + }; + menubarItemVolumeHitsoundOpponent.value = Std.int(hitsoundVolumeOpponent * 100); + + menubarItemVolumeInstrumental.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + if (audioInstTrack != null) audioInstTrack.volume = volume; + menubarLabelVolumeInstrumental.text = 'Instrumental - ${Std.int(event.value)}%'; + }; + + menubarItemVolumeVocalsPlayer.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + audioVocalTrackGroup.playerVolume = volume; + menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%'; + }; + + menubarItemVolumeVocalsOpponent.onChange = event -> { + var volume:Float = event.value.toFloat() / 100.0; + audioVocalTrackGroup.opponentVolume = volume; + menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%'; + }; + + menubarItemPlaybackSpeed.onChange = event -> { + var pitch:Float = (event.value.toFloat() * 2.0) / 100.0; + pitch = Math.floor(pitch / 0.05) * 0.05; // Round to nearest 5% + pitch = Math.max(0.05, Math.min(2.0, pitch)); // Clamp to 5% to 200% + #if FLX_PITCH + if (audioInstTrack != null) audioInstTrack.pitch = pitch; + audioVocalTrackGroup.pitch = pitch; + #end + var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places. + menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x'; + } + + menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value); + menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value); + menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value); + menubarItemToggleToolboxNoteData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTE_DATA_LAYOUT, event.value); + menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value); + menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value); + menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value); + menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value); + menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value); + + // TODO: Pass specific HaxeUI components to add context menus to them. + // registerContextMenu(null, Paths.ui('chart-editor/context/test')); + } + + function setupContextMenu():Void + { + Screen.instance.registerEvent(MouseEvent.RIGHT_MOUSE_UP, function(e:MouseEvent) { + var xPos = e.screenX; + var yPos = e.screenY; + onContextMenu(xPos, yPos); + }); + } + + function onContextMenu(xPos:Float, yPos:Float) + { + trace('User right clicked to open menu at (${xPos}, ${yPos})'); + // this.openDefaultContextMenu(xPos, yPos); + } + + function copySelection():Void + { + // Doesn't use a command because it's not undoable. + + // Calculate a single time offset for all the notes and events. + var timeOffset:Null = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null; + if (currentEventSelection.length > 0) + { + if (timeOffset == null || currentEventSelection[0].time < timeOffset) + { + timeOffset = Std.int(currentEventSelection[0].time); + } + } + + SongDataUtils.writeItemsToClipboard( + { + notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset), + events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset), + }); + } + + /** + * Initialize TurboKeyHandlers and add them to the state (so `update()` is called) + * We can then probe `keyHandler.activated` to see if the key combo's action should be taken. + */ + function setupTurboKeyHandlers():Void + { + // Keyboard shortcuts + add(undoKeyHandler); + add(redoKeyHandler); + add(upKeyHandler); + add(downKeyHandler); + add(wKeyHandler); + add(sKeyHandler); + add(pageUpKeyHandler); + add(pageDownKeyHandler); + + // Gamepad inputs + add(dpadUpGamepadHandler); + add(dpadDownGamepadHandler); + add(dpadLeftGamepadHandler); + add(dpadRightGamepadHandler); + add(leftStickUpGamepadHandler); + add(leftStickDownGamepadHandler); + add(leftStickLeftGamepadHandler); + add(leftStickRightGamepadHandler); + add(rightStickUpGamepadHandler); + add(rightStickDownGamepadHandler); + add(rightStickLeftGamepadHandler); + add(rightStickRightGamepadHandler); + } + + /** + * Setup timers and listerners to handle auto-save. + */ + function setupAutoSave():Void + { + // Called when clicking the X button on the window. + WindowUtil.windowExit.add(onWindowClose); + + // Called when the game crashes. + CrashHandler.errorSignal.add(onWindowCrash); + CrashHandler.criticalErrorSignal.add(onWindowCrash); + + saveDataDirty = false; + } + + var displayAutosavePopup:Bool = false; + + /** + * UPDATE FUNCTIONS + */ + function autoSave(?beforePlaytest:Bool = false):Void + { + var needsAutoSave:Bool = saveDataDirty; + + saveDataDirty = false; + + // Auto-save preferences. + writePreferences(needsAutoSave); + + // Auto-save the chart. + #if html5 + // Auto-save to local storage. + // TODO: Implement this. + #else + // Auto-save to temp file. + if (needsAutoSave) + { + this.exportAllSongData(true, null); + if (beforePlaytest) + { + displayAutosavePopup = true; + } + else + { + displayAutosavePopup = false; + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); + this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [ + { + text: "Take Me There", + callback: openBackupsFolder, + } + ]); + } + } + #end + } + + /** + * Open the backups folder in the file explorer. + * Don't call this on HTML5. + */ + function openBackupsFolder(?_):Bool + { + #if sys + // TODO: Is there a way to open a folder and highlight a file in it? + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); + FileUtil.openFolder(absoluteBackupsPath); + return true; + #else + trace('No file system access, cannot open backups folder.'); + return false; + #end + } + + /** + * Called when the window was closed, to save a backup of the chart. + * @param exitCode The exit code of the window. We use `-1` when calling the function due to a game crash. + */ + function onWindowClose(exitCode:Int):Void + { + trace('Window exited with exit code: $exitCode'); + trace('Should save chart? $saveDataDirty'); + + var needsAutoSave:Bool = saveDataDirty; + + writePreferences(needsAutoSave); + + if (needsAutoSave) + { + this.exportAllSongData(true, null); + } + } + + function onWindowCrash(message:String):Void + { + trace('Chart editor intercepted crash:'); + trace('${message}'); + + trace('Should save chart? $saveDataDirty'); + + var needsAutoSave:Bool = saveDataDirty; + + writePreferences(needsAutoSave); + + if (needsAutoSave) + { + this.exportAllSongData(true, null); + } + } + + function cleanupAutoSave():Void + { + WindowUtil.windowExit.remove(onWindowClose); + CrashHandler.errorSignal.remove(onWindowCrash); + CrashHandler.criticalErrorSignal.remove(onWindowCrash); + } + + public override function update(elapsed:Float):Void + { + // Override F4 behavior to include the autosave. + if (FlxG.keys.justPressed.F4 && !criticalFailure) + { + quitChartEditor(); + return; + } + + // dispatchEvent gets called here. + super.update(elapsed); + + if (criticalFailure) return; + + // These ones happen even if the modal dialog is open. + handleMusicPlayback(elapsed); + handleNoteDisplay(); + + // These ones only happen if the modal dialog is not open. + handleScrollKeybinds(); + handleSnap(); + handleCursor(); + + handleMenubar(); + handleToolboxes(); + handlePlaybar(); + handlePlayhead(); + handleNotePreview(); + handleHealthIcons(); + + handleFileKeybinds(); + handleEditKeybinds(); + handleViewKeybinds(); + handleTestKeybinds(); + handleHelpKeybinds(); + + #if FEATURE_DEBUG_FUNCTIONS + handleQuickWatch(); + #end + + handlePostUpdate(); + } + + /** + * Beat hit while the song is playing. + */ + override function beatHit():Bool + { + // dispatchEvent gets called here. + if (!super.beatHit()) return false; + + if (metronomeVolume > 0.0 && this.subState == null && (audioInstTrack != null && audioInstTrack.isPlaying)) + { + playMetronomeTick(Conductor.instance.currentBeat % Conductor.instance.beatsPerMeasure == 0); + } + + // Show the mouse cursor. + // Just throwing this somewhere convenient and infrequently called because sometimes Flixel's debug thing hides the cursor. + Cursor.show(); + + return true; + } + + /** + * Step hit while the song is playing. + */ + override function stepHit():Bool + { + // dispatchEvent gets called here. + if (!super.stepHit()) return false; + + if (audioInstTrack != null && audioInstTrack.isPlaying) + { + if (healthIconDad != null) healthIconDad.onStepHit(Conductor.instance.currentStep); + if (healthIconBF != null) healthIconBF.onStepHit(Conductor.instance.currentStep); + } + + // Updating these every step keeps it more accurate. + // playerPreviewDirty = true; + // opponentPreviewDirty = true; + + return true; + } + + /** + * UPDATE HANDLERS + */ + // ==================== + + /** + * Handle syncronizing the conductor with the music playback. + */ + function handleMusicPlayback(elapsed:Float):Void + { + if (audioInstTrack != null) + { + // This normally gets called by FlxG.sound.update() + // but we handle instrumental updates manually to prevent FlxG.sound.music.update() + // from being called twice when we move to the PlayState. + audioInstTrack.update(elapsed); + + // If the song starts 50ms in, make sure we start the song there. + if (Conductor.instance.instrumentalOffset < 0) + { + if (audioInstTrack.time < -Conductor.instance.instrumentalOffset) + { + trace('Resetting instrumental time to ${- Conductor.instance.instrumentalOffset}ms'); + audioInstTrack.time = -Conductor.instance.instrumentalOffset; + } + } + + if (!audioInstTrack.isPlaying && currentScrollEase != scrollPositionInPixels) easeSongToScrollPosition(currentScrollEase); + } + + if (audioInstTrack != null && audioInstTrack.isPlaying) + { + currentScrollEase = scrollPositionInPixels; + + if (FlxG.keys.pressed.ALT) + { + // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! + + var oldStepTime:Float = Conductor.instance.currentStepTime; + var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; + Conductor.instance.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); + // Resync vocals. + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + { + audioVocalTrackGroup.time = audioInstTrack.time; + } + var diffStepTime:Float = Conductor.instance.currentStepTime - oldStepTime; + + // Move the playhead. + playheadPositionInPixels += diffStepTime * GRID_SIZE; + + // We don't move the song to scroll position, or update the note sprites. + } + else + { + // Else, move the entire view. + var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; + Conductor.instance.update(audioInstTrack.time); + handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); + // Resync vocals. + if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) + { + audioVocalTrackGroup.time = audioInstTrack.time; + } + + // We need time in fractional steps here to allow the song to actually play. + // Also account for a potentially offset playhead. + scrollPositionInPixels = (Conductor.instance.currentStepTime + Conductor.instance.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels; + + // DO NOT move song to scroll position here specifically. + + // We need to update the note sprites. + noteDisplayDirty = true; + + // Update the note preview viewport box. + setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + } + } + + if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen) + { + toggleAudioPlayback(); + } + } + + /** + * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. + */ + function handleNoteDisplay():Void + { + if (noteDisplayDirty) + { + noteDisplayDirty = false; + + // Update for whether downscroll is enabled. + renderedNotes.flipX = (isViewDownscroll); + + // Calculate the top and bottom of the view area. + var viewAreaTopPixels:Float = MENU_BAR_HEIGHT; + var visibleGridHeightPixels:Float = FlxG.height - MENU_BAR_HEIGHT - PLAYBAR_HEIGHT; // The area underneath the menu bar and playbar is not visible. + var viewAreaBottomPixels:Float = viewAreaTopPixels + visibleGridHeightPixels; + + // Remove notes that are no longer visible and list the ones that are. + var displayedNoteData:Array = []; + for (noteSprite in renderedNotes.members) + { + if (noteSprite == null || noteSprite.noteData == null || !noteSprite.exists || !noteSprite.visible) continue; + + // Resolve an issue where dragging an event too far would cause it to be hidden. + var isSelectedAndDragged = currentNoteSelection.fastContains(noteSprite.noteData) && (dragTargetCurrentStep != 0); + + if ((noteSprite.isNoteVisible(viewAreaBottomPixels, viewAreaTopPixels) + && currentSongChartNoteData.fastContains(noteSprite.noteData)) + || isSelectedAndDragged) + { + // Note is already displayed and should remain displayed. + displayedNoteData.push(noteSprite.noteData); + + // Update the note sprite's position. + noteSprite.updateNotePosition(renderedNotes); + } + else + { + // This sprite is off-screen or was deleted. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + } + // Sort the note data array, using an algorithm that is fast on nearly-sorted data. + // We need this sorted to optimize indexing later. + displayedNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING)); + + var displayedHoldNoteData:Array = []; + for (holdNoteSprite in renderedHoldNotes.members) + { + if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue; + + if (holdNoteSprite.noteData == currentPlaceNoteData) + { + // This hold note is for the note we are currently dragging. + // It will be displayed by gridGhostHoldNoteSprite instead. + holdNoteSprite.kill(); + } + else if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)) + { + // This hold note is off-screen. + // Kill the hold note sprite and recycle it. + holdNoteSprite.kill(); + } + else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0) + { + // This hold note was deleted. + // Kill the hold note sprite and recycle it. + holdNoteSprite.kill(); + } + else if (displayedHoldNoteData.fastContains(holdNoteSprite.noteData)) + { + // This hold note is a duplicate. + // Kill the hold note sprite and recycle it. + holdNoteSprite.kill(); + } + else + { + displayedHoldNoteData.push(holdNoteSprite.noteData); + // Update the event sprite's height and position. + // var holdNoteHeight = holdNoteSprite.noteData.getStepLength() * GRID_SIZE; + // holdNoteSprite.setHeightDirectly(holdNoteHeight); + holdNoteSprite.updateHoldNotePosition(renderedNotes); + } + } + // Sort the note data array, using an algorithm that is fast on nearly-sorted data. + // We need this sorted to optimize indexing later. + displayedHoldNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING)); + + // Remove events that are no longer visible and list the ones that are. + var displayedEventData:Array = []; + for (eventSprite in renderedEvents.members) + { + if (eventSprite == null || eventSprite.eventData == null || !eventSprite.exists || !eventSprite.visible) continue; + + // Resolve an issue where dragging an event too far would cause it to be hidden. + var isSelectedAndDragged = currentEventSelection.fastContains(eventSprite.eventData) && (dragTargetCurrentStep != 0); + + if ((eventSprite.isEventVisible(FlxG.height - PLAYBAR_HEIGHT, MENU_BAR_HEIGHT) + && currentSongChartEventData.fastContains(eventSprite.eventData)) + || isSelectedAndDragged) + { + // Event is already displayed and should remain displayed. + displayedEventData.push(eventSprite.eventData); + + // Update the event sprite's position. + eventSprite.updateEventPosition(renderedEvents); + // Update the sprite's graphic. TODO: Is this inefficient? + eventSprite.playAnimation(eventSprite.eventData.eventKind); + } + else + { + // This event was deleted. + // Kill the event sprite and recycle it. + eventSprite.eventData = null; + } + } + // Sort the note data array, using an algorithm that is fast on nearly-sorted data. + // We need this sorted to optimize indexing later. + displayedEventData.insertionSort(SortUtil.eventDataByTime.bind(FlxSort.ASCENDING)); + + // Let's try testing only notes within a certain range of the view area. + // TODO: I don't think this messes up really long sustains, does it? + var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough? + var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough? + + // Add notes that are now visible. + for (noteData in currentSongChartNoteData) + { + // Remember if we are already displaying this note. + if (noteData == null) continue; + // Check if we are outside a broad range around the view area. + if (noteData.time < viewAreaTopMs || noteData.time > viewAreaBottomMs) continue; + + if (displayedNoteData.fastContains(noteData)) + { + continue; + } + + if (!ChartEditorNoteSprite.wouldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, + renderedNotes)) continue; // Else, this note is visible and we need to render it! + + // Get a note sprite from the pool. + // If we can reuse a deleted note, do so. + // If a new note is needed, call buildNoteSprite. + var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this)); + // trace('Creating new Note... (${renderedNotes.members.length})'); + noteSprite.parentState = this; + + // The note sprite handles animation playback and positioning. + noteSprite.noteData = noteData; + noteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + noteSprite.overrideStepTime = null; + noteSprite.overrideData = null; + + // Setting note data resets the position relative to the group! + // If we don't update the note position AFTER setting the note data, the note will be rendered offscreen at y=5000. + noteSprite.updateNotePosition(renderedNotes); + + // Add hold notes that are now visible (and not already displayed). + if (noteSprite.noteData != null + && noteSprite.noteData.length > 0 + && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1 + && noteSprite.noteData != currentPlaceNoteData) + { + var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this)); + // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})'); + + var noteLengthPixels:Float = noteSprite.noteData.getStepLength() * GRID_SIZE; + + holdNoteSprite.noteData = noteSprite.noteData; + holdNoteSprite.noteDirection = noteSprite.noteData.getDirection(); + + holdNoteSprite.setHeightDirectly(noteLengthPixels); + + holdNoteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteSprite.noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + + holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); + + trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height); + } + } + + // Add events that are now visible. + for (eventData in currentSongChartEventData) + { + // Remember if we are already displaying this event. + if (displayedEventData.indexOf(eventData) != -1) + { + continue; + } + + if (!ChartEditorEventSprite.wouldEventBeVisible(viewAreaBottomPixels, viewAreaTopPixels, eventData, renderedNotes)) continue; + + // Else, this event is visible and we need to render it! + + // Get an event sprite from the pool. + // If we can reuse a deleted event, do so. + // If a new event is needed, call buildEventSprite. + var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this), false, true); + eventSprite.parentState = this; + trace('Creating new Event... (${renderedEvents.members.length})'); + + // The event sprite handles animation playback and positioning. + eventSprite.eventData = eventData; + eventSprite.overrideStepTime = null; + + // Setting event data resets position relative to the grid so we fix that. + eventSprite.x += renderedEvents.x; + eventSprite.y += renderedEvents.y; + eventSprite.updateTooltipPosition(); + } + + // Add hold notes that have been made visible (but not their parents) + for (noteData in currentSongChartNoteData) + { + // Is the note a hold note? + if (noteData == null || noteData.length <= 0) continue; + + // Is the note the one we are dragging? If so, ghostHoldNoteSprite will handle it. + if (noteData == currentPlaceNoteData) continue; + + // Is the hold note rendered already? + if (displayedHoldNoteData.indexOf(noteData) != -1) continue; + + // Is the hold note offscreen? + if (!ChartEditorHoldNoteSprite.wouldHoldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, renderedHoldNotes)) continue; + + // Hold note should be rendered. + var holdNoteFactory = function() { + // TODO: Print some kind of warning if `renderedHoldNotes.members` is too high? + return new ChartEditorHoldNoteSprite(this); + } + var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(holdNoteFactory); + + var noteLengthPixels:Float = noteData.getStepLength() * GRID_SIZE; + + holdNoteSprite.noteData = noteData; + holdNoteSprite.noteDirection = noteData.getDirection(); + holdNoteSprite.setHeightDirectly(noteLengthPixels); + + holdNoteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + + holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); + + displayedHoldNoteData.push(noteData); + } + + // Destroy all existing selection squares. + for (member in renderedSelectionSquares.members) + { + // Killing the sprite is cheap because we can recycle it. + member.kill(); + } + + // Readd selection squares for selected notes. + // Recycle selection squares if possible. + for (noteSprite in renderedNotes.members) + { + // TODO: Handle selection of hold notes. + if (isNoteSelected(noteSprite.noteData)) + { + // Determine if the note is being dragged and offset the vertical position accordingly. + if (dragTargetCurrentStep != 0.0) + { + var stepTime:Float = (noteSprite.noteData == null) ? 0.0 : noteSprite.noteData.getStepTime(); + // Update the note's "ghost" step time. + noteSprite.overrideStepTime = (stepTime + dragTargetCurrentStep).clamp(0, songLengthInSteps - (1 * noteSnapRatio)); + // Then reapply the note sprite's position relative to the grid. + noteSprite.updateNotePosition(renderedNotes); + } + else + { + if (noteSprite.overrideStepTime != null) + { + // Reset the note's "ghost" step time. + noteSprite.overrideStepTime = null; + // Then reapply the note sprite's position relative to the grid. + noteSprite.updateNotePosition(renderedNotes); + } + } + + // Determine if the note is being dragged and offset the horizontal position accordingly. + if (dragTargetCurrentColumn != 0) + { + var data:Int = (noteSprite.noteData == null) ? 0 : noteSprite.noteData.data; + // Update the note's "ghost" column. + noteSprite.overrideData = gridColumnToNoteData((noteDataToGridColumn(data) + dragTargetCurrentColumn).clamp(0, + ChartEditorState.STRUMLINE_SIZE * 2 - 1)); + // Then reapply the note sprite's position relative to the grid. + noteSprite.updateNotePosition(renderedNotes); + } + else + { + if (noteSprite.overrideData != null) + { + // Reset the note's "ghost" column. + noteSprite.overrideData = null; + // Then reapply the note sprite's position relative to the grid. + noteSprite.updateNotePosition(renderedNotes); + } + } + + // Then, render the selection square. + var selectionSquare:ChartEditorSelectionSquareSprite = renderedSelectionSquares.recycle(buildSelectionSquare); + + // Set the position and size (because we might be recycling one with bad values). + selectionSquare.noteData = noteSprite.noteData; + selectionSquare.eventData = null; + selectionSquare.x = noteSprite.x; + selectionSquare.y = noteSprite.y; + selectionSquare.width = GRID_SIZE; + + var stepLength = noteSprite.noteData.getStepLength(); + selectionSquare.height = (stepLength <= 0) ? GRID_SIZE : ((stepLength + 1) * GRID_SIZE); + } + } + + for (eventSprite in renderedEvents.members) + { + if (isEventSelected(eventSprite.eventData)) + { + // Determine if the note is being dragged and offset the position accordingly. + if (dragTargetCurrentStep > 0 || dragTargetCurrentColumn > 0) + { + var stepTime = (eventSprite.eventData == null) ? 0 : eventSprite.eventData.getStepTime(); + eventSprite.overrideStepTime = (stepTime + dragTargetCurrentStep).clamp(0, songLengthInSteps); + // Then reapply the note sprite's position relative to the grid. + eventSprite.updateEventPosition(renderedEvents); + } + else + { + if (eventSprite.overrideStepTime != null) + { + // Reset the note's "ghost" column. + eventSprite.overrideStepTime = null; + // Then reapply the note sprite's position relative to the grid. + eventSprite.updateEventPosition(renderedEvents); + } + } + + // Then, render the selection square. + var selectionSquare:ChartEditorSelectionSquareSprite = renderedSelectionSquares.recycle(buildSelectionSquare); + + // Set the position and size (because we might be recycling one with bad values). + selectionSquare.noteData = null; + selectionSquare.eventData = eventSprite.eventData; + selectionSquare.x = eventSprite.x; + selectionSquare.y = eventSprite.y; + selectionSquare.width = eventSprite.width; + selectionSquare.height = eventSprite.height; + } + + // Additional cleanup on notes. + if (noteTooltipsDirty) eventSprite.updateTooltipText(); + } + + noteTooltipsDirty = false; + + // Sort the notes DESCENDING. This keeps the sustain behind the associated note. + renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort() + + // Sort the events DESCENDING. This keeps the sustain behind the associated note. + renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort() + } + } + + /** + * Handle keybinds for scrolling the chart editor grid. + */ + function handleScrollKeybinds():Void + { + // Don't scroll when the user is interacting with the UI, unless a playbar button (the << >> ones) is pressed. + if ((isHaxeUIFocused || isCursorOverHaxeUI) && playbarButtonPressed == null) return; + + var scrollAmount:Float = 0; // Amount to scroll the grid. + var playheadAmount:Float = 0; // Amount to scroll the playhead relative to the grid. + var shouldPause:Bool = false; // Whether to pause the song when scrolling. + var shouldEase:Bool = false; // Whether to ease the scroll. + + // Handle scroll anchor + if (scrollAnchorScreenPos != null) + { + var currentScreenPos = new FlxPoint(FlxG.mouse.x, FlxG.mouse.y); + var distance = currentScreenPos - scrollAnchorScreenPos; + + var verticalDistance = distance.y; + + // How much scrolling should be done based on the distance of the cursor from the anchor. + final ANCHOR_SCROLL_SPEED = 0.2; + + scrollAmount = ANCHOR_SCROLL_SPEED * verticalDistance; + shouldPause = true; + } + + // Mouse Wheel = Scroll + if (FlxG.mouse.wheel != 0) + { + scrollAmount = -50 * FlxG.mouse.wheel; + shouldPause = true; + } + + // Up Arrow = Scroll Up + if (upKeyHandler.activated && currentLiveInputStyle == None) + { + scrollAmount = -GRID_SIZE * 4; + shouldPause = true; + } + // Down Arrow = Scroll Down + if (downKeyHandler.activated && currentLiveInputStyle == None) + { + scrollAmount = GRID_SIZE * 4; + shouldPause = true; + } + + // W = Scroll Up (doesn't work with Ctrl+Scroll) + if (wKeyHandler.activated && currentLiveInputStyle == None && !pressingControl()) + { + scrollAmount = -GRID_SIZE * 4; + shouldPause = true; + } + // S = Scroll Down (doesn't work with Ctrl+Scroll) + if (sKeyHandler.activated && currentLiveInputStyle == None && !pressingControl()) + { + scrollAmount = GRID_SIZE * 4; + shouldPause = true; + } + + // GAMEPAD LEFT STICK UP = Scroll Up by 1 note snap + if (leftStickUpGamepadHandler.activated) + { + scrollAmount = -GRID_SIZE * noteSnapRatio; + shouldPause = true; + } + // GAMEPAD LEFT STICK DOWN = Scroll Down by 1 note snap + if (leftStickDownGamepadHandler.activated) + { + scrollAmount = GRID_SIZE * noteSnapRatio; + shouldPause = true; + } + + // GAMEPAD RIGHT STICK UP = Scroll Up by 1 note snap (playhead only) + if (rightStickUpGamepadHandler.activated) + { + playheadAmount = -GRID_SIZE * noteSnapRatio; + shouldPause = true; + } + // GAMEPAD RIGHT STICK DOWN = Scroll Down by 1 note snap (playhead only) + if (rightStickDownGamepadHandler.activated) + { + playheadAmount = GRID_SIZE * noteSnapRatio; + shouldPause = true; + } + + var funcJumpUp = (playheadOnly:Bool) -> { + var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight; + // If we would move less than one grid, instead move to the top of the previous measure. + var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); + if (targetScrollAmount < GRID_SIZE) + { + targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure; + } + + if (playheadOnly) + { + playheadAmount = targetScrollPosition - playheadPos; + } + else + { + scrollAmount = targetScrollPosition - playheadPos; + } + } + + // PAGE UP = Jump up to nearest measure + // GAMEPAD LEFT STICK LEFT = Jump up to nearest measure + if (pageUpKeyHandler.activated || leftStickLeftGamepadHandler.activated) + { + funcJumpUp(false); + shouldPause = true; + } + if (rightStickLeftGamepadHandler.activated) + { + funcJumpUp(true); + shouldPause = true; + } + if (playbarButtonPressed == 'playbarBack') + { + playbarButtonPressed = ''; + funcJumpUp(false); + shouldPause = true; + } + + var funcJumpDown = (playheadOnly:Bool) -> { + var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure; + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight; + // If we would move less than one grid, instead move to the top of the next measure. + var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); + if (targetScrollAmount < GRID_SIZE) + { + targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure; + } + + if (playheadOnly) + { + playheadAmount = targetScrollPosition - playheadPos; + } + else + { + scrollAmount = targetScrollPosition - playheadPos; + } + } + + // PAGE DOWN = Jump down to nearest measure + // GAMEPAD LEFT STICK RIGHT = Jump down to nearest measure + if (pageDownKeyHandler.activated || leftStickRightGamepadHandler.activated) + { + funcJumpDown(false); + shouldPause = true; + } + if (rightStickRightGamepadHandler.activated) + { + funcJumpDown(true); + shouldPause = true; + } + if (playbarButtonPressed == 'playbarForward') + { + playbarButtonPressed = ''; + funcJumpDown(false); + shouldPause = true; + } + + // SHIFT + Scroll = Scroll Fast + // GAMEPAD LEFT STICK CLICK + Scroll = Scroll Fast + if (FlxG.keys.pressed.SHIFT || (FlxG.gamepads.firstActive?.pressed?.LEFT_STICK_CLICK ?? false)) + { + scrollAmount *= 2; + } + // CONTROL + Scroll = Scroll Precise + if (pressingControl()) + { + scrollAmount /= 4; + } + + // Alt + Drag = Scroll but move the playhead the same amount. + if (FlxG.keys.pressed.ALT) + { + playheadAmount = scrollAmount; + scrollAmount = 0; + shouldPause = false; + } + + // HOME = Scroll to Top + if (FlxG.keys.justPressed.HOME) + { + // Scroll amount is the difference between the current position and the top. + scrollAmount = 0 - this.scrollPositionInPixels; + playheadAmount = 0 - this.playheadPositionInPixels; + shouldPause = true; + } + if (playbarButtonPressed == 'playbarStart') + { + playbarButtonPressed = ''; + scrollAmount = 0 - this.scrollPositionInPixels; + playheadAmount = 0 - this.playheadPositionInPixels; + shouldPause = true; + } + + // END = Scroll to Bottom + if (FlxG.keys.justPressed.END) + { + // Scroll amount is the difference between the current position and the bottom. + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; + shouldPause = true; + } + if (playbarButtonPressed == 'playbarEnd') + { + playbarButtonPressed = ''; + scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; + shouldPause = true; + } + + shouldEase = true; + if (shouldPause) stopAudioPlayback(); + + // Resync the conductor and audio tracks. + if (playheadAmount != 0) this.playheadPositionInPixels += playheadAmount; + + if (scrollAmount != 0) currentScrollEase += scrollAmount; + } + + /** + * Handle changing the note snapping level. + */ + function handleSnap():Void + { + if (currentLiveInputStyle == None) + { + if (FlxG.keys.justPressed.LEFT && !pressingControl()) + { + noteSnapQuantIndex--; + if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1; + } + + if (FlxG.keys.justPressed.RIGHT && !pressingControl()) + { + noteSnapQuantIndex++; + if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0; + } + } + } + + /** + * Handle display of the mouse cursor. + */ + function handleCursor():Void + { + // Mouse sounds + if (FlxG.mouse.justPressed) FunkinSound.playOnce(Paths.sound("chartingSounds/ClickDown")); + if (FlxG.mouse.justReleased) FunkinSound.playOnce(Paths.sound("chartingSounds/ClickUp")); + + // Note: If a menu is open in HaxeUI, don't handle cursor behavior. + var shouldHandleCursor:Bool = !(isHaxeUIFocused || playbarHeadDragging || isHaxeUIDialogOpen) + || (selectionBoxStartPos != null) + || (dragTargetNote != null || dragTargetEvent != null); + + var eventColumn:Int = (STRUMLINE_SIZE * 2 + 1) - 1; + + // trace('shouldHandleCursor: $shouldHandleCursor'); + + // TODO: TBH some of this should be using FlxMouseEventManager... + + if (shouldHandleCursor) + { + // Over the course of this big conditional block, + // we determine what the cursor should look like, + // and fall back to the default cursor if none of the conditions are met. + var targetCursorMode:Null = null; + + if (gridTiledSprite == null) throw "ERROR: Tried to handle cursor, but gridTiledSprite is null! Check ChartEditorState.buildGrid()"; + + var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite); + + var overlapsRenderedNotes:Bool = FlxG.mouse.overlaps(renderedNotes); + var overlapsRenderedHoldNotes:Bool = FlxG.mouse.overlaps(renderedHoldNotes); + var overlapsRenderedEvents:Bool = FlxG.mouse.overlaps(renderedEvents); + + // Cursor position relative to the grid. + var cursorX:Float = FlxG.mouse.viewX - gridTiledSprite.x; + var cursorY:Float = FlxG.mouse.viewY - gridTiledSprite.y; + + var overlapsSelectionBorder:Bool = overlapsGrid + && ((cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) + || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)) + || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))); + + var overlapsSelection:Bool = FlxG.mouse.overlaps(renderedSelectionSquares); + + var overlapsHealthIcons:Bool = FlxG.mouse.overlaps(healthIconBF) || FlxG.mouse.overlaps(healthIconDad); + + if (FlxG.mouse.justPressedMiddle) + { + if (scrollAnchorScreenPos == null) + { + scrollAnchorScreenPos = new FlxPoint(FlxG.mouse.x, FlxG.mouse.y); + selectionBoxStartPos = null; + } + else + { + scrollAnchorScreenPos = null; + } + } + + if (FlxG.mouse.justPressed) + { + if (scrollAnchorScreenPos != null) + { + scrollAnchorScreenPos = null; + } + else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks) && !isCursorOverHaxeUI) + { + gridPlayheadScrollAreaPressed = true; + } + else if (notePreview != null && FlxG.mouse.overlaps(notePreview) && !isCursorOverHaxeUI) + { + // Clicked note preview + notePreviewScrollAreaStartPos = new FlxPoint(FlxG.mouse.viewX, FlxG.mouse.viewY); + } + else if (!isCursorOverHaxeUI && (!overlapsGrid || overlapsSelectionBorder)) + { + selectionBoxStartPos = new FlxPoint(FlxG.mouse.viewX, FlxG.mouse.viewY); + // Drawing selection box. + targetCursorMode = Crosshair; + } + else if (overlapsSelection) + { + // Do nothing + trace('Clicked on a selected note!'); + } + } + + if (gridPlayheadScrollAreaPressed && FlxG.mouse.released) + { + gridPlayheadScrollAreaPressed = false; + } + + if (notePreviewScrollAreaStartPos != null && FlxG.mouse.released) + { + notePreviewScrollAreaStartPos = null; + } + + if (gridPlayheadScrollAreaPressed) + { + // Clicked on the playhead scroll area. + // Move the playhead to the cursor position. + this.playheadPositionInPixels = FlxG.mouse.viewY - (GRID_INITIAL_Y_POS); + moveSongToScrollPosition(); + + // Cursor should be a grabby hand. + if (targetCursorMode == null) targetCursorMode = Grabbing; + } + + // The song position of the cursor, in steps. + var cursorFractionalStep:Float = cursorY / GRID_SIZE; + var cursorMs:Float = Conductor.instance.getStepTimeInMs(cursorFractionalStep); + // Round the cursor step to the nearest snap quant. + var cursorSnappedStep:Float = Math.floor(cursorFractionalStep / noteSnapRatio) * noteSnapRatio; + var cursorSnappedMs:Float = Conductor.instance.getStepTimeInMs(cursorSnappedStep); + + // The direction value for the column at the cursor. + var cursorGridPos:Int = Math.floor(cursorX / GRID_SIZE); + var cursorColumn:Int = gridColumnToNoteData(cursorGridPos); + + if (selectionBoxStartPos != null) + { + var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; + var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; + + var hasDraggedMouse:Bool = Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD; + + // Determine if we dragged the mouse at all. + if (hasDraggedMouse) + { + // Handle releasing the selection box. + if (FlxG.mouse.justReleased) + { + // We released the mouse. Select the notes in the box. + var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE; + var cursorStepStart:Int = Math.floor(cursorFractionalStepStart); + var cursorMsStart:Float = Conductor.instance.getStepTimeInMs(cursorStepStart); + var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE); + var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE); + + // Since this selects based on noteData directly, + // we don't need to specifically exclude sustain pieces. + + // This logic is gross because the columns go 4567-0123-8. + // We build a list of columns to select. + var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart)); + var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart)); + var columns:Array = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int { + if (i >= eventColumn) + { + // Don't invert the event column. + return eventColumn; + } + else if (i >= STRUMLINE_SIZE) + { + // Invert the player columns. + return i - STRUMLINE_SIZE; + } + else if (i >= 0) + { + // Invert the opponent columns. + return i + STRUMLINE_SIZE; + } + else + { + // Minimum of -1. + return -1; + } + }); + + if (columns.length > 0) + { + var notesToSelect:Array = currentSongChartNoteData; + notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); + notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns); + + var eventsToSelect:Array = []; + + if (columns.indexOf(eventColumn) != -1) + { + // The drag selection included the event column. + eventsToSelect = currentSongChartEventData; + eventsToSelect = SongDataUtils.getEventsInTimeRange(eventsToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); + } + + if (notesToSelect.length > 0 || eventsToSelect.length > 0) + { + if (pressingControl()) + { + // Add to the selection. + performCommand(new SelectItemsCommand(notesToSelect, eventsToSelect)); + } + else + { + // Set the selection. + performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect)); + } + } + else + { + // We made a selection box, but it didn't select anything. + + if (!pressingControl()) + { + // Deselect all items. + var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); + if (shouldDeselect) + { + performCommand(new DeselectAllItemsCommand()); + } + } + } + } + else + { + // We made a selection box, but it didn't select any columns. + } + + // Clear the selection box. + selectionBoxStartPos = null; + setSelectionBoxBounds(); + } + else + { + // Clicking and dragging. + + // Scroll the screen if the mouse is above or below the grid. + if (FlxG.mouse.viewY < MENU_BAR_HEIGHT) + { + // Scroll up. + var diff:Float = MENU_BAR_HEIGHT - FlxG.mouse.viewY; + currentScrollEase -= diff * 0.5; // Too fast! + } + else if (FlxG.mouse.viewY > (playbarHeadLayout?.y ?? 0.0)) + { + // Scroll down. + var diff:Float = FlxG.mouse.viewY - (playbarHeadLayout?.y ?? 0.0); + currentScrollEase += (diff * 0.5); // Too fast! + } + + // Render the selection box, and keep the rendered graphic clamped to the size of the screen + var selectionRect:FlxRect = new FlxRect(); + selectionRect.x = Math.min(FlxG.mouse.viewX, selectionBoxStartPos.x); + selectionRect.y = Math.min(Math.max(0, selectionBoxStartPos.y), FlxG.mouse.viewY); + selectionRect.width = Math.abs(FlxG.mouse.viewX - selectionBoxStartPos.x); + selectionRect.height = Math.abs(FlxG.mouse.viewY - Math.max(Math.min(FlxG.height, selectionBoxStartPos.y), 0)); + setSelectionBoxBounds(selectionRect); + + targetCursorMode = Crosshair; + } + } + else if (FlxG.mouse.justReleased) + { + // Clear the selection box. + selectionBoxStartPos = null; + setSelectionBoxBounds(); + + if (overlapsGrid) + { + // We clicked on the grid without moving the mouse. + + // Find the first note that is at the cursor position. + var highlightedNote:Null = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:Null = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { + return event.alive && FlxG.mouse.overlaps(event); + }); + } + var highlightedHoldNote:Null = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } + + if (pressingControl()) + { + if (highlightedNote != null && highlightedNote.noteData != null) + { + // Control click to select/deselect an individual note. + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); + } + } + else if (highlightedEvent != null && highlightedEvent.eventData != null) + { + // Control click to select/deselect an individual note. + if (isEventSelected(highlightedEvent.eventData)) + { + performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); + } + else + { + performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); + } + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Control click to select/deselect an individual note. + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], [])); + } + } + else + { + // Do nothing if you control-clicked on an empty space. + } + } + else + { + if (highlightedNote != null && highlightedNote.noteData != null) + { + // Click a note to select it. + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [])); + } + else if (highlightedEvent != null && highlightedEvent.eventData != null) + { + // Click an event to select it. + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData])); + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Click a hold note to select it. + performCommand(new SetItemSelectionCommand([highlightedHoldNote.noteData], [])); + } + else + { + // Click on an empty space to deselect everything. + var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); + if (shouldDeselect) + { + performCommand(new DeselectAllItemsCommand()); + } + } + } + } + else + { + // If we clicked and released outside the grid. + + if (!pressingControl()) + { + // Deselect all items. + var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0); + if (shouldDeselect) + { + performCommand(new DeselectAllItemsCommand()); + } + } + } + } + } + else if (notePreviewScrollAreaStartPos != null) + { + // Player is clicking and holding on note preview to scrub around. + targetCursorMode = Grabbing; + + var clickedPosInPixels:Float = FlxMath.remapToRange(FlxG.mouse.viewY, (notePreview?.y ?? 0.0), (notePreview?.y ?? 0.0) + (notePreview?.height ?? 0.0), + 0, songLengthInPixels); + + currentScrollEase = clickedPosInPixels; + easeSongToScrollPosition(currentScrollEase); + } + else if (scrollAnchorScreenPos != null) + { + // Cursor should be a scroll anchor. + targetCursorMode = Scroll; + } + else if (dragTargetNote != null || dragTargetEvent != null) + { + if (FlxG.mouse.justReleased) + { + // Perform the actual drag operation. + var dragDistanceSteps:Float = dragTargetCurrentStep; + var dragDistanceMs:Float = 0; + if (dragTargetNote != null && dragTargetNote.noteData != null) + { + dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time; + } + else if (dragTargetEvent != null && dragTargetEvent.eventData != null) + { + dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time; + } + var dragDistanceColumns:Int = dragTargetCurrentColumn; + + if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) + { + // Both notes and events are selected. + performCommand(new MoveItemsCommand(currentNoteSelection, currentEventSelection, dragDistanceMs, dragDistanceColumns)); + } + else if (currentNoteSelection.length > 0) + { + // Only notes are selected. + performCommand(new MoveNotesCommand(currentNoteSelection, dragDistanceMs, dragDistanceColumns)); + } + else if (currentEventSelection.length > 0) + { + // Only events are selected. + performCommand(new MoveEventsCommand(currentEventSelection, dragDistanceMs)); + } + + // Finished dragging. Release the note at the new position. + dragTargetNote = null; + dragTargetEvent = null; + + noteDisplayDirty = true; + + dragTargetCurrentStep = 0; + dragTargetCurrentColumn = 0; + } + else + { + // Player is clicking and holding on a selected note or event to move the selection around. + targetCursorMode = Grabbing; + + // Scroll the screen if the mouse is above or below the grid. + if (FlxG.mouse.viewY < MENU_BAR_HEIGHT) + { + // Scroll up. + var diff:Float = MENU_BAR_HEIGHT - FlxG.mouse.viewY; + currentScrollEase -= (diff * 0.5); + } + else if (FlxG.mouse.viewY > (playbarHeadLayout?.y ?? 0.0)) + { + // Scroll down. + var diff:Float = FlxG.mouse.viewY - (playbarHeadLayout?.y ?? 0.0); + currentScrollEase += (diff * 0.5); + } + + // Calculate distance between the position dragged to and the original position. + var stepTime:Float = 0; + if (dragTargetNote != null && dragTargetNote.noteData != null) + { + stepTime = dragTargetNote.noteData.getStepTime(); + } + else if (dragTargetEvent != null && dragTargetEvent.eventData != null) + { + stepTime = dragTargetEvent.eventData.getStepTime(); + } + var dragDistanceSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime; + var data:Int = 0; + var noteGridPos:Int = 0; + if (dragTargetNote != null && dragTargetNote.noteData != null) + { + data = dragTargetNote.noteData.data; + noteGridPos = noteDataToGridColumn(data); + } + else if (dragTargetEvent != null) + { + data = ChartEditorState.STRUMLINE_SIZE * 2 + 1; + } + var dragDistanceColumns:Int = cursorGridPos - noteGridPos; + + if (dragTargetCurrentStep != dragDistanceSteps || dragTargetCurrentColumn != dragDistanceColumns) + { + // Play a sound as we drag. + this.playSound(Paths.sound('chartingSounds/noteLay')); + + trace('Dragged ${dragDistanceColumns} X and ${dragDistanceSteps} Y.'); + dragTargetCurrentStep = dragDistanceSteps; + dragTargetCurrentColumn = dragDistanceColumns; + + noteDisplayDirty = true; + } + } + } + else if (currentPlaceNoteData != null) + { + // Handle extending the note as you drag. + + var stepTime:Float = inline currentPlaceNoteData.getStepTime(); + var dragLengthSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs) - stepTime; + var dragLengthMs:Float = dragLengthSteps * Conductor.instance.stepLengthMs; + var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE; + + if (gridGhostHoldNote != null) + { + if (dragLengthSteps > 0) + { + if (dragLengthCurrent != dragLengthSteps) + { + stretchySounds = !stretchySounds; + this.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI')); + + dragLengthCurrent = dragLengthSteps; + } + + gridGhostHoldNote.visible = true; + gridGhostHoldNote.noteData = currentPlaceNoteData; + gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection(); + gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true); + gridGhostHoldNote.noteStyle = NoteKindManager.getNoteStyleId(currentPlaceNoteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes); + } + else + { + gridGhostHoldNote.visible = false; + gridGhostHoldNote.setHeightDirectly(0); + } + } + + if (FlxG.mouse.justReleased) + { + if (dragLengthSteps > 0) + { + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + // Apply the new length. + performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs)); + } + else + { + // Apply the new (zero) length if we are changing the length. + if (currentPlaceNoteData.length > 0) + { + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, 0)); + } + } + + // Finished dragging. Release the note. + currentPlaceNoteData = null; + } + else + { + // Cursor should be a grabby hand. + if (targetCursorMode == null) targetCursorMode = Grabbing; + } + } + else + { + if (FlxG.mouse.justPressed) + { + // Just clicked to place a note. + if (!isCursorOverHaxeUI && overlapsGrid && !overlapsSelectionBorder) + { + // We clicked on the grid without moving the mouse. + + // Find the first note that is at the cursor position. + var highlightedNote:Null = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:Null = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { + // If event.alive is false, the event is dead and awaiting recycling. + return event.alive && FlxG.mouse.overlaps(event); + }); + } + var highlightedHoldNote:Null = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + // If holdNote.alive is false, the holdNote is dead and awaiting recycling. + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } + + if (pressingControl()) + { + // Control click to select/deselect an individual note. + if (highlightedNote != null && highlightedNote.noteData != null) + { + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); + } + } + else if (highlightedEvent != null && highlightedEvent.eventData != null) + { + if (isEventSelected(highlightedEvent.eventData)) + { + performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); + } + else + { + performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); + } + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + if (isNoteSelected(highlightedNote.noteData)) + { + performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], [])); + } + else + { + performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], [])); + } + } + else + { + // Do nothing when control clicking nothing. + } + } + else + { + if (highlightedNote != null && highlightedNote.noteData != null) + { + if (isNoteSelected(highlightedNote.noteData)) + { + // Clicked a selected event, start dragging. + dragTargetNote = highlightedNote; + } + else + { + // If you click an unselected note, and aren't holding Control, deselect everything else. + performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [])); + } + } + else if (highlightedEvent != null && highlightedEvent.eventData != null) + { + if (isEventSelected(highlightedEvent.eventData)) + { + // Clicked a selected event, start dragging. + dragTargetEvent = highlightedEvent; + } + else + { + // If you click an unselected event, and aren't holding Control, deselect everything else. + performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData])); + } + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + // Clicked a hold note, start dragging TO EXTEND NOTE LENGTH. + currentPlaceNoteData = highlightedHoldNote.noteData; + } + else + { + // Click a blank space to place a note and select it. + + if (cursorGridPos == eventColumn) + { + // Create an event and place it in the chart. + // TODO: Figure out configuring event data. + var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.copy()); + + performCommand(new AddEventsCommand([newEventData], pressingControl())); + } + else + { + // Create a note and place it in the chart. + var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace, + ChartEditorState.cloneNoteParams(noteParamsToPlace)); + + performCommand(new AddNotesCommand([newNoteData], pressingControl())); + + currentPlaceNoteData = newNoteData; + } + } + } + } + else + { + // If we clicked and released outside the grid (or on HaxeUI), do nothing. + } + } + + var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight) + || (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0)); + if (rightMouseUpdated && overlapsGrid) + { + // We right clicked on the grid. + + // Find the first note that is at the cursor position. + var highlightedNote:Null = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { + // If note.alive is false, the note is dead and awaiting recycling. + return note.alive && FlxG.mouse.overlaps(note); + }); + var highlightedEvent:Null = null; + if (highlightedNote == null) + { + highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { + // If event.alive is false, the event is dead and awaiting recycling. + return event.alive && FlxG.mouse.overlaps(event); + }); + } + var highlightedHoldNote:Null = null; + if (highlightedNote == null && highlightedEvent == null) + { + highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool { + // If holdNote.alive is false, the holdNote is dead and awaiting recycling. + return holdNote.alive && FlxG.mouse.overlaps(holdNote); + }); + } + + if (highlightedNote != null && highlightedNote.noteData != null) + { + // TODO: Handle the case of clicking on a sustain piece. + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu. + var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedNote.noteData); + var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected) + || (isHighlightedNoteSelected && currentNoteSelection.length == 1); + // Show the context menu connected to the note. + if (useSingleNoteContextMenu) + { + this.openNoteContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY, highlightedNote.noteData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY); + } + } + else + { + // Right click removes the note. + performCommand(new RemoveNotesCommand([highlightedNote.noteData])); + } + } + else if (highlightedEvent != null && highlightedEvent.eventData != null) + { + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Event context menu. + var isHighlightedEventSelected:Bool = isEventSelected(highlightedEvent.eventData); + var useSingleEventContextMenu:Bool = (!isHighlightedEventSelected) + || (isHighlightedEventSelected && currentEventSelection.length == 1); + if (useSingleEventContextMenu) + { + this.openEventContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY, highlightedEvent.eventData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY); + } + } + else + { + // Right click removes the event. + performCommand(new RemoveEventsCommand([highlightedEvent.eventData])); + } + } + else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null) + { + if (FlxG.keys.pressed.SHIFT) + { + // Shift + Right click opens the context menu. + // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu. + var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedHoldNote.noteData); + var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected) + || (isHighlightedNoteSelected && currentNoteSelection.length == 1); + // Show the context menu connected to the note. + if (useSingleNoteContextMenu) + { + this.openHoldNoteContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY, highlightedHoldNote.noteData); + } + else + { + this.openSelectionContextMenu(FlxG.mouse.viewX, FlxG.mouse.viewY); + } + } + else + { + // Right click removes hold from the note. + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + performCommand(new ExtendNoteLengthCommand(highlightedHoldNote.noteData, 0)); + } + } + else + { + // Right clicked on nothing. + } + } + + var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null || overlapsRenderedNotes || overlapsRenderedHoldNotes + || overlapsRenderedEvents; + // Handle grid cursor. + if (!isCursorOverHaxeUI && overlapsGrid && !isOrWillSelect && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed) + { + // Indicate that we can place a note here. + + if (cursorGridPos == eventColumn) + { + if (gridGhostNote != null) gridGhostNote.visible = false; + if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false; + + if (gridGhostEvent == null) throw "ERROR: Tried to handle cursor, but gridGhostEvent is null! Check ChartEditorState.buildGrid()"; + + var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, eventKindToPlace, null); + + if (eventKindToPlace != eventData.eventKind) + { + eventData.eventKind = eventKindToPlace; + } + eventData.time = cursorSnappedMs; + + gridGhostEvent.visible = true; + gridGhostEvent.eventData = eventData; + gridGhostEvent.updateEventPosition(renderedEvents); + + targetCursorMode = Cell; + } + else + { + if (gridGhostEvent != null) gridGhostEvent.visible = false; + + if (gridGhostNote == null) throw "ERROR: Tried to handle cursor, but gridGhostNote is null! Check ChartEditorState.buildGrid()"; + + var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, noteKindToPlace, + ChartEditorState.cloneNoteParams(noteParamsToPlace)); + + if (cursorColumn != noteData.data || noteKindToPlace != noteData.kind || noteParamsToPlace != noteData.params) + { + noteData.kind = noteKindToPlace; + noteData.params = noteParamsToPlace; + noteData.data = cursorColumn; + gridGhostNote.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + gridGhostNote.playNoteAnimation(); + } + noteData.time = cursorSnappedMs; + + gridGhostNote.visible = true; + gridGhostNote.noteData = noteData; + gridGhostNote.updateNotePosition(renderedNotes); + + targetCursorMode = Cell; + } + } + else + { + if (gridGhostNote != null) gridGhostNote.visible = false; + if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false; + if (gridGhostEvent != null) gridGhostEvent.visible = false; + } + } + + if (targetCursorMode == null) + { + if (FlxG.mouse.pressed) + { + if (overlapsSelection) + { + targetCursorMode = Grabbing; + } + if (overlapsSelectionBorder) + { + targetCursorMode = Crosshair; + } + } + else + { + if (!isCursorOverHaxeUI) + { + if (notePreview != null && FlxG.mouse.overlaps(notePreview)) + { + targetCursorMode = Pointer; + } + else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks)) + { + targetCursorMode = Pointer; + } + else if (overlapsSelection) + { + targetCursorMode = Pointer; + } + else if (overlapsSelectionBorder) + { + targetCursorMode = Crosshair; + } + else if (overlapsRenderedNotes) + { + targetCursorMode = Pointer; + } + else if (overlapsRenderedHoldNotes) + { + targetCursorMode = Pointer; + } + else if (overlapsRenderedEvents) + { + targetCursorMode = Pointer; + } + else if (overlapsGrid) + { + targetCursorMode = Cell; + } + else if (overlapsHealthIcons) + { + targetCursorMode = Pointer; + } + } + } + } + + // Actually set the cursor mode to the one we specified earlier. + Cursor.cursorMode = targetCursorMode ?? Default; + } + else + { + if (gridGhostNote != null) gridGhostNote.visible = false; + if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false; + if (gridGhostEvent != null) gridGhostEvent.visible = false; + + // Do not set Cursor.cursorMode here, because it will be set by the HaxeUI. + } + } + + function handleToolboxes():Void + { + handleDifficultyToolbox(); + handlePlayerPreviewToolbox(); + handleOpponentPreviewToolbox(); + } + + function handleDifficultyToolbox():Void + { + if (difficultySelectDirty) + { + difficultySelectDirty = false; + + var difficultyToolbox:ChartEditorDifficultyToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + if (difficultyToolbox == null) return; + + difficultyToolbox.updateTree(); + } + } + + function handlePlayerPreviewToolbox():Void + { + // Manage the Select Difficulty tree view. + var charPreviewToolbox:Null = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); + if (charPreviewToolbox == null) return; + + // TODO: Re-enable the player preview once we figure out the performance issues. + var charPlayer:Null = null; // charPreviewToolbox.findComponent('charPlayer'); + if (charPlayer == null) return; + + currentPlayerCharacterPlayer = charPlayer; + + if (playerPreviewDirty) + { + playerPreviewDirty = false; + + if (currentSongMetadata.playData.characters.player != charPlayer.charId) + { + if (healthIconBF != null) + { + healthIconBF.characterId = currentSongMetadata.playData.characters.player; + } + + charPlayer.loadCharacter(currentSongMetadata.playData.characters.player); + charPlayer.characterType = CharacterType.BF; + charPlayer.flip = true; + charPlayer.targetScale = 0.5; + + charPreviewToolbox.title = 'Player Preview - ${charPlayer.charName}'; + } + + if (charPreviewToolbox != null && !charPreviewToolbox.minimized) + { + charPreviewToolbox.width = charPlayer.width + 32; + charPreviewToolbox.height = charPlayer.height + 64; + } + } + } + + function handleOpponentPreviewToolbox():Void + { + // Manage the Select Difficulty tree view. + var charPreviewToolbox:Null = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); + if (charPreviewToolbox == null) return; + + // TODO: Re-enable the player preview once we figure out the performance issues. + var charPlayer:Null = null; // charPreviewToolbox.findComponent('charPlayer'); + if (charPlayer == null) return; + + currentOpponentCharacterPlayer = charPlayer; + + if (opponentPreviewDirty) + { + opponentPreviewDirty = false; + + if (currentSongMetadata.playData.characters.opponent != charPlayer.charId) + { + if (healthIconDad != null) + { + healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; + } + + charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent); + charPlayer.characterType = CharacterType.DAD; + charPlayer.flip = false; + charPlayer.targetScale = 0.5; + + charPreviewToolbox.title = 'Opponent Preview - ${charPlayer.charName}'; + } + + if (charPreviewToolbox != null && !charPreviewToolbox.minimized) + { + charPreviewToolbox.width = charPlayer.width + 32; + charPreviewToolbox.height = charPlayer.height + 64; + } + } + } + + function handleSelectionButtons():Void + { + // Make sure buttons are never nudged out of the correct spot. + // TODO: Why do these even move in the first place? The camera never moves, LOL. + buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + buttonSelectPlayer.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + buttonSelectEvent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2; + } + + /** + * Handles display elements for the playbar at the bottom. + */ + function handlePlaybar():Void + { + if (playbarHeadLayout == null) throw "ERROR: Tried to handle playbar, but playbarHeadLayout is null!"; + + // Move the playhead to match the song position, if we aren't dragging it. + playbarHeadLayout.playbarHead.pos = currentScrollEase; + + playbarHeadLayout.playbarHead.max = songLengthInPixels; + + // Make sure the playbar is never nudged out of the correct spot. + playbarHeadLayout.x = 4; + playbarHeadLayout.y = FlxG.height - 48 - 8; + + var songPos:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset; + var songPosMilliseconds:String = Std.string(Math.floor(Math.abs(songPos) % 1000)).lpad('0', 2).substr(0, 2); + var songPosSeconds:String = Std.string(Math.floor((Math.abs(songPos) / 1000) % 60)).lpad('0', 2); + var songPosMinutes:String = Std.string(Math.floor((Math.abs(songPos) / 1000) / 60)).lpad('0', 2); + if (songPos < 0) songPosMinutes = '-' + songPosMinutes; + 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); + var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2); + var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2); + var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}'; + + if (playbarSongRemaining.value != songRemainingString) playbarSongRemaining.value = songRemainingString; + + playbarNoteSnap.text = '1/${noteSnapQuant}'; + playbarDifficulty.text = '${selectedDifficulty.toTitleCase()}'; + playbarBPM.text = 'BPM: ${(Conductor.instance.bpm ?? 0.0)}'; + } + + function handlePlayhead():Void + { + // Place notes at the playhead with the keyboard. + for (note => key in LIVE_INPUT_KEYS[currentLiveInputStyle]) + { + if (FlxG.keys.checkStatus(key, JUST_PRESSED)) placeNoteAtPlayhead(note) + else if (FlxG.keys.checkStatus(key, JUST_RELEASED)) finishPlaceNoteAtPlayhead(note); + } + + // Place events at playhead. + if (FlxG.keys.justPressed.COMMA) placeEventAtPlayhead(true); + if (FlxG.keys.justPressed.PERIOD) placeEventAtPlayhead(false); + + updatePlayheadGhostHoldNotes(); + } + + function placeNoteAtPlayhead(column:Int):Void + { + // SHIFT + press or LEFT_SHOULDER + press to remove notes instead of placing them. + var removeNoteInstead:Bool = FlxG.keys.pressed.SHIFT || (FlxG.gamepads.firstActive?.pressed?.LEFT_SHOULDER ?? false); + + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; + var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); + var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio; + + // Look for notes within 1 step of the playhead. + var notesAtPos:Array = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs, + playheadPosSnappedMs + Conductor.instance.stepLengthMs * noteSnapRatio); + notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]); + + if (notesAtPos.length == 0 && !removeNoteInstead) + { + trace('Placing note. ${column}'); + var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace, ChartEditorState.cloneNoteParams(noteParamsToPlace)); + performCommand(new AddNotesCommand([newNoteData], pressingControl())); + currentLiveInputPlaceNoteData[column] = newNoteData; + } + else if (removeNoteInstead) + { + trace('Removing existing note at position. ${column}'); + performCommand(new RemoveNotesCommand(notesAtPos)); + } + else + { + trace('Already a note there. ${column}'); + } + } + + function placeEventAtPlayhead(isOpponent:Bool):Void + { + // SHIFT + press or LEFT_SHOULDER + press to remove events instead of placing them. + var removeEventInstead:Bool = FlxG.keys.pressed.SHIFT || (FlxG.gamepads.firstActive?.pressed?.LEFT_SHOULDER ?? false); + + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; + var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); + var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio; + + // Look for events within 1 step of the playhead. + var eventsAtPos:Array = SongDataUtils.getEventsInTimeRange(currentSongChartEventData, playheadPosSnappedMs, + playheadPosSnappedMs + Conductor.instance.stepLengthMs * noteSnapRatio); + eventsAtPos = SongDataUtils.getEventsWithKind(eventsAtPos, ['FocusCamera']); + + if (eventsAtPos.length == 0 && !removeEventInstead) + { + trace('Placing event ${isOpponent}'); + var newEventData:SongEventData = new SongEventData(playheadPosSnappedMs, 'FocusCamera', + { + char: isOpponent ? 1 : 0, + }); + performCommand(new AddEventsCommand([newEventData], pressingControl())); + } + else if (removeEventInstead) + { + trace('Removing existing event at position.'); + performCommand(new RemoveEventsCommand(eventsAtPos)); + } + else + { + trace('Already an event there.'); + } + } + + function updatePlayheadGhostHoldNotes():Void + { + // Ensure all the ghost hold notes exist. + while (gridPlayheadGhostHoldNotes.length < (STRUMLINE_SIZE * 2)) + { + var ghost = new ChartEditorHoldNoteSprite(this); + ghost.alpha = 0.6; + ghost.noteData = null; + ghost.visible = false; + ghost.zIndex = 11; + add(ghost); // Don't add to `renderedHoldNotes` because then it will get killed every frame. + + gridPlayheadGhostHoldNotes.push(ghost); + refresh(); + } + + // Update playhead ghost hold notes. + for (column in 0...gridPlayheadGhostHoldNotes.length) + { + var targetNoteData = currentLiveInputPlaceNoteData[column]; + var ghostHold = gridPlayheadGhostHoldNotes[column]; + + if (targetNoteData == null && ghostHold.noteData != null) + { + // Remove the ghost hold note. + ghostHold.noteData = null; + } + + if (targetNoteData != null && ghostHold.noteData == null) + { + // Readd the new ghost hold note. + ghostHold.noteData = targetNoteData.clone(); + ghostHold.noteDirection = ghostHold.noteData.getDirection(); + ghostHold.visible = true; + ghostHold.alpha = 0.6; + ghostHold.setHeightDirectly(0); + ghostHold.noteStyle = NoteKindManager.getNoteStyleId(ghostHold.noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + ghostHold.updateHoldNotePosition(renderedHoldNotes); + } + + if (ghostHold.noteData == null) + { + ghostHold.visible = false; + ghostHold.setHeightDirectly(0); + playheadDragLengthCurrent[column] = 0; + continue; + } + + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; + var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); + var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio; + + var newNoteLength:Float = playheadPosSnappedMs - ghostHold.noteData.time; + trace('newNoteLength: ${newNoteLength}'); + + if (newNoteLength > 0) + { + ghostHold.noteData.length = newNoteLength; + var targetNoteLengthSteps:Float = ghostHold.noteData.getStepLength(true); + var targetNoteLengthStepsInt:Int = Std.int(Math.floor(targetNoteLengthSteps)); + var targetNoteLengthPixels:Float = targetNoteLengthSteps * GRID_SIZE; + + if (playheadDragLengthCurrent[column] != targetNoteLengthStepsInt) + { + stretchySounds = !stretchySounds; + this.playSound(Paths.sound('chartingSounds/stretch' + (stretchySounds ? '1' : '2') + '_UI')); + playheadDragLengthCurrent[column] = targetNoteLengthStepsInt; + } + ghostHold.visible = true; + ghostHold.alpha = 0.6; + ghostHold.setHeightDirectly(targetNoteLengthPixels, true); + ghostHold.updateHoldNotePosition(renderedHoldNotes); + trace('lerpLength: ${ghostHold.fullSustainLength}'); + trace('position: ${ghostHold.x}, ${ghostHold.y}'); + } + else + { + ghostHold.visible = false; + ghostHold.setHeightDirectly(0); + playheadDragLengthCurrent[column] = 0; + continue; + } + } + } + + function finishPlaceNoteAtPlayhead(column:Int):Void + { + if (currentLiveInputPlaceNoteData[column] == null) return; + + var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; + var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; + var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); + var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio; + + var newNoteLength:Float = playheadPosSnappedMs - currentLiveInputPlaceNoteData[column].time; + trace('finishPlace newNoteLength: ${newNoteLength}'); + + if (newNoteLength < Conductor.instance.stepLengthMs) + { + // Don't extend the note if it's too short. + trace('Not extending note. ${column}'); + currentLiveInputPlaceNoteData[column] = null; + gridPlayheadGhostHoldNotes[column].noteData = null; + } + else + { + // Extend the note to the playhead position. + trace('Extending note. ${column}'); + this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI')); + performCommand(new ExtendNoteLengthCommand(currentLiveInputPlaceNoteData[column], newNoteLength)); + currentLiveInputPlaceNoteData[column] = null; + gridPlayheadGhostHoldNotes[column].noteData = null; + } + } + + /** + * Handle aligning the health icons next to the grid. + */ + function handleHealthIcons():Void + { + if (healthIconsDirty) + { + var charDataBF = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.player); + var charDataDad = CharacterDataParser.fetchCharacterData(currentSongMetadata.playData.characters.opponent); + if (healthIconBF != null) + { + healthIconBF.configure(charDataBF?.healthIcon); + healthIconBF.size *= 0.5; // Make the icon smaller in Chart Editor. + healthIconBF.flipX = !healthIconBF.flipX; // BF faces the other way. + } + if (buttonSelectPlayer != null) + { + buttonSelectPlayer.text = charDataBF?.name ?? 'Player'; + } + if (healthIconDad != null) + { + healthIconDad.configure(charDataDad?.healthIcon); + healthIconDad.size *= 0.5; // Make the icon smaller in Chart Editor. + } + if (buttonSelectOpponent != null) + { + buttonSelectOpponent.text = charDataDad?.name ?? 'Opponent'; + } + healthIconsDirty = false; + } + + // Right align, and visibly center, the BF health icon. + if (healthIconBF != null) + { + // Base X position to the right of the grid. + var xOffset = 45 - (healthIconBF.width / 2); + healthIconBF.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS + gridTiledSprite.width + xOffset); + var yOffset = 30 - (healthIconBF.height / 2); + healthIconBF.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset; + } + + // Visibly center the Dad health icon. + if (healthIconDad != null) + { + var xOffset = 75 + (healthIconDad.width / 2); + healthIconDad.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS - xOffset); + var yOffset = 30 - (healthIconDad.height / 2); + healthIconDad.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset; + } + } + + /** + * Handle keybinds for File menu items. + */ + function handleFileKeybinds():Void + { + // CTRL + N = New Chart + if (pressingControl() && FlxG.keys.justPressed.N && !isHaxeUIDialogOpen) + { + this.openWelcomeDialog(true); + } + + // CTRL + O = Open Chart + if (pressingControl() && FlxG.keys.justPressed.O && !isHaxeUIDialogOpen) + { + this.openBrowseFNFC(true); + } + + if (pressingControl() && FlxG.keys.justPressed.S && !isHaxeUIDialogOpen) + { + if (currentWorkingFilePath == null || FlxG.keys.pressed.SHIFT) + { + // CTRL + SHIFT + S = Save As + this.exportAllSongData(false, null, function(path:String) { + // CTRL + SHIFT + S Successful + this.success('Saved Chart', 'Chart saved successfully to ${path}.'); + }, function() { + // CTRL + SHIFT + S Cancelled + }); + } + else + { + // CTRL + S = Save Chart + this.exportAllSongData(true, currentWorkingFilePath); + this.success('Saved Chart', 'Chart saved successfully to ${currentWorkingFilePath}.'); + } + } + + // CTRL + Q = Quit to Menu + if (pressingControl() && FlxG.keys.justPressed.Q) + { + quitChartEditor(); + } + } + + @:nullSafety(Off) + function quitChartEditor():Void + { + autoSave(); + stopWelcomeMusic(); + // TODO: PR Flixel to make onComplete nullable. + if (audioInstTrack != null) audioInstTrack.onComplete = null; + FlxG.switchState(() -> new MainMenuState()); + + resetWindowTitle(); + + criticalFailure = true; + } + + /** + * Handle keybinds for edit menu items. + */ + function handleEditKeybinds():Void + { + // CTRL + Z = Undo + if (undoKeyHandler.activated) + { + undoLastCommand(); + } + + // CTRL + Y = Redo + if (redoKeyHandler.activated) + { + redoLastCommand(); + } + + // CTRL + C = Copy + if (pressingControl() && FlxG.keys.justPressed.C) + { + performCommand(new CopyItemsCommand(currentNoteSelection, currentEventSelection)); + } + + // CTRL + X = Cut + if (pressingControl() && FlxG.keys.justPressed.X) + { + // Cut selected notes. + performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); + } + + // CTRL + V = Paste + if (pressingControl() && FlxG.keys.justPressed.V) + { + // CTRL + SHIFT + V = Paste Unsnapped. + var targetMs:Float = if (FlxG.keys.pressed.SHIFT) + { + scrollPositionInMs + playheadPositionInMs; + } + else + { + var targetMs:Float = scrollPositionInMs + playheadPositionInMs; + var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs); + var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio; + var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep); + targetSnappedMs; + } + performCommand(new PasteItemsCommand(targetMs)); + } + + // DELETE = Delete + var delete:Bool = FlxG.keys.justPressed.DELETE; + + // on macbooks, Delete == backspace + #if mac + delete = delete || FlxG.keys.justPressed.BACKSPACE; + #end + + if (delete) + { + // Delete selected items. + if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) + { + performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection)); + } + else if (currentNoteSelection.length > 0) + { + performCommand(new RemoveNotesCommand(currentNoteSelection)); + } + else if (currentEventSelection.length > 0) + { + performCommand(new RemoveEventsCommand(currentEventSelection)); + } + } + + // CTRL + F = Flip Notes + if (pressingControl() && FlxG.keys.justPressed.F) + { + // Flip selected notes. + performCommand(new FlipNotesCommand(currentNoteSelection)); + } + + // CTRL + A = Select All Notes + if (pressingControl() && FlxG.keys.justPressed.A) + { + // Select all items. + if (FlxG.keys.pressed.ALT) + { + if (FlxG.keys.pressed.SHIFT) + { + // CTRL + ALT + SHIFT + A = Append All Events to Selection + performCommand(new SelectItemsCommand([], currentSongChartEventData)); + } + else + { + // CTRL + ALT + A = Set Selection to All Events + performCommand(new SelectAllItemsCommand(false, true)); + } + } + else + { + if (FlxG.keys.pressed.SHIFT) + { + // CTRL + SHIFT + A = Append All Notes to Selection + performCommand(new SelectItemsCommand(currentSongChartNoteData, [])); + } + else + { + // CTRL + A = Set Selection to All Notes + performCommand(new SelectAllItemsCommand(true, false)); + } + } + } + + // CTRL + I = Select Inverse + if (pressingControl() && FlxG.keys.justPressed.I) + { + // Select unselected items and deselect selected items. + performCommand(new InvertSelectedItemsCommand()); + } + + // CTRL + D = Select None + if (pressingControl() && FlxG.keys.justPressed.D) + { + // Deselect all items. + performCommand(new DeselectAllItemsCommand()); + } + } + + /** + * Handle keybinds for View menu items. + */ + function handleViewKeybinds():Void + { + if (currentLiveInputStyle == None) + { + if (pressingControl() && FlxG.keys.justPressed.LEFT) + { + incrementDifficulty(-1); + } + if (pressingControl() && FlxG.keys.justPressed.RIGHT) + { + incrementDifficulty(1); + } + // Would bind Ctrl+A and Ctrl+D here, but they are already bound to Select All and Select None. + } + else + { + // trace('Ignoring keybinds for View menu items because we are in live input mode (${currentLiveInputStyle}).'); + } + } + + /** + * Small helper for MacOS, "WINDOWS" is keycode 15, which maps to "COMMAND" on Mac, which is more often used than "CONTROL" + * Everywhere else, it just returns `FlxG.keys.pressed.CONTROL` + * @return Bool + */ + function pressingControl():Bool + { + #if mac + return FlxG.keys.pressed.WINDOWS; + #else + return FlxG.keys.pressed.CONTROL; + #end + } + + /** + * Handle keybinds for the Test menu items. + */ + function handleTestKeybinds():Void + { + if (!isHaxeUIDialogOpen && !isHaxeUIFocused && FlxG.keys.justPressed.ENTER) + { + var minimal = FlxG.keys.pressed.SHIFT; + this.hideAllToolboxes(); + testSongInPlayState(minimal); + } + } + + /** + * Handle keybinds for Help menu items. + */ + function handleHelpKeybinds():Void + { + // F1 = Open Help + if (FlxG.keys.justPressed.F1 && !isHaxeUIDialogOpen) + { + this.openUserGuideDialog(); + } + } + + function handleQuickWatch():Void + { + FlxG.watch.addQuick('musicTime', audioInstTrack?.time ?? 0.0); + + FlxG.watch.addQuick('noteKindToPlace', noteKindToPlace); + FlxG.watch.addQuick('noteParamsToPlace', noteParamsToPlace); + FlxG.watch.addQuick('eventKindToPlace', eventKindToPlace); + + FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels); + FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels); + + FlxG.watch.addQuick("tapNotesRendered", renderedNotes?.members?.length); + FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes?.members?.length); + FlxG.watch.addQuick("eventsRendered", renderedEvents?.members?.length); + FlxG.watch.addQuick("notesSelected", currentNoteSelection?.length); + FlxG.watch.addQuick("eventsSelected", currentEventSelection?.length); + } + + function handlePostUpdate():Void + { + wasCursorOverHaxeUI = isCursorOverHaxeUI; + } + + /** + * PLAYTEST FUNCTIONS + */ + // ==================== + + /** + * Transitions to the Play State to test the song + */ + function testSongInPlayState(minimal:Bool = false):Void + { + autoSave(true); + + stopWelcomeMusic(); + stopAudioPlayback(); + + var startTimestamp:Float = 0; + if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; + + var playbackRate:Float = ((menubarItemPlaybackSpeed.value ?? 1.0) * 2.0) / 100.0; + playbackRate = Math.floor(playbackRate / 0.05) * 0.05; // Round to nearest 5% + playbackRate = Math.max(0.05, Math.min(2.0, playbackRate)); // Clamp to 5% to 200% + + var targetSong:Song; + try + { + targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, playtestSongScripts, false); + } + catch (e) + { + this.error('Could Not Playtest', 'Got an error trying to playtest the song.\n${e}'); + return; + } + + // TODO: Rework asset system so we can remove this jank. + switch (currentSongStage) + { + case 'mainStage' | 'mainStageErect': + PlayStatePlaylist.campaignId = 'week1'; + case 'spookyMansion' | 'spookyMansionErect': + PlayStatePlaylist.campaignId = 'week2'; + case 'phillyTrain' | 'phillyTrainErect': + PlayStatePlaylist.campaignId = 'week3'; + case 'limoRide' | 'limoRideErect': + PlayStatePlaylist.campaignId = 'week4'; + case 'mallXmas' | 'mallXmasErect' | 'mallEvil': + PlayStatePlaylist.campaignId = 'week5'; + case 'school' | 'schoolEvil': + PlayStatePlaylist.campaignId = 'week6'; + case 'tankmanBattlefield': + PlayStatePlaylist.campaignId = 'week7'; + case 'phillyStreets' | 'phillyStreetsErect' | 'phillyBlazin' | 'phillyBlazin2': + PlayStatePlaylist.campaignId = 'weekend1'; + } + Paths.setCurrentLevel(PlayStatePlaylist.campaignId); + + subStateClosed.add(reviveUICamera); + subStateClosed.add(resetConductorAfterTest); + + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; + + var targetStateParams = + { + targetSong: targetSong, + targetDifficulty: selectedDifficulty, + targetVariation: selectedVariation, + practiceMode: playtestPracticeMode, + botPlayMode: playtestBotPlayMode, + minimalMode: minimal, + startTimestamp: startTimestamp, + playbackRate: playbackRate, + overrideMusic: true, + }; + + // Override music. + if (audioInstTrack != null) + { + FlxG.sound.music = audioInstTrack; + } + + // Kill and replace the UI camera so it doesn't get destroyed during the state transition. + uiCamera.kill(); + FlxG.cameras.remove(uiCamera, false); + FlxG.cameras.reset(new FunkinCamera('chartEditorUI2')); + + this.persistentUpdate = false; + this.persistentDraw = false; + stopWelcomeMusic(); + + LoadingState.loadPlayState(targetStateParams, false, true, function(targetState) { + targetState.vocals = audioVocalTrackGroup; + }); + } + + /** + * COMMAND FUNCTIONS + */ + // ==================== + + /** + * Perform (or redo) a command, then add it to the undo stack. + * + * @param command The command to perform. + * @param purgeRedoStack If `true`, the redo stack will be cleared after performing the command. + */ + function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void + { + command.execute(this); + if (command.shouldAddToHistory(this)) + { + undoHistory.push(command); + commandHistoryDirty = true; + } + if (purgeRedoStack) redoHistory = []; + } + + /** + * Undo a command, then add it to the redo stack. + * @param command The command to undo. + */ + function undoCommand(command:ChartEditorCommand):Void + { + command.undo(this); + // Note, if we are undoing a command, it should already be in the history, + // therefore we don't need to check `shouldAddToHistory(state)` + redoHistory.push(command); + commandHistoryDirty = true; + } + + /** + * Undo the last command in the undo stack, then add it to the redo stack. + */ + function undoLastCommand():Void + { + var command:Null = undoHistory.pop(); + if (command == null) + { + trace('No actions to undo.'); + return; + } + undoCommand(command); + } + + /** + * Redo the last command in the redo stack, then add it to the undo stack. + */ + function redoLastCommand():Void + { + var command:Null = redoHistory.pop(); + if (command == null) + { + trace('No actions to redo.'); + return; + } + performCommand(command, false); + } + + /** + * GRAPHICS FUNCTIONS + */ + // ==================== + + /** + * This is for the smaller green squares that appear over each note when you select them. + */ + function buildSelectionSquare():ChartEditorSelectionSquareSprite + { + if (selectionSquareBitmap == null) + throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()"; + + // FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap"); + var result = new ChartEditorSelectionSquareSprite(this); + result.loadGraphic(selectionSquareBitmap); + return result; + } + + /** + * Revive the UI camera and re-establish it as the main camera so UI elements depending on it don't explode. + */ + function reviveUICamera(_:FlxSubState = null):Void + { + uiCamera.revive(); + FlxG.cameras.reset(uiCamera); + uiCamera.onResize(); + + add(this.root); + } + + /** + * AUDIO FUNCTIONS + */ + // ==================== + + function startAudioPlayback():Void + { + if (audioInstTrack != null) + { + audioInstTrack.play(false, audioInstTrack.time); + audioVocalTrackGroup.play(false, audioInstTrack.time); + } + + playbarPlay.text = '||'; // Pause + } + + /** + * Play the metronome tick sound. + * @param high Whether to play the full beat sound rather than the quarter beat sound. + */ + function playMetronomeTick(high:Bool = false):Void + { + this.playSound(Paths.sound('chartingSounds/metronome${high ? '1' : '2'}'), metronomeVolume); + } + + function switchToCurrentInstrumental():Void + { + // ChartEditorAudioHandler + this.switchToInstrumental(currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent); + } + + public function updateGridHeight():Void + { + // Make sure playhead doesn't go outside the song after we update the grid height. + if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs; + + if (gridTiledSprite != null) + { + gridTiledSprite.height = songLengthInPixels; + } + if (measureTicks != null) + { + measureTicks.setHeight(songLengthInPixels); + } + + // Remove any notes past the end of the song. + var songCutoffPointSteps:Float = songLengthInSteps - 0.1; + var songCutoffPointMs:Float = Conductor.instance.getStepTimeInMs(songCutoffPointSteps); + currentSongChartNoteData = SongDataUtils.clampSongNoteData(currentSongChartNoteData, 0.0, songCutoffPointMs); + currentSongChartEventData = SongDataUtils.clampSongEventData(currentSongChartEventData, 0.0, songCutoffPointMs); + + scrollPositionInPixels = 0; + playheadPositionInPixels = 0; + notePreviewDirty = true; + notePreviewViewportBoundsDirty = true; + noteDisplayDirty = true; + moveSongToScrollPosition(); + } + + /** + * CHART DATA FUNCTIONS + */ + // ==================== + + function sortChartData():Void + { + // TODO: .insertionSort() + currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + + // TODO: .insertionSort() + currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + } + + function isEventSelected(event:Null):Bool + { + return event != null && currentEventSelection.indexOf(event) != -1; + } + + function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0):Void + { + var variationMetadata:Null = songMetadata.get(variation); + if (variationMetadata == null) return; + + variationMetadata.playData.difficulties.push(difficulty); + + var resultChartData = songChartData.get(variation); + if (resultChartData == null) + { + resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]); + songChartData.set(variation, resultChartData); + } + else + { + resultChartData.scrollSpeed.set(difficulty, scrollSpeed); + resultChartData.notes.set(difficulty, []); + } + + difficultySelectDirty = true; // Force the Difficulty toolbox to update. + } + + function removeDifficulty(variation:String, difficulty:String):Void + { + var variationMetadata:Null = songMetadata.get(variation); + if (variationMetadata == null) return; + + variationMetadata.playData.difficulties.remove(difficulty); + + var resultChartData = songChartData.get(variation); + if (resultChartData != null) + { + resultChartData.scrollSpeed.remove(difficulty); + resultChartData.notes.remove(difficulty); + } + + if (songMetadata.size() > 1) + { + if (variationMetadata.playData.difficulties.length == 0) + { + songMetadata.remove(variation); + songChartData.remove(variation); + } + + if (variation == selectedVariation) + { + var firstVariation = songMetadata.keyValues()[0]; + if (firstVariation != null) selectedVariation = firstVariation; + variationMetadata = songMetadata.get(selectedVariation); + } + } + + if (selectedDifficulty == difficulty + || !variationMetadata.playData.difficulties.contains(selectedDifficulty)) selectedDifficulty = variationMetadata.playData.difficulties[0]; + + difficultySelectDirty = true; // Force the Difficulty toolbox to update. + } + + function incrementDifficulty(change:Int):Void + { + var currentDifficultyIndex:Int = availableDifficulties.indexOf(selectedDifficulty); + var currentAllDifficultyIndex:Int = allDifficulties.indexOf(selectedDifficulty); + + if (currentDifficultyIndex == -1 || currentAllDifficultyIndex == -1) + { + trace('ERROR determining difficulty index!'); + } + + var isFirstDiff:Bool = currentAllDifficultyIndex == 0; + var isLastDiff:Bool = (currentAllDifficultyIndex == allDifficulties.length - 1); + + var isFirstDiffInVariation:Bool = currentDifficultyIndex == 0; + var isLastDiffInVariation:Bool = (currentDifficultyIndex == availableDifficulties.length - 1); + + trace(allDifficulties); + + if (change < 0 && isFirstDiff) + { + trace('At lowest difficulty! Do nothing.'); + return; + } + + if (change > 0 && isLastDiff) + { + trace('At highest difficulty! Do nothing.'); + return; + } + + if (change < 0) + { + trace('Decrement difficulty.'); + + // If we reached this point, we are not at the lowest difficulty. + if (isFirstDiffInVariation) + { + // Go to the previous variation, then last difficulty in that variation. + var currentVariationIndex:Int = availableVariations.indexOf(selectedVariation); + var prevVariation = availableVariations[currentVariationIndex - 1]; + selectedVariation = prevVariation; + + var prevDifficulty = availableDifficulties[availableDifficulties.length - 1]; + selectedDifficulty = prevDifficulty; + + Conductor.instance.mapTimeChanges(this.currentSongMetadata.timeChanges); + updateTimeSignature(); + + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + else + { + // Go to previous difficulty in this variation. + var prevDifficulty = availableDifficulties[currentDifficultyIndex - 1]; + selectedDifficulty = prevDifficulty; + + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + } + else + { + trace('Increment difficulty.'); + + // If we reached this point, we are not at the highest difficulty. + if (isLastDiffInVariation) + { + // Go to next variation, then first difficulty in that variation. + var currentVariationIndex:Int = availableVariations.indexOf(selectedVariation); + var nextVariation = availableVariations[currentVariationIndex + 1]; + selectedVariation = nextVariation; + + var nextDifficulty = availableDifficulties[0]; + selectedDifficulty = nextDifficulty; + + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + } + else + { + // Go to next difficulty in this variation. + var nextDifficulty = availableDifficulties[currentDifficultyIndex + 1]; + selectedDifficulty = nextDifficulty; + + this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); + this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + } + } + + // Removed this notification because you can see your difficulty in the playbar now. + // this.success('Switch Difficulty', 'Switched difficulty to ${selectedDifficulty.toTitleCase()}'); + } + + /** + * SCROLLING FUNCTIONS + */ + // ==================== + + /** + * When setting the scroll position, except when automatically scrolling during song playback, + * we need to update the conductor's current step time and the timestamp of the audio tracks. + */ + function moveSongToScrollPosition():Void + { + // Update the songPosition in the audio tracks. + if (audioInstTrack != null) + { + audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset; + // Update the songPosition in the Conductor. + Conductor.instance.update(audioInstTrack.time); + audioVocalTrackGroup.time = audioInstTrack.time; + } + + // We need to update the note sprites because we changed the scroll position. + noteDisplayDirty = true; + } + + /** + * Smoothly ease the song to a new scroll position over a duration. + * @param targetScrollPosition The desired value for the `scrollPositionInPixels`. + */ + function easeSongToScrollPosition(targetScrollPosition:Float):Void + { + currentScrollEase = Math.max(0, targetScrollPosition); + currentScrollEase = Math.min(currentScrollEase, songLengthInPixels); + scrollPositionInPixels = MathUtil.smoothLerp(scrollPositionInPixels, currentScrollEase, FlxG.elapsed, SCROLL_EASE_DURATION, 1 / 1000); + moveSongToScrollPosition(); + } + + /** + * Fix the current scroll position after exiting the PlayState used when testing. + */ + @:nullSafety(Off) + function resetConductorAfterTest(_:FlxSubState = null):Void + { + this.persistentUpdate = true; + this.persistentDraw = true; + + if (displayAutosavePopup) + { + displayAutosavePopup = false; + #if sys + Toolkit.callLater(() -> { + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]); + this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [ + { + text: "Take Me There", + callback: openBackupsFolder, + } + ]); + }); + #else + // TODO: No auto-save on HTML5? + #end + } + + moveSongToScrollPosition(); + + fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); + + // Reapply the volume. + var instTargetVolume:Float = menubarItemVolumeInstrumental.value ?? 1.0; + var vocalPlayerTargetVolume:Float = menubarItemVolumeVocalsPlayer.value ?? 1.0; + var vocalOpponentTargetVolume:Float = menubarItemVolumeVocalsOpponent.value ?? 1.0; + + if (audioInstTrack != null) + { + audioInstTrack.volume = instTargetVolume; + audioInstTrack.onComplete = null; + } + if (audioVocalTrackGroup != null) + { + audioVocalTrackGroup.playerVolume = vocalPlayerTargetVolume; + audioVocalTrackGroup.opponentVolume = vocalOpponentTargetVolume; + } + } + + function updateTimeSignature():Void + { + // Redo the grid bitmap to be 4/4. + this.updateTheme(); + gridTiledSprite.loadGraphic(gridBitmap); + measureTicks.reloadTickBitmap(); + } + + /** + * HAXEUI FUNCTIONS + */ + // ================== + + /** + * STATIC FUNCTIONS + */ + // ================== + + function handleNotePreview():Void + { + if (notePreviewDirty && notePreview != null) + { + notePreviewDirty = false; + + // TODO: Only update the notes that have changed. + notePreview.erase(); + notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs)); + notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs)); + notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs)); + } + + if (notePreviewViewportBoundsDirty) + { + setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); + notePreviewViewportBoundsDirty = false; + } + } + + /** + * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. + * Does not handle onClick ACTIONS of the menubar. + */ + function handleMenubar():Void + { + if (commandHistoryDirty) + { + commandHistoryDirty = false; + + // Update the Undo and Redo buttons. + if (undoHistory.length == 0) + { + // Disable the Undo button. + menubarItemUndo.disabled = true; + menubarItemUndo.text = 'Undo'; + } + else + { + // Change the label to the last command. + menubarItemUndo.disabled = false; + menubarItemUndo.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}'; + } + + if (redoHistory.length == 0) + { + // Disable the Redo button. + menubarItemRedo.disabled = true; + menubarItemRedo.text = 'Redo'; + } + else + { + // Change the label to the last command. + menubarItemRedo.disabled = false; + menubarItemRedo.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}'; + } + } + } + + /** + * Handle the playback of hitsounds. + */ + function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void + { + if (!hitsoundsEnabled) return; + + // Assume notes are sorted by time. + for (noteData in currentSongChartNoteData) + { + // Check for notes between the old and new song positions. + + if (noteData.time < oldSongPosition) // Note is in the past. + continue; + + if (noteData.time > newSongPosition) // Note is in the future. + return; // Assume all notes are also in the future. + + // Note was just hit. + + // Character preview. + + // NoteScriptEvent takes a sprite, ehe. Need to rework that. + var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault()); + tempNote.noteData = noteData; + tempNote.scrollFactor.set(0, 0); + var event:NoteScriptEvent = new HitNoteScriptEvent(tempNote, 0.0, 0, 'perfect', false, 0); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) continue; + + // Hitsounds. + switch (noteData.getStrumlineIndex()) + { + case 0: // Player + if (hitsoundVolumePlayer > 0) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolumePlayer); + case 1: // Opponent + if (hitsoundVolumeOpponent > 0) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolumeOpponent); + } + } + } + + function stopAudioPlayback():Void + { + if (audioInstTrack != null) audioInstTrack.pause(); + audioVocalTrackGroup.pause(); + + playbarPlay.text = '>'; + } + + function toggleAudioPlayback():Void + { + if (audioInstTrack == null) return; + + currentScrollEase = this.scrollPositionInPixels; + + if (audioInstTrack.isPlaying) + { + // Pause + stopAudioPlayback(); + fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION); + } + else + { + // Play + startAudioPlayback(); + stopWelcomeMusic(); + } + } + + public function postLoadInstrumental():Void + { + if (audioInstTrack != null) + { + // Prevent the time from skipping back to 0 when the song ends. + audioInstTrack.onComplete = function() { + if (audioInstTrack != null) + { + audioInstTrack.pause(); + // Keep the track at the end. + audioInstTrack.time = audioInstTrack.length; + } + audioVocalTrackGroup.pause(); + }; + } + else + { + trace('ERROR: Instrumental track is null!'); + } + + Conductor.instance.mapTimeChanges(this.currentSongMetadata.timeChanges); + updateTimeSignature(); + + this.songLengthInMs = (audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + Conductor.instance.currentTimeChange.bpm = currentSongMetadata.timeChanges[0].bpm; + + // Many things get reset when song length changes. + healthIconsDirty = true; + } + + function hardRefreshOffsetsToolbox():Void + { + var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + if (offsetsToolbox != null) + { + offsetsToolbox.refreshAudioPreview(); + offsetsToolbox.refresh(); + } + } + + function hardRefreshFreeplayToolbox():Void + { + var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT); + if (freeplayToolbox != null) + { + freeplayToolbox.refreshAudioPreview(); + freeplayToolbox.refresh(); + } + } + + /** + * Clear the voices group. + */ + public function clearVocals():Void + { + audioVocalTrackGroup.clear(); + } + + function isNoteSelected(note:Null):Bool + { + return note != null && currentNoteSelection.indexOf(note) != -1; + } + + override function destroy():Void + { + super.destroy(); + + cleanupAutoSave(); + + this.closeAllMenus(); + + // Hide the mouse cursor on other states. + Cursor.hide(); + + @:privateAccess + ChartEditorNoteSprite.noteFrameCollection = null; + + // Stop the music. + if (welcomeMusic != null) welcomeMusic.destroy(); + if (audioInstTrack != null) audioInstTrack.destroy(); + if (audioVocalTrackGroup != null) audioVocalTrackGroup.destroy(); + } + + function applyCanQuickSave():Void + { + if (menubarItemSaveChart == null) return; + + if (currentWorkingFilePath == null) + { + menubarItemSaveChart.disabled = true; + } + else + { + menubarItemSaveChart.disabled = false; + } + } + + function applyWindowTitle():Void + { + var inner:String = 'New Chart'; + var cwfp:Null = currentWorkingFilePath; + if (cwfp != null) + { + inner = cwfp; + } + if (currentWorkingFilePath == null || saveDataDirty) + { + inner += '*'; + } + WindowUtil.setWindowTitle('Friday Night Funkin\' Chart Editor - ${inner}'); + } + + function resetWindowTitle():Void + { + WindowUtil.setWindowTitle('Friday Night Funkin\''); + } + + /** + * Convert a note data value into a chart editor grid column number. + */ + public static function noteDataToGridColumn(input:Int):Int + { + if (input < 0) input = 0; + if (input >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1)) + { + // Don't invert the Event column. + input = (ChartEditorState.STRUMLINE_SIZE * 2 + 1); + } + else + { + // Invert player and opponent columns. + if (input >= ChartEditorState.STRUMLINE_SIZE) + { + input -= ChartEditorState.STRUMLINE_SIZE; + } + else + { + input += ChartEditorState.STRUMLINE_SIZE; + } + } + return input; + } + + /** + * Convert a chart editor grid column number into a note data value. + */ + public static function gridColumnToNoteData(input:Int):Int + { + if (input < 0) input = 0; + if (input >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1)) + { + // Don't invert the Event column. + input = (ChartEditorState.STRUMLINE_SIZE * 2 + 1); + } + else + { + // Invert player and opponent columns. + if (input >= ChartEditorState.STRUMLINE_SIZE) + { + input -= ChartEditorState.STRUMLINE_SIZE; + } + else + { + input += ChartEditorState.STRUMLINE_SIZE; + } + } + return input; + } + + public static function cloneNoteParams(paramsToClone:Array):Array + { + var params:Array = []; + for (param in paramsToClone) + { + params.push(param.clone()); + } + return params; + } +} + +/** + * Available input modes for the chart editor state. Numbers/arrows/WASD available for other keybinds. + */ +enum abstract ChartEditorLiveInputStyle(String) +{ + /** + * No hotkeys to place notes at the playbar. + */ + var None; + + /** + * 1/2/3/4 to place notes on opponent's side, 5/6/7/8 to place notes on player's side. + */ + var NumberKeys; + + /** + * WASD to place notes on opponent's side, Arrow keys to place notes on player's side. + */ + var WASDKeys; +} + +typedef ChartEditorParams = +{ + /** + * If non-null, load this song immediately instead of the welcome screen. + */ + var ?fnfcTargetPath:String; + + /** + * If non-null, load this song immediately instead of the welcome screen. + */ + var ?targetSongId:String; +}; + +/** + * Available themes for the chart editor state. + */ +enum abstract ChartEditorTheme(String) +{ + /** + * The default theme for the chart editor. + */ + var Light; + + /** + * A theme which introduces darker colors. + */ + var Dark; +} diff --git a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx new file mode 100644 index 00000000..a878ee68 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx @@ -0,0 +1,73 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Adds the given events to the current chart in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class AddEventsCommand implements ChartEditorCommand +{ + var events:Array; + var appendToSelection:Bool; + + public function new(events:Array, appendToSelection:Bool = false) + { + this.events = events; + this.appendToSelection = appendToSelection; + } + + public function execute(state:ChartEditorState):Void + { + for (event in events) + { + state.currentSongChartEventData.push(event); + } + + if (appendToSelection) + { + state.currentEventSelection = state.currentEventSelection.concat(events); + } + else + { + state.currentNoteSelection = []; + state.currentEventSelection = events; + } + + state.playSound(Paths.sound('chartingSounds/noteLay')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + + public function toString():String + { + var len:Int = events.length; + return 'Add $len Events'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx new file mode 100644 index 00000000..ea984c82 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx @@ -0,0 +1,78 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Adds the given notes to the current chart in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class AddNotesCommand implements ChartEditorCommand +{ + var notes:Array; + var appendToSelection:Bool; + + public function new(notes:Array, appendToSelection:Bool = false) + { + this.notes = notes; + this.appendToSelection = appendToSelection; + } + + public function execute(state:ChartEditorState):Void + { + for (note in notes) + { + state.currentSongChartNoteData.push(note); + } + + if (appendToSelection) + { + state.currentNoteSelection = state.currentNoteSelection.concat(notes); + } + else + { + state.currentNoteSelection = notes; + state.currentEventSelection = []; + } + + state.playSound(Paths.sound('chartingSounds/noteLay')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentNoteSelection = []; + state.currentEventSelection = []; + state.playSound(Paths.sound('chartingSounds/undo')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + + public function toString():String + { + if (notes.length == 1) + { + var dir:String = notes[0].getDirectionName(); + return 'Add $dir Note'; + } + + return 'Add ${notes.length} Notes'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx new file mode 100644 index 00000000..a3728f33 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx @@ -0,0 +1,81 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongTimeChange; + +/** + * A command which changes the starting BPM of the song. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChangeStartingBPMCommand implements ChartEditorCommand +{ + var targetBPM:Float; + + var previousBPM:Float = 100; + + public function new(targetBPM:Float) + { + this.targetBPM = targetBPM; + } + + public function execute(state:ChartEditorState):Void + { + var timeChanges:Array = state.currentSongMetadata.timeChanges; + if (timeChanges == null || timeChanges.length == 0) + { + previousBPM = 100; + timeChanges = [new SongTimeChange(0, targetBPM)]; + } + else + { + previousBPM = timeChanges[0].bpm; + timeChanges[0].bpm = targetBPM; + } + + state.currentSongMetadata.timeChanges = timeChanges; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + + state.updateGridHeight(); + } + + public function undo(state:ChartEditorState):Void + { + var timeChanges:Array = state.currentSongMetadata.timeChanges; + if (timeChanges == null || timeChanges.length == 0) + { + timeChanges = [new SongTimeChange(0, previousBPM)]; + } + else + { + timeChanges[0].bpm = previousBPM; + } + + state.currentSongMetadata.timeChanges = timeChanges; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + + Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges); + + state.updateGridHeight(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (targetBPM != previousBPM); + } + + public function toString():String + { + return 'Change Starting BPM to ${targetBPM}'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx new file mode 100644 index 00000000..1fa86ad9 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx @@ -0,0 +1,41 @@ +package funkin.ui.debug.charting.commands; + +/** + * Actions in the chart editor are backed by the Command pattern + * (see Bob Nystrom's book "Game Programming Patterns" for more info) + * + * To make a functionality compatible with the undo/redo history, create a new class + * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())` + * + * NOTE: Make the constructor very simple, as it may be called without executing by the command palette. + */ +interface ChartEditorCommand +{ + /** + * Calling this function should perform the action that this command represents. + * @param state The ChartEditorState to perform the action on. + */ + public function execute(state:ChartEditorState):Void; + + /** + * Calling this function should perform the inverse of the action that this command represents, + * effectively undoing the action. Assume that the original action was the last action performed. + * @param state The ChartEditorState to undo the action on. + */ + public function undo(state:ChartEditorState):Void; + + /** + * Return whether or not this command should be appended to the in the undo/redo history. + * Generally this should be true, it should only be false if the command is minor and non-destructive, + * like copying to the clipboard. + * + * Called after `execute()` is performed. + */ + public function shouldAddToHistory(state:ChartEditorState):Bool; + + /** + * Get a short description of the action (for the UI). + * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu. + */ + public function toString():String; +} diff --git a/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx new file mode 100644 index 00000000..376d0ceb --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx @@ -0,0 +1,170 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * without deleting them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class CopyItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + // Calculate a single time offset for all the notes and events. + var timeOffset:Null = state.currentNoteSelection.length > 0 ? Std.int(state.currentNoteSelection[0].time) : null; + if (state.currentEventSelection.length > 0) + { + if (timeOffset == null || state.currentEventSelection[0].time < timeOffset) + { + timeOffset = Std.int(state.currentEventSelection[0].time); + } + } + + SongDataUtils.writeItemsToClipboard( + { + notes: SongDataUtils.buildNoteClipboard(state.currentNoteSelection, timeOffset), + events: SongDataUtils.buildEventClipboard(state.currentEventSelection, timeOffset), + }); + + performVisuals(state); + } + + function performVisuals(state:ChartEditorState):Void + { + var hasNotes:Bool = false; + var hasEvents:Bool = false; + + // Wiggle copied notes. + if (state.currentNoteSelection.length > 0) + { + hasNotes = true; + + for (note in state.renderedNotes.members) + { + if (state.isNoteSelected(note.noteData)) + { + FlxTween.globalManager.cancelTweensOf(note); + FlxTween.globalManager.cancelTweensOf(note.scale); + note.playNoteAnimation(); + var prevX:Float = note.scale.x; + var prevY:Float = note.scale.y; + + note.scale.x *= 1.2; + note.scale.y *= 1.2; + + note.angle = FlxG.random.bool() ? -10 : 10; + FlxTween.tween(note, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut}); + + FlxTween.tween(note.scale, {"y": prevX, "x": prevY}, 0.7, + { + ease: FlxEase.elasticOut, + onComplete: function(_) { + note.playNoteAnimation(); + } + }); + } + } + } + + // Wiggle copied events. + if (state.currentEventSelection.length > 0) + { + hasEvents = true; + + for (event in state.renderedEvents.members) + { + if (state.isEventSelected(event.eventData)) + { + FlxTween.globalManager.cancelTweensOf(event); + FlxTween.globalManager.cancelTweensOf(event.scale); + event.playAnimation(); + var prevX:Float = event.scale.x; + var prevY:Float = event.scale.y; + + event.scale.x *= 1.2; + event.scale.y *= 1.2; + + event.angle = FlxG.random.bool() ? -10 : 10; + FlxTween.tween(event, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut}); + + FlxTween.tween(event.scale, {"y": prevX, "x": prevY}, 0.7, + { + ease: FlxEase.elasticOut, + onComplete: function(_) { + event.playAnimation(); + } + }); + } + } + } + + // Display the "Copied Notes" text. + if ((hasNotes || hasEvents) && state.txtCopyNotif != null) + { + var copiedString:String = ''; + if (hasNotes) + { + var copiedNotes:Int = state.currentNoteSelection.length; + copiedString += '${copiedNotes} note'; + if (copiedNotes > 1) copiedString += 's'; + + if (hasEvents) copiedString += ' and '; + } + if (hasEvents) + { + var copiedEvents:Int = state.currentEventSelection.length; + copiedString += '${state.currentEventSelection.length} event'; + if (copiedEvents > 1) copiedString += 's'; + } + + state.txtCopyNotif.visible = true; + state.txtCopyNotif.text = 'Copied ${copiedString} to clipboard'; + state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2); + state.txtCopyNotif.y = FlxG.mouse.y - 16; + FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5, + { + type: FlxTweenType.ONESHOT, + ease: FlxEase.quadOut, + onComplete: function(_) { + state.txtCopyNotif.visible = false; + } + }); + } + } + + public function undo(state:ChartEditorState):Void + { + // This command is not undoable. Do nothing. + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is not undoable. Don't add it to the history. + return false; + } + + public function toString():String + { + var len:Int = notes.length + events.length; + + if (notes.length == 0) return 'Copy $len Events to Clipboard'; + else if (events.length == 0) return 'Copy $len Notes to Clipboard'; + else + return 'Copy $len Items to Clipboard'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx new file mode 100644 index 00000000..6cf674f8 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx @@ -0,0 +1,74 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * and then deletes them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class CutItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + // Copy the notes. + SongDataUtils.writeItemsToClipboard( + { + notes: SongDataUtils.buildNoteClipboard(notes), + events: SongDataUtils.buildEventClipboard(events) + }); + + // Delete the notes. + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); + state.currentSongChartEventData = state.currentSongChartEventData.concat(events); + + state.currentNoteSelection = notes; + state.currentEventSelection = events; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Always add it to the history. + return (notes.length > 0 || events.length > 0); + } + + public function toString():String + { + var len:Int = notes.length + events.length; + + if (notes.length == 0) return 'Cut $len Events to Clipboard'; + else if (events.length == 0) return 'Cut $len Notes to Clipboard'; + else + return 'Cut $len Items to Clipboard'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx new file mode 100644 index 00000000..5bfef76c --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx @@ -0,0 +1,47 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; + +/** + * Command that deselects all selected notes and events in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class DeselectAllItemsCommand implements ChartEditorCommand +{ + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; + + public function new() {} + + public function execute(state:ChartEditorState):Void + { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.noteDisplayDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; + + state.noteDisplayDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (previousNoteSelection.length > 0 || previousEventSelection.length > 0); + } + + public function toString():String + { + return 'Deselect All Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx new file mode 100644 index 00000000..6a115a26 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx @@ -0,0 +1,71 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Command to deselect a specific set of notes and events in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class DeselectItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events); + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + for (note in this.notes) + { + state.currentNoteSelection.push(note); + } + + for (event in this.events) + { + state.currentEventSelection.push(event); + } + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + + public function toString():String + { + var isPlural = (notes.length + events.length) > 1; + var notesOnly = (notes.length > 0 && events.length == 0); + var eventsOnly = (notes.length == 0 && events.length > 0); + + if (notesOnly) + { + return 'Deselect ${notes.length} ${isPlural ? 'Notes' : 'Note'}'; + } + else if (eventsOnly) + { + return 'Deselect ${events.length} ${isPlural ? 'Events' : 'Event'}'; + } + + return 'Deselect ${notes.length + events.length} Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx new file mode 100644 index 00000000..3ef9f22d --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx @@ -0,0 +1,84 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; + +/** + * Command that modifies the length of a hold note in the chart editor. + * If it is not a hold note, it will become one, and if it is already a hold note, its length will change. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ExtendNoteLengthCommand implements ChartEditorCommand +{ + var note:SongNoteData; + var oldLength:Float; + var newLength:Float; + var unit:Unit; + + public function new(note:SongNoteData, newLength:Float, unit:Unit = MILLISECONDS) + { + this.note = note; + this.oldLength = note.length; + this.newLength = newLength; + this.unit = unit; + } + + public function execute(state:ChartEditorState):Void + { + switch (unit) + { + case MILLISECONDS: + this.note.length = newLength; + case STEPS: + this.note.setStepLength(newLength); + } + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.playSound(Paths.sound('chartingSounds/undo')); + + // Always use milliseconds for undoing + this.note.length = oldLength; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (oldLength != newLength); + } + + public function toString():String + { + if (oldLength == 0) + { + return 'Add Hold to Note'; + } + else if (newLength == 0) + { + return 'Remove Hold from Note'; + } + else + { + return 'Extend Hold Note Length'; + } + } +} + +enum Unit +{ + MILLISECONDS; + STEPS; +} diff --git a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx new file mode 100644 index 00000000..f54ffed1 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx @@ -0,0 +1,65 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Command that flips a given array of notes from the player's side of the chart editor to the opponent's side, or vice versa. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class FlipNotesCommand implements ChartEditorCommand +{ + var notes:Array = []; + var flippedNotes:Array = []; + + public function new(notes:Array) + { + this.notes = notes; + this.flippedNotes = SongDataUtils.flipNotes(notes); + } + + public function execute(state:ChartEditorState):Void + { + // Delete the notes. + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + + // Add the flipped notes. + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes); + + state.currentNoteSelection = flippedNotes; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes); + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); + + state.currentNoteSelection = notes; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + + public function toString():String + { + var len:Int = notes.length; + return 'Flip $len Notes'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx new file mode 100644 index 00000000..d9a28f46 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx @@ -0,0 +1,49 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Command to deselect all items that are currently selected in the chart editor, + * then select all the items that were previously unselected. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class InvertSelectedItemsCommand implements ChartEditorCommand +{ + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; + + public function new() {} + + public function execute(state:ChartEditorState):Void + { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection); + + state.noteDisplayDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; + + state.noteDisplayDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (previousNoteSelection.length > 0 || previousEventSelection.length > 0); + } + + public function toString():String + { + return 'Invert Selected Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx new file mode 100644 index 00000000..ed50ad33 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx @@ -0,0 +1,79 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Move the given events by the given offset and shift them by the given number of columns in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class MoveEventsCommand implements ChartEditorCommand +{ + var events:Array; + var movedEvents:Array; + var offset:Float; + + public function new(events:Array, offset:Float) + { + // Clone the notes to prevent editing from affecting the history. + this.events = [for (event in events) event.clone()]; + this.offset = offset; + this.movedEvents = []; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + movedEvents = []; + + for (event in events) + { + // Clone the notes to prevent editing from affecting the history. + var resultEvent = event.clone(); + resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + + movedEvents.push(resultEvent); + } + + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + state.currentSongChartEventData = state.currentSongChartEventData.concat(movedEvents); + state.currentEventSelection = movedEvents; + + state.playSound(Paths.sound('chartingSounds/noteLay')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, movedEvents); + state.currentSongChartEventData = state.currentSongChartEventData.concat(events); + + state.currentEventSelection = events; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + + public function toString():String + { + var len:Int = events.length; + return 'Move $len Events'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx new file mode 100644 index 00000000..f44cb973 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx @@ -0,0 +1,102 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Move the given notes by the given offset and shift them by the given number of columns in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class MoveItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var movedNotes:Array; + var events:Array; + var movedEvents:Array; + var offset:Float; + var columns:Int; + + public function new(notes:Array, events:Array, offset:Float, columns:Int) + { + // Clone the notes to prevent editing from affecting the history. + this.notes = notes.clone(); + this.events = events.clone(); + this.offset = offset; + this.columns = columns; + this.movedNotes = []; + this.movedEvents = []; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + movedNotes = []; + movedEvents = []; + + for (note in notes) + { + // Clone the notes to prevent editing from affecting the history. + var resultNote = note.clone(); + resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0, + ChartEditorState.STRUMLINE_SIZE * 2 - 1)); + + movedNotes.push(resultNote); + } + + for (event in events) + { + // Clone the notes to prevent editing from affecting the history. + var resultEvent = event.clone(); + resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + + movedEvents.push(resultEvent); + } + + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(movedNotes); + state.currentSongChartEventData = state.currentSongChartEventData.concat(movedEvents); + state.currentNoteSelection = movedNotes; + state.currentEventSelection = movedEvents; + + state.playSound(Paths.sound('chartingSounds/noteLay')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, movedNotes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, movedEvents); + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); + state.currentSongChartEventData = state.currentSongChartEventData.concat(events); + + state.currentNoteSelection = notes; + state.currentEventSelection = events; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + + public function toString():String + { + var len:Int = notes.length + events.length; + return 'Move $len Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx new file mode 100644 index 00000000..51aeb5bb --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx @@ -0,0 +1,81 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Move the given notes by the given offset and shift them by the given number of columns in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class MoveNotesCommand implements ChartEditorCommand +{ + var notes:Array; + var movedNotes:Array; + var offset:Float; + var columns:Int; + + public function new(notes:Array, offset:Float, columns:Int) + { + // Clone the notes to prevent editing from affecting the history. + this.notes = [for (note in notes) note.clone()]; + this.offset = offset; + this.columns = columns; + this.movedNotes = []; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + + movedNotes = []; + + for (note in notes) + { + // Clone the notes to prevent editing from affecting the history. + var resultNote = note.clone(); + resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio))); + resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0, + ChartEditorState.STRUMLINE_SIZE * 2 - 1)); + + movedNotes.push(resultNote); + } + + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(movedNotes); + state.currentNoteSelection = movedNotes; + + state.playSound(Paths.sound('chartingSounds/noteLay')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, movedNotes); + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); + + state.currentNoteSelection = notes; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + + public function toString():String + { + var len:Int = notes.length; + return 'Move $len Notes'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx new file mode 100644 index 00000000..257db94b --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx @@ -0,0 +1,91 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; +import funkin.data.song.SongDataUtils.SongClipboardItems; + +/** + * A command which inserts the contents of the clipboard into the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class PasteItemsCommand implements ChartEditorCommand +{ + var targetTimestamp:Float; + // Notes we added with this command, for undo. + var addedNotes:Array = []; + var addedEvents:Array = []; + + public function new(targetTimestamp:Float) + { + this.targetTimestamp = targetTimestamp; + } + + public function execute(state:ChartEditorState):Void + { + var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard(); + + if (currentClipboard.valid != true) + { + state.error('Failed to Paste', 'Could not parse clipboard contents.'); + return; + } + + var stepEndOfSong:Float = Conductor.instance.getTimeInSteps(state.songLengthInMs); + var stepCutoff:Float = stepEndOfSong - 1.0; + var msCutoff:Float = Conductor.instance.getStepTimeInMs(stepCutoff); + + addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp)); + addedNotes = SongDataUtils.clampSongNoteData(addedNotes, 0.0, msCutoff); + addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp)); + addedEvents = SongDataUtils.clampSongEventData(addedEvents, 0.0, msCutoff); + + state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); + state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents); + state.currentNoteSelection = addedNotes.copy(); + state.currentEventSelection = addedEvents.copy(); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + + state.success('Paste Successful', 'Successfully pasted clipboard contents.'); + } + + public function undo(state:ChartEditorState):Void + { + state.playSound(Paths.sound('chartingSounds/undo')); + + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents); + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (addedNotes.length > 0 || addedEvents.length > 0); + } + + public function toString():String + { + var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard(); + + var len:Int = currentClipboard.notes.length + currentClipboard.events.length; + + if (currentClipboard.notes.length == 0) return 'Paste $len Events'; + else if (currentClipboard.events.length == 0) return 'Paste $len Notes'; + else + return 'Paste $len Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx new file mode 100644 index 00000000..30f4280d --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx @@ -0,0 +1,70 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Deletes the given events from the current chart in the chart editor. + * Use only when ONLY events are being deleted. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class RemoveEventsCommand implements ChartEditorCommand +{ + var events:Array; + + public function new(events:Array) + { + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + if (events.length == 0) return; + + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + state.currentEventSelection = []; + + state.playSound(Paths.sound('chartingSounds/noteErase')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + if (events.length == 0) return; + + for (event in events) + { + state.currentSongChartEventData.push(event); + } + state.currentEventSelection = events; + state.playSound(Paths.sound('chartingSounds/undo')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (events.length > 0); + } + + public function toString():String + { + if (events.length == 1 && events[0] != null) + { + return 'Remove Event'; + } + + return 'Remove ${events.length} Events'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx new file mode 100644 index 00000000..1cc61f23 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx @@ -0,0 +1,79 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Deletes the given notes and events from the current chart in the chart editor. + * Use only when BOTH notes and events are being deleted. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class RemoveItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + if ((notes.length + events.length) == 0) return; + + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events); + + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.playSound(Paths.sound('chartingSounds/noteErase')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + if ((notes.length + events.length) == 0) return; + + for (note in notes) + { + state.currentSongChartNoteData.push(note); + } + + for (event in events) + { + state.currentSongChartEventData.push(event); + } + + state.currentNoteSelection = notes; + state.currentEventSelection = events; + + state.playSound(Paths.sound('chartingSounds/undo')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + + public function toString():String + { + return 'Remove ${notes.length + events.length} Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx new file mode 100644 index 00000000..18ad6e04 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx @@ -0,0 +1,73 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Deletes the given notes from the current chart in the chart editor. + * Use only when ONLY notes are being deleted. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class RemoveNotesCommand implements ChartEditorCommand +{ + var notes:Array; + + public function new(notes:Array) + { + this.notes = notes; + } + + public function execute(state:ChartEditorState):Void + { + if (notes.length == 0) return; + + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); + state.currentNoteSelection = []; + state.currentEventSelection = []; + + state.playSound(Paths.sound('chartingSounds/noteErase')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + if (notes.length == 0) return; + + for (note in notes) + { + state.currentSongChartNoteData.push(note); + } + state.currentNoteSelection = notes; + state.currentEventSelection = []; + state.playSound(Paths.sound('chartingSounds/undo')); + + state.saveDataDirty = true; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + + state.sortChartData(); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0); + } + + public function toString():String + { + if (notes.length == 1 && notes[0] != null) + { + var dir:String = notes[0].getDirectionName(); + return 'Remove $dir Note'; + } + + return 'Remove ${notes.length} Notes'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx new file mode 100644 index 00000000..f550e044 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx @@ -0,0 +1,69 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; + +/** + * Command to set the selection to all notes and events in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SelectAllItemsCommand implements ChartEditorCommand +{ + var shouldSelectNotes:Bool; + var shouldSelectEvents:Bool; + + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; + + public function new(shouldSelectNotes:Bool, shouldSelectEvents:Bool) + { + this.shouldSelectNotes = shouldSelectNotes; + this.shouldSelectEvents = shouldSelectEvents; + } + + public function execute(state:ChartEditorState):Void + { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + + state.currentNoteSelection = shouldSelectNotes ? state.currentSongChartNoteData : []; + state.currentEventSelection = shouldSelectEvents ? state.currentSongChartEventData : []; + + state.noteDisplayDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; + + state.noteDisplayDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (state.currentNoteSelection.length > 0 || state.currentEventSelection.length > 0); + } + + public function toString():String + { + if (shouldSelectNotes && !shouldSelectEvents) + { + return 'Select All Notes'; + } + else if (shouldSelectEvents && !shouldSelectNotes) + { + return 'Select All Events'; + } + else if (shouldSelectNotes && shouldSelectEvents) + { + return 'Select All Notes and Events'; + } + else + { + return 'Select Nothing (Huh?)'; + } + } +} diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx new file mode 100644 index 00000000..423295f1 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx @@ -0,0 +1,121 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongDataUtils; + +/** + * Appends one or more items to the selection in the chart editor. + * This does not deselect any items that are already selected, if any. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SelectItemsCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + + public function new(?notes:Array, ?events:Array) + { + this.notes = notes ?? []; + this.events = events ?? []; + } + + public function execute(state:ChartEditorState):Void + { + for (note in this.notes) + { + state.currentNoteSelection.push(note); + } + + for (event in this.events) + { + state.currentEventSelection.push(event); + } + + // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event. + if (this.notes.length == 0 && this.events.length == 1) + { + var eventSelected = this.events[0]; + + state.eventKindToPlace = eventSelected.eventKind; + + // This code is here to parse event data that's not built as a struct for some reason. + // TODO: Clean this up or get rid of it. + var eventSchema = eventSelected.getSchema(); + var defaultKey = null; + if (eventSchema == null) + { + trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.'); + } + else + { + defaultKey = eventSchema.getFirstField()?.name; + } + var eventData = eventSelected.valueAsStruct(defaultKey); + + state.eventDataToPlace = eventData; + + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + + // If we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note. + if (this.events.length == 0 && this.notes.length == 1) + { + var noteSelected = this.notes[0]; + + state.noteKindToPlace = noteSelected.kind; + + // This code is here to parse note data that's not built as a struct for some reason. + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTE_DATA_LAYOUT); + } + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes); + state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events); + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (notes.length > 0 || events.length > 0); + } + + public function toString():String + { + var len:Int = notes.length + events.length; + + if (notes.length == 0) + { + if (events.length == 1) + { + return 'Select Event'; + } + else + { + return 'Select ${events.length} Events'; + } + } + else if (events.length == 0) + { + if (notes.length == 1) + { + return 'Select Note'; + } + else + { + return 'Select ${notes.length} Notes'; + } + } + + return 'Select ${len} Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx new file mode 100644 index 00000000..606d544c --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx @@ -0,0 +1,107 @@ +package funkin.ui.debug.charting.commands; + +/** + * Command that copies a given set of notes and song events to the clipboard, + * without deleting them from the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetAudioOffsetCommand implements ChartEditorCommand +{ + var type:AudioOffsetType; + var oldOffset:Float = 0; + var newOffset:Float; + var refreshOffsetsToolbox:Bool; + + public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true) + { + this.type = type; + this.newOffset = newOffset; + this.refreshOffsetsToolbox = refreshOffsetsToolbox; + } + + public function execute(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + oldOffset = state.currentInstrumentalOffset; + state.currentInstrumentalOffset = newOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + oldOffset = state.currentVocalOffsetPlayer; + state.currentVocalOffsetPlayer = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + oldOffset = state.currentVocalOffsetOpponent; + state.currentVocalOffsetOpponent = newOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + if (refreshOffsetsToolbox) + { + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT); + } + } + + public function undo(state:ChartEditorState):Void + { + switch (type) + { + case INSTRUMENTAL: + state.currentInstrumentalOffset = oldOffset; + + // Update rendering. + Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; + state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset; + case PLAYER: + state.currentVocalOffsetPlayer = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + case OPPONENT: + state.currentVocalOffsetOpponent = oldOffset; + + // Update rendering. + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + } + + // Update the offsets toolbox. + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT); + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // This command is undoable. Add to the history if we actually performed an action. + return (newOffset != oldOffset); + } + + public function toString():String + { + switch (type) + { + case INSTRUMENTAL: + return 'Set Inst. Audio Offset to $newOffset'; + case PLAYER: + return 'Set Player Audio Offset to $newOffset'; + case OPPONENT: + return 'Set Opponent Audio Offset to $newOffset'; + } + } +} + +enum AudioOffsetType +{ + INSTRUMENTAL; + PLAYER; + OPPONENT; +} diff --git a/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx new file mode 100644 index 00000000..23276890 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx @@ -0,0 +1,62 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongDataUtils; + +/** + * Command that sets the start time or end time of the Freeplay preview. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetFreeplayPreviewCommand implements ChartEditorCommand +{ + var previousStartTime:Int = 0; + var previousEndTime:Int = 0; + var newStartTime:Null = null; + var newEndTime:Null = null; + + public function new(newStartTime:Null, newEndTime:Null) + { + this.newStartTime = newStartTime; + this.newEndTime = newEndTime; + } + + public function execute(state:ChartEditorState):Void + { + this.previousStartTime = state.currentSongFreeplayPreviewStart; + this.previousEndTime = state.currentSongFreeplayPreviewEnd; + + if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime; + if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime; + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongFreeplayPreviewStart = previousStartTime; + state.currentSongFreeplayPreviewEnd = previousEndTime; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime); + } + + public function toString():String + { + var setStart = newStartTime != null && newStartTime != previousStartTime; + var setEnd = newEndTime != null && newEndTime != previousEndTime; + + if (setStart && !setEnd) + { + return "Set Freeplay Preview Start Time"; + } + else if (setEnd && !setStart) + { + return "Set Freeplay Preview End Time"; + } + else + { + return "Set Freeplay Preview Start and End Times"; + } + } +} diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx new file mode 100644 index 00000000..661c44d8 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx @@ -0,0 +1,103 @@ +package funkin.ui.debug.charting.commands; + +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; + +/** + * Command to set the current selection in the chart editor (rather than appending it). + * Deselects any notes that are not in the new selection. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SetItemSelectionCommand implements ChartEditorCommand +{ + var notes:Array; + var events:Array; + var previousNoteSelection:Array = []; + var previousEventSelection:Array = []; + + public function new(notes:Array, events:Array) + { + this.notes = notes; + this.events = events; + } + + public function execute(state:ChartEditorState):Void + { + this.previousNoteSelection = state.currentNoteSelection; + this.previousEventSelection = state.currentEventSelection; + + state.currentNoteSelection = notes; + state.currentEventSelection = events; + + // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event. + if (this.notes.length == 0 && this.events.length == 1) + { + var eventSelected = this.events[0]; + + if (state.eventKindToPlace == eventSelected.eventKind) + { + trace('Target event kind matches selection: ${eventSelected.eventKind}'); + } + else + { + trace('Switching target event kind to match selection: ${state.eventKindToPlace} != ${eventSelected.eventKind}'); + state.eventKindToPlace = eventSelected.eventKind; + } + + // This code is here to parse event data that's not built as a struct for some reason. + // TODO: Clean this up or get rid of it. + var eventSchema = eventSelected.getSchema(); + var defaultKey = null; + if (eventSchema == null) + { + trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.'); + } + else + { + defaultKey = eventSchema.getFirstField()?.name; + } + var eventData = eventSelected.valueAsStruct(defaultKey); + + var eventDataClone = Reflect.copy(eventData); + + if (eventDataClone != null) + { + state.eventDataToPlace = eventDataClone; + } + + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + + // IF we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note. + if (this.events.length == 0 && this.notes.length == 1) + { + var noteSelected = this.notes[0]; + + state.noteKindToPlace = noteSelected.kind; + + state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTE_DATA_LAYOUT); + } + + state.noteDisplayDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.currentNoteSelection = previousNoteSelection; + state.currentEventSelection = previousEventSelection; + + state.noteDisplayDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // Add to the history if we actually performed an action. + return (state.currentNoteSelection != previousNoteSelection && state.currentEventSelection != previousEventSelection); + } + + public function toString():String + { + return 'Select ${notes.length + events.length} Items'; + } +} diff --git a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx new file mode 100644 index 00000000..30c2edb6 --- /dev/null +++ b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx @@ -0,0 +1,51 @@ +package funkin.ui.debug.charting.commands; + +/** + * Switch the current difficulty (and possibly variation) of the chart in the chart editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class SwitchDifficultyCommand implements ChartEditorCommand +{ + var prevDifficulty:String; + var newDifficulty:String; + var prevVariation:String; + var newVariation:String; + + public function new(prevDifficulty:String, newDifficulty:String, prevVariation:String, newVariation:String) + { + this.prevDifficulty = prevDifficulty; + this.newDifficulty = newDifficulty; + this.prevVariation = prevVariation; + this.newVariation = newVariation; + } + + public function execute(state:ChartEditorState):Void + { + state.selectedVariation = newVariation != null ? newVariation : prevVariation; + state.selectedDifficulty = newDifficulty != null ? newDifficulty : prevDifficulty; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.selectedVariation = prevVariation != null ? prevVariation : newVariation; + state.selectedDifficulty = prevDifficulty != null ? prevDifficulty : newDifficulty; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function shouldAddToHistory(state:ChartEditorState):Bool + { + // Add to the history if we actually performed an action. + return (prevVariation != newVariation || prevDifficulty != newDifficulty); + } + + public function toString():String + { + return 'Switch Difficulty'; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx new file mode 100644 index 00000000..645e5162 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx @@ -0,0 +1,247 @@ +package funkin.ui.debug.charting.components; + +import funkin.data.event.SongEventRegistry; +import flixel.graphics.frames.FlxAtlasFrames; +import openfl.display.BitmapData; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.data.song.SongData.SongEventData; +import haxe.ui.tooltips.ToolTipRegionOptions; +import funkin.util.HaxeUIUtil; +import haxe.ui.tooltips.ToolTipManager; + +/** + * A sprite that can be used to display a song event in a chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +@:nullSafety +class ChartEditorEventSprite extends FlxSprite +{ + public static final DEFAULT_EVENT = 'Default'; + + public var parentState:ChartEditorState; + + /** + * The note data that this sprite represents. + * You can set this to null to kill the sprite and flag it for recycling. + */ + public var eventData(default, set):Null = null; + + /** + * The image used for all song events. Cached for performance. + */ + static var eventSpriteBasic:Null = null; + + public var overrideStepTime(default, set):Null = null; + + public var tooltip:ToolTipRegionOptions; + + /** + * Whether this sprite is a "ghost" sprite used when hovering to place a new event. + */ + public var isGhost:Bool = false; + + function set_overrideStepTime(value:Null):Null + { + if (overrideStepTime == value) return overrideStepTime; + + overrideStepTime = value; + updateEventPosition(); + return overrideStepTime; + } + + public function new(parent:ChartEditorState, isGhost:Bool = false) + { + super(); + + this.parentState = parent; + this.isGhost = isGhost; + + this.tooltip = HaxeUIUtil.buildTooltip('N/A'); + this.frames = buildFrames(); + + buildAnimations(); + refresh(); + } + + static var eventFrames:Null = null; + + /** + * Build a set of animations to allow displaying different types of chart events. + * @param force `true` to force rebuilding the frames. + */ + static function buildFrames(force:Bool = false):FlxFramesCollection + { + if (eventFrames != null && !force) return eventFrames; + + initEmptyEventFrames(); + if (eventFrames == null) throw 'Failed to initialize empty event frames.'; + + // Push the default event as a frame. + var defaultFrames:FlxAtlasFrames = Paths.getSparrowAtlas('ui/chart-editor/events/$DEFAULT_EVENT'); + defaultFrames.parent.persist = true; + for (frame in defaultFrames.frames) + { + eventFrames.pushFrame(frame); + } + + // Push all the other events as frames. + for (eventName in SongEventRegistry.listEventIds()) + { + var exists:Bool = Assets.exists(Paths.image('ui/chart-editor/events/$eventName')); + if (!exists) continue; // No graphic for this event. + + var frames:FlxAtlasFrames = Paths.getSparrowAtlas('ui/chart-editor/events/$eventName'); + if (frames == null) continue; // Could not load graphic for this event. + + frames.parent.persist = true; + for (frame in frames.frames) + { + eventFrames.pushFrame(frame); + } + } + + return eventFrames; + } + + @:nullSafety(Off) + static function initEmptyEventFrames():Void + { + eventFrames = new FlxAtlasFrames(null); + } + + function buildAnimations():Void + { + var eventNames:Array = [DEFAULT_EVENT].concat(SongEventRegistry.listEventIds()); + for (eventName in eventNames) + { + this.animation.addByPrefix(eventName, '${eventName}0', 24, false); + } + } + + public function correctAnimationName(name:String):String + { + if (this.animation.exists(name)) return name; + trace('Warning: Invalid animation name "${name}" for song event. Using "${DEFAULT_EVENT}"'); + return DEFAULT_EVENT; + } + + public function playAnimation(?name:String):Void + { + if (name == null) name = eventData?.eventKind ?? DEFAULT_EVENT; + + var correctedName = correctAnimationName(name); + this.animation.play(correctedName); + refresh(); + } + + function refresh():Void + { + setGraphicSize(ChartEditorState.GRID_SIZE); + this.updateHitbox(); + } + + function set_eventData(value:Null):Null + { + if (value == null) + { + this.eventData = null; + // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING + this.kill(); + this.visible = false; + updateTooltipPosition(); + return null; + } + else + { + this.visible = true; + playAnimation(value.eventKind); + this.eventData = value; + // Update the position to match the note data. + updateEventPosition(); + updateTooltipText(); + return this.eventData; + } + } + + public function updateEventPosition(?origin:FlxObject) + { + if (this.eventData == null) return; + + this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE; + + var stepTime:Float = (overrideStepTime != null) ? overrideStepTime : eventData.getStepTime(); + this.y = stepTime * ChartEditorState.GRID_SIZE; + + if (origin != null) + { + this.x += origin.x; + this.y += origin.y; + } + + this.updateTooltipPosition(); + } + + public function updateTooltipText():Void + { + if (this.eventData == null) return; + if (this.isGhost) return; + this.tooltip.tipData = {text: this.eventData.buildTooltip()}; + } + + public function updateTooltipPosition():Void + { + // No tooltip for ghost sprites. + if (this.isGhost) return; + + if (this.eventData == null) + { + // Disable the tooltip. + ToolTipManager.instance.unregisterTooltipRegion(this.tooltip); + } + else + { + // Update the position. + this.tooltip.left = this.x; + this.tooltip.top = this.y; + this.tooltip.width = this.width; + this.tooltip.height = this.height; + + // Enable the tooltip. + ToolTipManager.instance.registerTooltipRegion(this.tooltip); + } + } + + /** + * Return whether this event is currently visible. + */ + public function isEventVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool + { + // True if the note is above the view area. + var aboveViewArea = (this.y + this.height < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (this.y > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } + + /** + * Return whether an event, if placed in the scene, would be visible. + */ + public static function wouldEventBeVisible(viewAreaBottom:Float, viewAreaTop:Float, eventData:SongEventData, ?origin:FlxObject):Bool + { + var noteHeight:Float = ChartEditorState.GRID_SIZE; + var notePosY:Float = eventData.getStepTime() * ChartEditorState.GRID_SIZE; + if (origin != null) notePosY += origin.y; + + // True if the note is above the view area. + var aboveViewArea = (notePosY + noteHeight < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (notePosY > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx new file mode 100644 index 00000000..e4012d55 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx @@ -0,0 +1,261 @@ +package funkin.ui.debug.charting.components; + +import funkin.play.notes.Strumline; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import flixel.FlxObject; +import funkin.play.notes.SustainTrail; +import funkin.data.song.SongData.SongNoteData; +import flixel.math.FlxMath; + +/** + * A sprite that can be used to display the trail of a hold note in a chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +@:access(funkin.ui.debug.charting.ChartEditorState) +@:nullSafety +class ChartEditorHoldNoteSprite extends SustainTrail +{ + /** + * The ChartEditorState this note belongs to. + */ + public var parentState:ChartEditorState; + + @:isVar + public var noteStyle(get, set):Null; + + function get_noteStyle():Null + { + return this.noteStyle ?? this.parentState.currentSongNoteStyle; + } + + @:nullSafety(Off) + function set_noteStyle(value:Null):Null + { + this.noteStyle = value; + this.updateHoldNoteGraphic(); + return value; + } + + public function new(parent:ChartEditorState) + { + var noteStyle = NoteStyleRegistry.instance.fetchDefault(); + + super(0, 100, noteStyle); + + this.parentState = parent; + } + + @:nullSafety(Off) + function updateHoldNoteGraphic():Void + { + var bruhStyle:Null = NoteStyleRegistry.instance.fetchEntry(noteStyle); + if (bruhStyle == null) bruhStyle = NoteStyleRegistry.instance.fetchDefault(); + setupHoldNoteGraphic(bruhStyle); + } + + override function setupHoldNoteGraphic(noteStyle:NoteStyle):Void + { + var graphicPath = noteStyle.getHoldNoteAssetPath(); + if (graphicPath == null) return; + loadGraphic(graphicPath); + + antialiasing = true; + + this.isPixel = noteStyle.isHoldNotePixel(); + if (isPixel) + { + endOffset = bottomClip = 1; + antialiasing = false; + } + else + { + endOffset = 0.5; + bottomClip = 0.9; + } + + zoom = 1.0; + zoom *= noteStyle.fetchHoldNoteScale(); + zoom *= 0.7; + zoom *= ChartEditorState.GRID_SIZE / Strumline.STRUMLINE_SIZE; + + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + graphicHeight = sustainLength * 0.45; // sustainHeight + + flipY = false; + + alpha = 1.0; + + updateColorTransform(); + + updateClipping(); + + setup(); + } + + public override function updateHitbox():Void + { + // Expand the clickable hitbox to the full column width, then nudge to the left to re-center it. + width = ChartEditorState.GRID_SIZE; + height = graphicHeight; + + var xOffset = (ChartEditorState.GRID_SIZE - graphicWidth) / 2; + offset.set(-xOffset, 0); + origin.set(width * 0.5, height * 0.5); + } + + /** + * Set the height directly, to a value in pixels. + * @param h The desired height in pixels. + */ + public function setHeightDirectly(h:Float, lerp:Bool = false) + { + if (lerp) + { + sustainLength = FlxMath.lerp(sustainLength, h / (getBaseScrollSpeed() * Constants.PIXELS_PER_MS), 0.25); + } + else + { + sustainLength = h / (getBaseScrollSpeed() * Constants.PIXELS_PER_MS); + } + + fullSustainLength = sustainLength; + } + + #if FLX_DEBUG + /** + * Call this to override how debug bounding boxes are drawn for this sprite. + */ + public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) return; + + var rect = getBoundingBox(camera); + trace('hold note bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height); + + var gfx = beginDrawDebug(camera); + debugBoundingBoxColor = 0xffFF66FF; + gfx.lineStyle(2, color, 0.5); // thickness, color, alpha + gfx.drawRect(rect.x, rect.y, rect.width, rect.height); + endDrawDebug(camera); + } + #end + + function setup():Void + { + strumTime = 999999999; + missedNote = false; + hitNote = false; + active = true; + visible = true; + alpha = 1.0; + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + + updateHitbox(); + } + + public override function revive():Void + { + super.revive(); + + setup(); + } + + public override function kill():Void + { + super.kill(); + + active = false; + visible = false; + noteData = null; + strumTime = 999999999; + noteDirection = 0; + sustainLength = 0; + fullSustainLength = 0; + } + + /** + * Return whether this note is currently visible. + */ + public function isHoldNoteVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool + { + // True if the note is above the view area. + var aboveViewArea = (this.y + this.height < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (this.y > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } + + /** + * Return whether a hold note, if placed in the scene, would be visible. + */ + public static function wouldHoldNoteBeVisible(viewAreaBottom:Float, viewAreaTop:Float, noteData:SongNoteData, ?origin:FlxObject):Bool + { + var noteHeight:Float = noteData.getStepLength() * ChartEditorState.GRID_SIZE; + var stepTime:Float = inline noteData.getStepTime(); + var notePosY:Float = stepTime * ChartEditorState.GRID_SIZE; + if (origin != null) notePosY += origin.y; + + // True if the note is above the view area. + var aboveViewArea = (notePosY + noteHeight < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (notePosY > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } + + public function updateHoldNotePosition(?origin:FlxObject):Void + { + if (this.noteData == null) return; + + var cursorColumn:Int = this.noteData.data; + + if (cursorColumn < 0) cursorColumn = 0; + if (cursorColumn >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1)) + { + cursorColumn = (ChartEditorState.STRUMLINE_SIZE * 2 + 1); + } + else + { + // Invert player and opponent columns. + if (cursorColumn >= ChartEditorState.STRUMLINE_SIZE) + { + cursorColumn -= ChartEditorState.STRUMLINE_SIZE; + } + else + { + cursorColumn += ChartEditorState.STRUMLINE_SIZE; + } + } + + this.x = cursorColumn * ChartEditorState.GRID_SIZE; + + // Notes far in the song will start far down, but the group they belong to will have a high negative offset. + // noteData.getStepTime() returns a calculated value which accounts for BPM changes + var stepTime:Float = + inline this.noteData.getStepTime(); + if (stepTime >= 0) + { + // Add epsilon to fix rounding issues? + // var roundedStepTime:Float = Math.floor((stepTime + 0.01) / noteSnapRatio) * noteSnapRatio; + this.y = stepTime * ChartEditorState.GRID_SIZE; + } + + this.x += ChartEditorState.GRID_SIZE / 2; + this.x -= this.graphicWidth / 2; + + this.y += ChartEditorState.GRID_SIZE / 2; + + if (origin != null) + { + this.x += origin.x; + this.y += origin.y; + } + + // Account for expanded clickable hitbox. + this.x += this.offset.x; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx new file mode 100644 index 00000000..2d8b62cb --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx @@ -0,0 +1,77 @@ +package funkin.ui.debug.charting.components; + +import flixel.FlxSprite; +import flixel.addons.display.FlxTiledSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.text.FlxText; +import flixel.util.FlxColor; + +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorMeasureTicks extends FlxTypedSpriteGroup +{ + var chartEditorState:ChartEditorState; + + var tickTiledSprite:FlxTiledSprite; + var measureNumber:FlxText; + + override function set_y(value:Float):Float + { + var result = super.set_y(value); + + updateMeasureNumber(); + + return result; + } + + public function new(chartEditorState:ChartEditorState) + { + super(); + + this.chartEditorState = chartEditorState; + + tickTiledSprite = new FlxTiledSprite(chartEditorState.measureTickBitmap, chartEditorState.measureTickBitmap.width, 1000, false, true); + add(tickTiledSprite); + + measureNumber = new FlxText(0, 0, ChartEditorState.GRID_SIZE, "1"); + measureNumber.setFormat(Paths.font('vcr.ttf'), 20, FlxColor.WHITE); + measureNumber.borderStyle = FlxTextBorderStyle.OUTLINE; + measureNumber.borderColor = FlxColor.BLACK; + add(measureNumber); + } + + public function reloadTickBitmap():Void + { + tickTiledSprite.loadGraphic(chartEditorState.measureTickBitmap); + } + + /** + * At time of writing, we only have to manipulate one measure number because we can only see one measure at a time. + */ + function updateMeasureNumber() + { + if (measureNumber == null) return; + + var viewTopPosition = 0 - this.y; + var viewHeight = FlxG.height - ChartEditorState.MENU_BAR_HEIGHT - ChartEditorState.PLAYBAR_HEIGHT; + var viewBottomPosition = viewTopPosition + viewHeight; + + var measureNumberInViewport = Math.floor(viewTopPosition / ChartEditorState.GRID_SIZE / Conductor.instance.stepsPerMeasure) + 1; + var measureNumberPosition = measureNumberInViewport * ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure; + + measureNumber.y = measureNumberPosition + this.y; + + // Show the measure number only if it isn't beneath the end of the note grid. + // Using measureNumber + 1 because the cut-off bar at the bottom is technically a bar, but it looks bad if a measure number shows up there. + if ((measureNumberInViewport + 1) < chartEditorState.songLengthInSteps / Conductor.instance.stepsPerMeasure) + measureNumber.text = '${measureNumberInViewport + 1}'; + else + measureNumber.text = ''; + + // trace(measureNumber.text + ' at ' + measureNumber.y); + } + + public function setHeight(songLengthInPixels:Float):Void + { + tickTiledSprite.height = songLengthInPixels; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx new file mode 100644 index 00000000..8d9ec674 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx @@ -0,0 +1,180 @@ +package funkin.ui.debug.charting.components; + +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import flixel.math.FlxMath; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import flixel.util.FlxSpriteUtil; + +/** + * Handles the note scrollbar preview in the chart editor. + */ +@:nullSafety +class ChartEditorNotePreview extends FlxSprite +{ + // + // Constants + // + static final NOTE_WIDTH:Int = 5; + static final WIDTH:Int = NOTE_WIDTH * 9; + static final NOTE_HEIGHT:Int = 1; + + static final BG_COLOR:FlxColor = 0xFF606060; + static final LEFT_COLOR:FlxColor = 0xFFFF22AA; + static final DOWN_COLOR:FlxColor = 0xFF00EEFF; + static final UP_COLOR:FlxColor = 0xFF00CC00; + static final RIGHT_COLOR:FlxColor = 0xFFCC1111; + static final EVENT_COLOR:FlxColor = 0xFF111111; + static final SELECTED_COLOR:FlxColor = 0xFFFFFF00; + + var previewHeight:Int; + + public function new(height:Int) + { + super(0, 0); + this.previewHeight = height; + buildBackground(); + } + + /** + * Build the initial sprite for the preview. + */ + function buildBackground():Void + { + makeGraphic(WIDTH, previewHeight, BG_COLOR); + } + + /** + * Erase all notes from the preview. + */ + public function erase():Void + { + drawRect(0, 0, WIDTH, previewHeight, BG_COLOR); + } + + /** + * Add a single note to the preview. + * @param note The data for the note. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addNote(note:SongNoteData, songLengthInMs:Int, ?isSelection:Bool = false):Void + { + var noteDir:Int = note.getDirection(); + var mustHit:Bool = note.getStrumlineIndex() == 0; + drawNote(noteDir, mustHit, Std.int(note.time), songLengthInMs, isSelection); + } + + /** + * Add a song event to the preview. + * @param event The data for the event. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addEvent(event:SongEventData, songLengthInMs:Int, ?isSelection:Bool = false):Void + { + drawNote(-1, false, Std.int(event.time), songLengthInMs, isSelection); + } + + /** + * Add an array of notes to the preview. + * @param notes The data for the notes. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addNotes(notes:Array, songLengthInMs:Int):Void + { + for (note in notes) + { + addNote(note, songLengthInMs, false); + } + } + + /** + * Add an array of selected notes to the preview. + * @param notes The data for the notes. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addSelectedNotes(notes:Array, songLengthInMs:Int):Void + { + for (note in notes) + { + addNote(note, songLengthInMs, true); + } + } + + /** + * Add an array of events to the preview. + * @param events The data for the events. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addEvents(events:Array, songLengthInMs:Int):Void + { + for (event in events) + { + addEvent(event, songLengthInMs); + } + } + + /** + * Add an array of selected events to the preview. + * @param events The data for the events. + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addSelectedEvents(events:Array, songLengthInMs:Int):Void + { + for (event in events) + { + addEvent(event, songLengthInMs, true); + } + } + + /** + * Draws a note on the preview. + * @param dir Note data. + * @param mustHit False if opponent, true if player. + * @param strumTimeInMs Time in milliseconds to strum the note. + * @param songLengthInMs Length of the song in milliseconds. + * @param isSelection If current note is selected note, which then it's forced to be green + */ + public function drawNote(dir:Int, mustHit:Bool, strumTimeInMs:Int, songLengthInMs:Int, ?isSelection:Bool = false):Void + { + var color:FlxColor = switch (dir) + { + case 0: LEFT_COLOR; + case 1: DOWN_COLOR; + case 2: UP_COLOR; + case 3: RIGHT_COLOR; + default: EVENT_COLOR; + }; + + var noteHeight:Int = NOTE_HEIGHT; + + if (isSelection != null && isSelection) + { + color = SELECTED_COLOR; + noteHeight += 1; + } + + var noteX:Float = NOTE_WIDTH * dir; + if (mustHit) noteX += NOTE_WIDTH * 4; + if (dir == -1) noteX = NOTE_WIDTH * 8; + + var noteY:Float = FlxMath.remapToRange(strumTimeInMs, 0, songLengthInMs, 0, previewHeight); + drawRect(noteX, noteY, NOTE_WIDTH, noteHeight, color); + } + + function eraseNote(dir:Int, mustHit:Bool, strumTimeInMs:Int, songLengthInMs:Int):Void + { + var noteX:Float = NOTE_WIDTH * dir; + if (mustHit) noteX += NOTE_WIDTH * 4; + if (dir == -1) noteX = NOTE_WIDTH * 8; + + var noteY:Float = FlxMath.remapToRange(strumTimeInMs, 0, songLengthInMs, 0, previewHeight); + + drawRect(noteX, noteY, NOTE_WIDTH, NOTE_HEIGHT, BG_COLOR); + } + + inline function drawRect(noteX:Float, noteY:Float, width:Int, height:Int, color:FlxColor):Void + { + FlxSpriteUtil.drawRect(this, noteX, noteY, width, height, color); + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx new file mode 100644 index 00000000..17cd2bc6 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx @@ -0,0 +1,277 @@ +package funkin.ui.debug.charting.components; + +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.graphics.frames.FlxAtlasFrames; +import flixel.graphics.frames.FlxFrame; +import funkin.data.animation.AnimationData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.notes.NoteDirection; + +/** + * A sprite that can be used to display a note in a chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorNoteSprite extends FlxSprite +{ + /** + * The list of available note skin to validate against. + */ + public static final NOTE_STYLES:Array = ['funkin', 'pixel']; + + /** + * The ChartEditorState this note belongs to. + */ + public var parentState:ChartEditorState; + + /** + * The note data that this sprite represents. + * You can set this to null to kill the sprite and flag it for recycling. + */ + public var noteData(default, set):Null; + + /** + * The name of the note style currently in use. + */ + @:isVar + public var noteStyle(get, set):Null; + + public var overrideStepTime(default, set):Null = null; + + function set_overrideStepTime(value:Null):Null + { + if (overrideStepTime == value) return overrideStepTime; + + overrideStepTime = value; + updateNotePosition(); + return overrideStepTime; + } + + public var overrideData(default, set):Null = null; + + function set_overrideData(value:Null):Null + { + if (overrideData == value) return overrideData; + + overrideData = value; + playNoteAnimation(); + return overrideData; + } + + public function new(parent:ChartEditorState) + { + super(); + + this.parentState = parent; + + var entries:Array = NoteStyleRegistry.instance.listEntryIds(); + + if (noteFrameCollection == null) + { + buildEmptyFrameCollection(); + + for (entry in entries) + { + addNoteStyleFrames(fetchNoteStyle(entry)); + } + } + + if (noteFrameCollection == null) throw 'ERROR: Could not initialize note sprite animations.'; + + this.frames = noteFrameCollection; + + for (entry in entries) + { + addNoteStyleAnimations(fetchNoteStyle(entry)); + } + } + + static var noteFrameCollection:Null = null; + + function fetchNoteStyle(noteStyleId:String):NoteStyle + { + var result = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (result != null) return result; + return NoteStyleRegistry.instance.fetchDefault(); + } + + @:access(funkin.play.notes.notestyle.NoteStyle) + @:nullSafety(Off) + static function addNoteStyleFrames(noteStyle:NoteStyle):Void + { + var prefix:String = noteStyle.id.toTitleCase(); + + var frameCollection:FlxAtlasFrames = Paths.getSparrowAtlas(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary()); + if (frameCollection == null) + { + trace('Could not retrieve frame collection for ${noteStyle}: ${Paths.image(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary())}'); + FlxG.log.error('Could not retrieve frame collection for ${noteStyle}: ${Paths.image(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary())}'); + return; + } + for (frame in frameCollection.frames) + { + // cloning the frame because else + // we will fuck up the frame data used in game + var clonedFrame:FlxFrame = frame.copyTo(); + clonedFrame.name = '$prefix${clonedFrame.name}'; + noteFrameCollection.pushFrame(clonedFrame); + } + } + + @:access(funkin.play.notes.notestyle.NoteStyle) + @:nullSafety(Off) + function addNoteStyleAnimations(noteStyle:NoteStyle):Void + { + var prefix:String = noteStyle.id.toTitleCase(); + var suffix:String = noteStyle.id.toTitleCase(); + + var leftData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.LEFT); + this.animation.addByPrefix('tapLeft$suffix', '$prefix${leftData.prefix}', leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY); + + var downData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.DOWN); + this.animation.addByPrefix('tapDown$suffix', '$prefix${downData.prefix}', downData.frameRate, downData.looped, downData.flipX, downData.flipY); + + var upData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.UP); + this.animation.addByPrefix('tapUp$suffix', '$prefix${upData.prefix}', upData.frameRate, upData.looped, upData.flipX, upData.flipY); + + var rightData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.RIGHT); + this.animation.addByPrefix('tapRight$suffix', '$prefix${rightData.prefix}', rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY); + } + + @:nullSafety(Off) + static function buildEmptyFrameCollection():Void + { + noteFrameCollection = new FlxFramesCollection(null, ATLAS, null); + } + + function set_noteData(value:Null):Null + { + this.noteData = value; + + if (this.noteData == null) + { + this.kill(); + return this.noteData; + } + + this.visible = true; + + // Update the animation to match the note data. + // Animation is updated first so size is correct before updating position. + playNoteAnimation(); + + // Update the position to match the note data. + updateNotePosition(); + + return this.noteData; + } + + public function updateNotePosition(?origin:FlxObject):Void + { + if (this.noteData == null) return; + + var cursorColumn:Int = (overrideData != null) ? overrideData : this.noteData.data; + + cursorColumn = ChartEditorState.noteDataToGridColumn(cursorColumn); + + this.x = cursorColumn * ChartEditorState.GRID_SIZE; + + // Notes far in the song will start far down, but the group they belong to will have a high negative offset. + // noteData.getStepTime() returns a calculated value which accounts for BPM changes + var stepTime:Float = (overrideStepTime != null) ? overrideStepTime : noteData.getStepTime(); + if (stepTime >= 0) + { + this.y = stepTime * ChartEditorState.GRID_SIZE; + } + + if (origin != null) + { + this.x += origin.x; + this.y += origin.y; + } + } + + function get_noteStyle():Null + { + if (this.noteStyle == null) + { + var result = this.parentState.currentSongNoteStyle; + return result; + } + return this.noteStyle; + } + + function set_noteStyle(value:Null):Null + { + this.noteStyle = value; + this.playNoteAnimation(); + return value; + } + + @:nullSafety(Off) + public function playNoteAnimation():Void + { + if (this.noteData == null) return; + + // Decide whether to display a note or a sustain. + var baseAnimationName:String = 'tap'; + + // Play the appropriate animation for the type, direction, and skin. + var dirName:String = overrideData != null ? SongNoteData.buildDirectionName(overrideData) : this.noteData.getDirectionName(); + var noteStyleSuffix:String = this.noteStyle?.toTitleCase() ?? Constants.DEFAULT_NOTE_STYLE.toTitleCase(); + var animationName:String = '${baseAnimationName}${dirName}${this.noteStyle.toTitleCase()}'; + + this.animation.play(animationName); + + // Resize note. + + switch (baseAnimationName) + { + case 'tap': + this.setGraphicSize(ChartEditorState.GRID_SIZE, 0); + this.updateHitbox(); + } + + var bruhStyle:NoteStyle = fetchNoteStyle(this.noteStyle); + this.antialiasing = !bruhStyle._data?.assets?.note?.isPixel ?? true; + } + + /** + * Return whether this note (or its parent) is currently visible. + */ + public function isNoteVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool + { + // True if the note is above the view area. + var aboveViewArea = (this.y + this.height < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (this.y > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } + + /** + * Return whether a note, if placed in the scene, would be visible. + * This function should be made HYPER EFFICIENT because it's called a lot. + */ + public static function wouldNoteBeVisible(viewAreaBottom:Float, viewAreaTop:Float, noteData:SongNoteData, ?origin:FlxObject):Bool + { + var noteHeight:Float = ChartEditorState.GRID_SIZE; + var stepTime:Float = inline noteData.getStepTime(); + var notePosY:Float = stepTime * ChartEditorState.GRID_SIZE; + if (origin != null) notePosY += origin.y; + + // True if the note is above the view area. + var aboveViewArea = (notePosY + noteHeight < viewAreaTop); + + // True if the note is below the view area. + var belowViewArea = (notePosY > viewAreaBottom); + + return !aboveViewArea && !belowViewArea; + } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorPlaybarHead.hx b/source/funkin/ui/debug/charting/components/ChartEditorPlaybarHead.hx new file mode 100644 index 00000000..7e62912d --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorPlaybarHead.hx @@ -0,0 +1,16 @@ +package funkin.ui.debug.charting.components; + +import haxe.ui.containers.Box; + +/** + * The component which contains the playhead for the chart editor. + * This is in a separate component so it can be positioned independently. + */ +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/components/playbar-head.xml")) +class ChartEditorPlaybarHead extends Box +{ + // Auto-populated + // public var playbarHead:HorizontalSlider; + // Auto-populated. + // public function new() { } +} diff --git a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx new file mode 100644 index 00000000..eec96b45 --- /dev/null +++ b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx @@ -0,0 +1,32 @@ +package funkin.ui.debug.charting.components; + +import flixel.addons.display.FlxSliceSprite; +import flixel.math.FlxRect; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.handlers.ChartEditorThemeHandler; + +/** + * A sprite that can be used to display a square over a selected note or event in the chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorSelectionSquareSprite extends FlxSliceSprite +{ + public var noteData:Null; + public var eventData:Null; + + public function new(chartEditorState:ChartEditorState) + { + super(chartEditorState.selectionSquareBitmap, + new FlxRect(ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + + 4, ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + + 4, + ChartEditorState.GRID_SIZE + - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8), + ChartEditorState.GRID_SIZE + - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8)), + 32, 32); + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx new file mode 100644 index 00000000..f25f3ebb --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx @@ -0,0 +1,19 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.Menu; + +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorBaseContextMenu extends Menu +{ + var chartEditorState:ChartEditorState; + + public function new(chartEditorState:ChartEditorState, xPos:Float = 0, yPos:Float = 0) + { + super(); + + this.chartEditorState = chartEditorState; + + this.left = xPos; + this.top = yPos; + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx new file mode 100644 index 00000000..d704114e --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx @@ -0,0 +1,11 @@ +package funkin.ui.debug.charting.contextmenus; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/default.xml")) +class ChartEditorDefaultContextMenu extends ChartEditorBaseContextMenu +{ + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0) + { + super(chartEditorState2, xPos2, yPos2); + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx new file mode 100644 index 00000000..afdf2dca --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx @@ -0,0 +1,34 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.MenuItem; +import funkin.data.song.SongData.SongEventData; +import funkin.ui.debug.charting.commands.RemoveEventsCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/event.xml")) +class ChartEditorEventContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuEdit:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongEventData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongEventData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize() + { + contextmenuEdit.onClick = function(_) { + chartEditorState.showToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveEventsCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx new file mode 100644 index 00000000..4a94cd1a --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx @@ -0,0 +1,41 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.MenuItem; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.commands.FlipNotesCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; +import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/hold-note.xml")) +class ChartEditorHoldNoteContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuFlip:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongNoteData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize():Void + { + // NOTE: Remember to use commands here to ensure undo/redo works properly + contextmenuFlip.onClick = function(_) { + chartEditorState.performCommand(new FlipNotesCommand([data])); + } + + contextmenuRemoveHold.onClick = function(_) { + chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 0)); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveNotesCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx new file mode 100644 index 00000000..41931281 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx @@ -0,0 +1,41 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.MenuItem; +import funkin.data.song.SongData.SongNoteData; +import funkin.ui.debug.charting.commands.FlipNotesCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; +import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/note.xml")) +class ChartEditorNoteContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuFlip:MenuItem; + var contextmenuDelete:MenuItem; + + var data:SongNoteData; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData) + { + super(chartEditorState2, xPos2, yPos2); + this.data = data; + + initialize(); + } + + function initialize():Void + { + // NOTE: Remember to use commands here to ensure undo/redo works properly + contextmenuFlip.onClick = function(_) { + chartEditorState.performCommand(new FlipNotesCommand([data])); + } + + contextmenuAddHold.onClick = function(_) { + chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 4, STEPS)); + } + + contextmenuDelete.onClick = function(_) { + chartEditorState.performCommand(new RemoveNotesCommand([data])); + } + } +} diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx new file mode 100644 index 00000000..883ce5a1 --- /dev/null +++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx @@ -0,0 +1,56 @@ +package funkin.ui.debug.charting.contextmenus; + +import haxe.ui.containers.menus.MenuItem; +import funkin.ui.debug.charting.commands.CutItemsCommand; +import funkin.ui.debug.charting.commands.RemoveEventsCommand; +import funkin.ui.debug.charting.commands.RemoveItemsCommand; +import funkin.ui.debug.charting.commands.RemoveNotesCommand; + +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/selection.xml")) +class ChartEditorSelectionContextMenu extends ChartEditorBaseContextMenu +{ + var contextmenuCut:MenuItem; + var contextmenuCopy:MenuItem; + var contextmenuPaste:MenuItem; + var contextmenuDelete:MenuItem; + var contextmenuFlip:MenuItem; + var contextmenuSelectAll:MenuItem; + var contextmenuSelectInverse:MenuItem; + var contextmenuSelectNone:MenuItem; + + public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0) + { + super(chartEditorState2, xPos2, yPos2); + + initialize(); + } + + function initialize():Void + { + contextmenuCut.onClick = (_) -> { + chartEditorState.performCommand(new CutItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection)); + }; + contextmenuCopy.onClick = (_) -> { + chartEditorState.copySelection(); + }; + contextmenuFlip.onClick = (_) -> { + if (chartEditorState.currentNoteSelection.length > 0 && chartEditorState.currentEventSelection.length > 0) + { + chartEditorState.performCommand(new RemoveItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection)); + } + else if (chartEditorState.currentNoteSelection.length > 0) + { + chartEditorState.performCommand(new RemoveNotesCommand(chartEditorState.currentNoteSelection)); + } + else if (chartEditorState.currentEventSelection.length > 0) + { + chartEditorState.performCommand(new RemoveEventsCommand(chartEditorState.currentEventSelection)); + } + else + { + // Do nothing??? + } + }; + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorAboutDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorAboutDialog.hx new file mode 100644 index 00000000..8b55be54 --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorAboutDialog.hx @@ -0,0 +1,25 @@ +package funkin.ui.debug.charting.dialogs; + +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams; + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/about.xml")) +class ChartEditorAboutDialog extends ChartEditorBaseDialog +{ + public function new(chartEditorState2:ChartEditorState, params2:DialogParams) + { + super(chartEditorState2, params2); + } + + public static function build(chartEditorState:ChartEditorState, ?closable:Bool, ?modal:Bool):ChartEditorAboutDialog + { + var dialog = new ChartEditorAboutDialog(chartEditorState, + { + closable: closable ?? true, + modal: modal ?? true + }); + + dialog.showDialog(modal ?? true); + + return dialog; + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx new file mode 100644 index 00000000..7cac85df --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseDialog.hx @@ -0,0 +1,114 @@ +package funkin.ui.debug.charting.dialogs; + +import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.dialogs.Dialog.DialogEvent; +import haxe.ui.animation.AnimationBuilder; +import haxe.ui.core.Component; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. + +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorBaseDialog extends Dialog +{ + var chartEditorState:ChartEditorState; + var params:DialogParams; + + var locked:Bool = false; + + public function new(chartEditorState:ChartEditorState, params:DialogParams) + { + super(); + + this.chartEditorState = chartEditorState; + this.params = params; + + this.destroyOnClose = true; + this.closable = params.closable ?? false; + + this.onDialogClosed = event -> onClose(event); + } + + public override function showDialog(modal:Bool = true):Void + { + super.showDialog(modal); + fadeInComponent(this, 1); + } + + private override function onReady():Void + { + _overlay.opacity = 0; + fadeInDialogOverlay(); + } + + /** + * Called when the dialog is closed. + * Override this to add custom behavior. + */ + public function onClose(event:DialogEvent):Void + { + chartEditorState.isHaxeUIDialogOpen = false; + } + + /** + * Locks this dialog from interaction. + * Use this when you want to prevent dialog interaction while another dialog is open. + */ + public function lock():Void + { + this.locked = true; + + this.closable = false; + } + + /** + * Unlocks the dialog for interaction. + */ + public function unlock():Void + { + this.locked = false; + + this.closable = params.closable ?? false; + } + + static final OVERLAY_EASE_DURATION:Float = 0.2; + static final OVERLAY_EASE_TYPE:String = "easeOut"; + + function fadeInDialogOverlay():Void + { + if (!modal) + { + trace('Dialog is not modal, skipping overlay fade...'); + return; + } + + if (_overlay == null) + { + trace('[WARN] Dialog overlay is null, skipping overlay fade...'); + return; + } + + fadeInComponent(_overlay, 0.5); + } + + function fadeInComponent(component:Component, fadeTo:Float = 1):Void + { + var builder = new AnimationBuilder(component, OVERLAY_EASE_DURATION, OVERLAY_EASE_TYPE); + builder.setPosition(0, "opacity", 0, true); // 0% absolute + builder.setPosition(100, "opacity", fadeTo, true); + + trace('Fading in dialog component...'); + builder.play(); + } +} + +typedef DialogParams = +{ + ?closable:Bool, + ?modal:Bool +}; + +typedef DialogDropTarget = +{ + component:Component, + handler:String->Void +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx new file mode 100644 index 00000000..95200151 --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorBaseMenu.hx @@ -0,0 +1,19 @@ +package funkin.ui.debug.charting.dialogs; + +import haxe.ui.containers.menus.Menu; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorBaseMenu extends Menu +{ + var chartEditorState:ChartEditorState; + + public function new(chartEditorState:ChartEditorState) + { + super(); + + this.chartEditorState = chartEditorState; + + // this.destroyOnClose = true; + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx new file mode 100644 index 00000000..2c9de5c6 --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorCharacterIconSelectorMenu.hx @@ -0,0 +1,146 @@ +package funkin.ui.debug.charting.dialogs; + +import flixel.math.FlxPoint; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.components.HealthIcon; +import funkin.util.SortUtil; +import haxe.ui.components.Label; +import haxe.ui.containers.Grid; +import haxe.ui.containers.ScrollView; +import haxe.ui.core.Screen; +import flixel.tweens.FlxTween; +import flixel.tweens.FlxEase; +import haxe.ui.components.Button; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:access(funkin.ui.debug.charting.ChartEditorState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/character-icon-selector.xml")) +class ChartEditorCharacterIconSelectorMenu extends ChartEditorBaseMenu +{ + public var charSelectScroll:ScrollView; + public var charIconName:Label; + + var currentCharButton:Button; + + public function new(chartEditorState2:ChartEditorState, charType:CharacterType, lockPosition:Bool = false) + { + super(chartEditorState2); + + initialize(charType, lockPosition); + this.alpha = 0; + this.y -= 10; + FlxTween.tween(this, {alpha: 1, y: this.y + 10}, 0.2, + { + ease: FlxEase.quartOut, + onComplete: function(_) { + // Just focus the button FFS. Idk why, but the scrollbar doesn't update until after the tween finishes with this???? + currentCharButton.focus = true; + } + }); + } + + function initialize(charType:CharacterType, lockPosition:Bool) + { + var currentCharId:String = switch (charType) + { + case BF: chartEditorState.currentSongMetadata.playData.characters.player; + case GF: chartEditorState.currentSongMetadata.playData.characters.girlfriend; + case DAD: chartEditorState.currentSongMetadata.playData.characters.opponent; + default: throw 'Invalid charType: ' + charType; + }; + + // Position this menu. + var targetHealthIcon:Null = switch (charType) + { + case BF: chartEditorState.healthIconBF; + case DAD: chartEditorState.healthIconDad; + default: null; + }; + + if (lockPosition && targetHealthIcon != null) + { + var healthIconBottomCenter:FlxPoint = new FlxPoint(targetHealthIcon.x + targetHealthIcon.width / 2, targetHealthIcon.y + targetHealthIcon.height); + + this.x = healthIconBottomCenter.x - this.width / 2; + this.y = healthIconBottomCenter.y; + } + else + { + this.x = Screen.instance.currentMouseX; + this.y = Screen.instance.currentMouseY; + } + + var charGrid = new Grid(); + charGrid.columns = 5; + charGrid.width = this.width; + charSelectScroll.addComponent(charGrid); + + var charIds:Array = CharacterDataParser.listCharacterIds(); + charIds.sort(SortUtil.alphabetically); + + charIds.insert(0, ""); // Add none/null/NuN character option + + var defaultText:String = '(choose a character)'; + + for (charIndex => charId in charIds) + { + var charData:CharacterData = CharacterDataParser.fetchCharacterData(charId); + + var charButton = new Button(); + charButton.width = 70; + charButton.height = 70; + charButton.padding = 8; + charButton.iconPosition = "top"; + + if (charId == currentCharId) + { + // Scroll to the character if it is already selected. + charSelectScroll.vscrollPos = Math.floor(charIndex / 5) * 80; + charButton.focus = true; + + defaultText = (currentCharId != "") ? '${charData.name} [${charId}]' : 'None'; + + currentCharButton = charButton; + } + + var LIMIT = 6; + charButton.icon = haxe.ui.util.Variant.fromImageData(CharacterDataParser.getCharPixelIconAsset(charId)); + charButton.text = (charId != "") ? (charData.name.length > LIMIT ? '${charData.name.substr(0, LIMIT)}.' : '${charData.name}') : 'None'; + + charButton.onClick = _ -> { + switch (charType) + { + case BF: chartEditorState.currentSongMetadata.playData.characters.player = charId; + case GF: chartEditorState.currentSongMetadata.playData.characters.girlfriend = charId; + case DAD: chartEditorState.currentSongMetadata.playData.characters.opponent = charId; + default: throw 'Invalid charType: ' + charType; + }; + + defaultText = (charId != "") ? '${charData.name} [${charId}]' : 'None'; + chartEditorState.healthIconsDirty = true; + chartEditorState.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); + }; + + charButton.onMouseOver = _ -> { + charIconName.text = (charId != "") ? '${charData.name} [${charId}]' : 'None'; + }; + charButton.onMouseOut = _ -> { + charIconName.text = defaultText; + }; + charGrid.addComponent(charButton); + } + + charIconName.text = defaultText; + } + + public static function build(chartEditorState:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):ChartEditorCharacterIconSelectorMenu + { + var menu = new ChartEditorCharacterIconSelectorMenu(chartEditorState, charType, lockPosition); + + Screen.instance.addComponent(menu); + + return menu; + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx new file mode 100644 index 00000000..e0b6b2b1 --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx @@ -0,0 +1,163 @@ +package funkin.ui.debug.charting.dialogs; + +import funkin.input.Cursor; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams; +import funkin.util.FileUtil; +import haxe.io.Path; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import haxe.ui.containers.dialogs.Dialog.DialogEvent; +import haxe.ui.containers.dialogs.Dialogs.SelectedFileInfo; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-chart.xml")) +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorUploadChartDialog extends ChartEditorBaseDialog +{ + var dropHandlers:Array = []; + + public function new(state2:ChartEditorState, params2:DialogParams) + { + super(state2, params2); + + this.dialogCancel.onClick = (_) -> this.hideDialog(DialogButton.CANCEL); + + this.chartBox.onClick = (_) -> this.onClickChartBox(); + + this.chartBox.onMouseOver = function(_event) { + if (this.locked) return; + this.chartBox.swapClass('upload-bg', 'upload-bg-hover'); + Cursor.cursorMode = Pointer; + } + + this.chartBox.onMouseOut = function(_event) { + this.chartBox.swapClass('upload-bg-hover', 'upload-bg'); + Cursor.cursorMode = Default; + } + + dropHandlers.push({component: this.chartBox, handler: this.onDropFileChartBox}); + } + + public static function build(state:ChartEditorState, ?closable:Bool, ?modal:Bool):ChartEditorUploadChartDialog + { + var dialog = new ChartEditorUploadChartDialog(state, + { + closable: closable ?? false, + modal: modal ?? true + }); + + for (dropTarget in dialog.dropHandlers) + { + state.addDropHandler(dropTarget); + } + + dialog.showDialog(modal ?? true); + + return dialog; + } + + public override function onClose(event:DialogEvent):Void + { + super.onClose(event); + + if (event.button != DialogButton.APPLY && !this.closable) + { + // User cancelled the wizard! Back to the welcome dialog. + chartEditorState.openWelcomeDialog(this.closable); + } + + for (dropTarget in dropHandlers) + { + chartEditorState.removeDropHandler(dropTarget); + } + } + + public override function lock():Void + { + super.lock(); + this.dialogCancel.disabled = true; + } + + public override function unlock():Void + { + super.unlock(); + this.dialogCancel.disabled = false; + } + + /** + * Called when clicking the Upload Chart box. + */ + public function onClickChartBox():Void + { + if (this.locked) return; + + this.lock(); + // TODO / BUG: File filtering not working on mac finder dialog, so we don't use it for now + #if !mac + FileUtil.browseForBinaryFile('Open Chart', [FileUtil.FILE_EXTENSION_INFO_FNFC], onSelectFile, onCancelBrowse); + #else + FileUtil.browseForBinaryFile('Open Chart', null, onSelectFile, onCancelBrowse); + #end + } + + /** + * Called when a file is selected by dropping a file onto the Upload Chart box. + */ + function onDropFileChartBox(pathStr:String):Void + { + var path:Path = new Path(pathStr); + trace('Dropped file (${path})'); + + try + { + var result:Null> = ChartEditorImportExportHandler.loadFromFNFCPath(chartEditorState, path.toString()); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${path.toString()})' : 'Loaded chart (${path.toString()})\n${result.join("\n")}'); + this.hideDialog(DialogButton.APPLY); + } + else + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()})'); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()}): ${err}'); + } + } + + /** + * Called when a file is selected by the dialog displayed when clicking the Upload Chart box. + */ + function onSelectFile(selectedFile:SelectedFileInfo):Void + { + this.unlock(); + + if (selectedFile != null && selectedFile.bytes != null) + { + try + { + var result:Null> = ChartEditorImportExportHandler.loadFromFNFC(chartEditorState, selectedFile.bytes); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${selectedFile.name})' : 'Loaded chart (${selectedFile.name})\n${result.join("\n")}'); + + if (selectedFile.fullPath != null) chartEditorState.currentWorkingFilePath = selectedFile.fullPath; + this.hideDialog(DialogButton.APPLY); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${selectedFile.name}): ${err}'); + } + } + } + + function onCancelBrowse():Void + { + this.unlock(); + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx new file mode 100644 index 00000000..c585c5eb --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx @@ -0,0 +1,309 @@ +package funkin.ui.debug.charting.dialogs; + +import funkin.input.Cursor; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams; +import funkin.util.FileUtil; +import funkin.play.character.CharacterData; +import haxe.io.Path; +import haxe.ui.components.Button; +import haxe.ui.components.Label; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import haxe.ui.containers.dialogs.Dialog.DialogEvent; +import haxe.ui.containers.Box; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.core.Component; + +// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros. + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals.xml")) +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorUploadVocalsDialog extends ChartEditorBaseDialog +{ + var dropHandlers:Array = []; + + var vocalContainer:Component; + var dialogCancel:Button; + var dialogNoVocals:Button; + var dialogContinue:Button; + + var charIds:Array; + var instId:String; + var hasClearedVocals:Bool = false; + + public function new(state2:ChartEditorState, charIds:Array, params2:DialogParams) + { + super(state2, params2); + + this.charIds = charIds; + this.instId = chartEditorState.currentInstrumentalId; + + dialogCancel.onClick = function(_) { + hideDialog(DialogButton.CANCEL); + } + + dialogNoVocals.onClick = function(_) { + // Dismiss + chartEditorState.wipeVocalData(); + hideDialog(DialogButton.APPLY); + }; + + dialogContinue.onClick = function(_) { + // Dismiss + hideDialog(DialogButton.APPLY); + }; + + buildDropHandlers(); + } + + function buildDropHandlers():Void + { + for (charKey in charIds) + { + trace('Adding vocal upload for character ${charKey}'); + + var charMetadata:Null = CharacterDataParser.fetchCharacterData(charKey); + var charName:String = charMetadata?.name ?? charKey; + + var vocalsEntry = new ChartEditorUploadVocalsEntry(charName); + + var dropHandler:DialogDropTarget = {component: vocalsEntry, handler: null}; + + var onDropFile:String->Void = function(pathStr:String) { + trace('Selected file: $pathStr'); + var path:Path = new Path(pathStr); + + if (chartEditorState.loadVocalsFromPath(path, charKey, this.instId, !this.hasClearedVocals)) + { + this.hasClearedVocals = true; + // Tell the user the load was successful. + chartEditorState.success('Loaded Vocals', 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${chartEditorState.selectedVariation}'); + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}'; + #end + + dialogNoVocals.hidden = true; + chartEditorState.removeDropHandler(dropHandler); + } + else + { + trace('Failed to load vocal track (${path.file}.${path.ext})'); + + chartEditorState.error('Failed to Load Vocals', + 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${chartEditorState.selectedVariation})'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + } + }; + + vocalsEntry.onClick = function(_event) { + Dialogs.openBinaryFile('Open $charName Vocals', [ + {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) { + if (selectedFile != null && selectedFile.bytes != null) + { + trace('Selected file: ' + selectedFile.name); + + if (chartEditorState.loadVocalsFromBytes(selectedFile.bytes, charKey, this.instId, !this.hasClearedVocals)) + { + hasClearedVocals = true; + // Tell the user the load was successful. + chartEditorState.success('Loaded Vocals', + 'Loaded vocals for $charName (${selectedFile.name}), variation ${chartEditorState.selectedVariation}'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}'; + #end + + dialogNoVocals.hidden = true; + } + else + { + trace('Failed to load vocal track (${selectedFile.fullPath})'); + + chartEditorState.error('Failed to Load Vocals', + 'Failed to load vocal track (${selectedFile.name}) for variation (${chartEditorState.selectedVariation})'); + + #if FILE_DROP_SUPPORTED + vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + } + } + }); + } + + dropHandler.handler = onDropFile; + + // onDropFile + #if FILE_DROP_SUPPORTED + dropHandlers.push(dropHandler); + #end + + vocalContainer.addComponent(vocalsEntry); + } + } + + public static function build(state:ChartEditorState, charIds:Array, ?closable:Bool, ?modal:Bool):ChartEditorUploadVocalsDialog + { + var dialog = new ChartEditorUploadVocalsDialog(state, charIds, + { + closable: closable ?? false, + modal: modal ?? true + }); + + for (dropTarget in dialog.dropHandlers) + { + state.addDropHandler(dropTarget); + } + + dialog.showDialog(modal ?? true); + + return dialog; + } + + public override function onClose(event:DialogEvent):Void + { + super.onClose(event); + + if (event.button != DialogButton.APPLY && !this.closable) + { + // User cancelled the wizard! Back to the welcome dialog. + chartEditorState.openWelcomeDialog(this.closable); + } + + for (dropTarget in dropHandlers) + { + chartEditorState.removeDropHandler(dropTarget); + } + } + + public override function lock():Void + { + super.lock(); + this.dialogCancel.disabled = true; + } + + public override function unlock():Void + { + super.unlock(); + this.dialogCancel.disabled = false; + } + + /** + * Called when clicking the Upload Chart box. + */ + public function onClickChartBox():Void + { + if (this.locked) return; + + this.lock(); + // TODO / BUG: File filtering not working on mac finder dialog, so we don't use it for now + #if !mac + FileUtil.browseForBinaryFile('Open Chart', [FileUtil.FILE_EXTENSION_INFO_FNFC], onSelectFile, onCancelBrowse); + #else + FileUtil.browseForBinaryFile('Open Chart', null, onSelectFile, onCancelBrowse); + #end + } + + /** + * Called when a file is selected by dropping a file onto the Upload Chart box. + */ + function onDropFileChartBox(pathStr:String):Void + { + var path:Path = new Path(pathStr); + trace('Dropped file (${path})'); + + try + { + var result:Null> = ChartEditorImportExportHandler.loadFromFNFCPath(chartEditorState, path.toString()); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${path.toString()})' : 'Loaded chart (${path.toString()})\n${result.join("\n")}'); + this.hideDialog(DialogButton.APPLY); + } + else + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()})'); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()}): ${err}'); + } + } + + /** + * Called when a file is selected by the dialog displayed when clicking the Upload Chart box. + */ + function onSelectFile(selectedFile:SelectedFileInfo):Void + { + this.unlock(); + + if (selectedFile != null && selectedFile.bytes != null) + { + try + { + var result:Null> = ChartEditorImportExportHandler.loadFromFNFC(chartEditorState, selectedFile.bytes); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${selectedFile.name})' : 'Loaded chart (${selectedFile.name})\n${result.join("\n")}'); + + if (selectedFile.fullPath != null) chartEditorState.currentWorkingFilePath = selectedFile.fullPath; + this.hideDialog(DialogButton.APPLY); + } + } + catch (err) + { + chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${selectedFile.name}): ${err}'); + } + } + } + + function onCancelBrowse():Void + { + this.unlock(); + } +} + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals-entry.xml")) +class ChartEditorUploadVocalsEntry extends Box +{ + public var vocalsEntryLabel:Label; + + var charName:String; + + public function new(charName:String) + { + super(); + + this.charName = charName; + + #if FILE_DROP_SUPPORTED + vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.'; + #else + vocalsEntryLabel.text = 'Click to browse for vocals for $charName.'; + #end + + this.onMouseOver = function(_event) { + // if (this.locked) return; + this.swapClass('upload-bg', 'upload-bg-hover'); + Cursor.cursorMode = Pointer; + } + + this.onMouseOut = function(_event) { + this.swapClass('upload-bg-hover', 'upload-bg'); + Cursor.cursorMode = Default; + } + } +} diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx new file mode 100644 index 00000000..165d4ca5 --- /dev/null +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx @@ -0,0 +1,244 @@ +package funkin.ui.debug.charting.dialogs; + +import funkin.data.song.SongRegistry; +import funkin.play.song.Song; +import funkin.ui.debug.charting.ChartEditorState; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams; +import funkin.util.FileUtil; +import funkin.util.SortUtil; +import haxe.ui.components.Label; +import haxe.ui.components.Link; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import haxe.ui.containers.dialogs.Dialog.DialogEvent; +import haxe.ui.events.MouseEvent; + +/** + * Builds and opens a dialog letting the user create a new chart, open a recent chart, or load from a template. + * Opens when the chart editor first opens. + */ +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/welcome.xml")) +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorWelcomeDialog extends ChartEditorBaseDialog +{ + /** + * @param closable Whether the dialog can be closed by the user. + * @param modal Whether the dialog is locked to the center of the screen (with a dark overlay behind it). + */ + public function new(state2:ChartEditorState, params2:DialogParams) + { + super(state2, params2); + + this.splashBrowse.onClick = _ -> onClickButtonBrowse(); + this.splashCreateFromSongBasicOnly.onClick = _ -> onClickLinkCreateBasicOnly(); + this.splashCreateFromSongErectOnly.onClick = _ -> onClickLinkCreateErectOnly(); + this.splashCreateFromSongBasicErect.onClick = _ -> onClickLinkCreateBasicErect(); + this.splashImportChartLegacy.onClick = _ -> onClickLinkImportChartLegacy(); + + // Add items to the Recent Charts list + #if sys + for (chartPath in chartEditorState.previousWorkingFilePaths) + { + if (chartPath == null) continue; + this.addRecentFilePath(chartEditorState, chartPath); + } + #else + this.addHTML5RecentFileMessage(); + #end + + // Add items to the Load From Template list + this.buildTemplateSongList(chartEditorState); + } + + /** + * @param state The current state of the chart editor. + * @return A newly created `ChartEditorWelcomeDialog`. + */ + public static function build(chartEditorState:ChartEditorState, ?closable:Bool, ?modal:Bool):ChartEditorWelcomeDialog + { + var dialog = new ChartEditorWelcomeDialog(chartEditorState, + { + closable: closable ?? false, + modal: modal ?? true + }); + + dialog.showDialog(modal ?? true); + + return dialog; + } + + public override function onClose(event:DialogEvent):Void + { + super.onClose(event); + } + + /** + * Add a file path to the "Open Recent" scroll box on the left. + * @param path + */ + public function addRecentFilePath(state:ChartEditorState, chartPath:String):Void + { + var linkRecentChart:Link = new Link(); + + var fileNamePattern:EReg = new EReg("([^/\\\\]+)$", ""); + var fileName:String = fileNamePattern.match(chartPath) ? fileNamePattern.matched(1) : chartPath; + linkRecentChart.text = fileName; + + linkRecentChart.tooltip = chartPath; + + #if sys + var lastModified:String = "Last Modified: " + sys.FileSystem.stat(chartPath).mtime.toString(); + linkRecentChart.tooltip += "\n" + lastModified; + #end + + linkRecentChart.onClick = function(_event) { + linkRecentChart.hide(); + + this.hideDialog(DialogButton.CANCEL); + + // Load chart from file + var result:Null> = ChartEditorImportExportHandler.loadFromFNFCPath(state, chartPath); + if (result != null) + { + chartEditorState.success('Loaded Chart', + result.length == 0 ? 'Loaded chart (${chartPath.toString()})' : 'Loaded chart (${chartPath.toString()})\n${result.join("\n")}'); + } + else + { + chartEditorState.error('Failed to Load Chart', 'Failed to load chart (${chartPath.toString()})'); + } + } + + if (!FileUtil.fileExists(chartPath)) + { + trace('Previously loaded chart file (${chartPath}) does not exist, disabling link...'); + linkRecentChart.disabled = true; + } + + splashRecentContainer.addComponent(linkRecentChart); + } + + /** + * Add a string message to the "Open Recent" scroll box on the left. + * Only displays on platforms which don't support direct file system access. + */ + public function addHTML5RecentFileMessage():Void + { + var webLoadLabel:Label = new Label(); + webLoadLabel.text = 'Click the button below to load a chart file (.fnfc) from your computer.'; + + splashRecentContainer.addComponent(webLoadLabel); + } + + /** + * Add all the links to the "Create From Template" scroll box on the right. + */ + public function buildTemplateSongList(state:ChartEditorState):Void + { + var songList:Array = SongRegistry.instance.listEntryIds(); + songList.sort(SortUtil.alphabetically); + + for (targetSongId in songList) + { + var songData:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (songData == null) continue; + + var songName:Null = songData.getDifficulty('normal')?.songName; + if (songName == null) songName = songData.getDifficulty()?.songName; + if (songName == null) // Still null? + { + trace('[WARN] Could not fetch song name for ${targetSongId}'); + continue; + } + + this.addTemplateSong(songName, targetSongId, (_) -> { + this.hideDialog(DialogButton.CANCEL); + + // Load song from template + chartEditorState.loadSongAsTemplate(targetSongId); + }); + } + } + + /** + * @param loadTemplateCb The callback to call when the user clicks the link. The callback should load the song ID from the template. + */ + public function addTemplateSong(songName:String, songId:String, onClickCb:(MouseEvent) -> Void):Void + { + var linkTemplateSong:Link = new Link(); + linkTemplateSong.text = songName; + linkTemplateSong.onClick = onClickCb; + + this.splashTemplateContainer.addComponent(linkTemplateSong); + } + + /** + * Called when the user clicks the "Browse Chart" button in the dialog. + * Reassign this function to change the behavior. + */ + public function onClickButtonBrowse():Void + { + // Hide the welcome dialog + this.hideDialog(DialogButton.CANCEL); + + // Open the "Open Chart" dialog + chartEditorState.openBrowseFNFC(false); + } + + /** + * Called when the user clicks the "Create From Template: Easy/Normal/Hard Only" link in the dialog. + * Reassign this function to change the behavior. + */ + public function onClickLinkCreateBasicOnly():Void + { + // Hide the welcome dialog + this.hideDialog(DialogButton.CANCEL); + + // + // Create Song Wizard + // + chartEditorState.openCreateSongWizardBasicOnly(false); + } + + /** + * Called when the user clicks the "Create From Template: Erect/Nightmare Only" link in the dialog. + * Reassign this function to change the behavior. + */ + public function onClickLinkCreateErectOnly():Void + { + // Hide the welcome dialog + this.hideDialog(DialogButton.CANCEL); + + // + // Create Song Wizard + // + chartEditorState.openCreateSongWizardErectOnly(false); + } + + /** + * Called when the user clicks the "Create From Template: Easy/Normal/Hard/Erect/Nightmare" link in the dialog. + * Reassign this function to change the behavior. + */ + public function onClickLinkCreateBasicErect():Void + { + // Hide the welcome dialog + this.hideDialog(DialogButton.CANCEL); + + // + // Create Song Wizard + // + chartEditorState.openCreateSongWizardBasicErect(false); + } + + /** + * Called when the user clicks the "Import Chart: FNF Legacy" link in the dialog. + * Reassign this function to change the behavior. + */ + public function onClickLinkImportChartLegacy():Void + { + // Hide the welcome dialog + this.hideDialog(DialogButton.CANCEL); + + // Open the "Import Chart" dialog + chartEditorState.openImportChartWizard('legacy', false); + } +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx new file mode 100644 index 00000000..329873d6 --- /dev/null +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -0,0 +1,382 @@ +package funkin.ui.debug.charting.handlers; + +import flixel.system.FlxAssets.FlxSoundAsset; +import funkin.audio.VoicesGroup; +import funkin.audio.FunkinSound; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.util.FileUtil; +import funkin.util.assets.SoundUtil; +import funkin.util.TimerUtil; +import funkin.audio.waveform.WaveformData; +import funkin.audio.waveform.WaveformDataParser; +import funkin.audio.waveform.WaveformSprite; +import flixel.util.FlxColor; +import haxe.io.Bytes; +import haxe.io.Path; + +/** + * Functions for loading audio for the chart editor. + * Handlers split up the functionality of the Chart Editor into different classes based on focus to limit the amount of code in each class. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorAudioHandler +{ + /** + * Loads and stores byte data for a vocal track from an absolute file path + * + * @param path The absolute path to the audio file. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = '', wipeFirst:Bool = false):Bool + { + #if sys + var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); + return loadVocalsFromBytes(state, fileBytes, charId, instId, wipeFirst); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + return false; + #end + } + + /** + * Loads and stores byte data for a vocal track from an asset + * + * @param path The path to the asset. Use `Paths` to build this. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = '', wipeFirst:Bool = false):Bool + { + var trackData:Null = Assets.getBytes(path); + if (trackData != null) + { + return loadVocalsFromBytes(state, trackData, charId, instId, wipeFirst); + } + return false; + } + + /** + * Loads and stores byte data for a vocal track + * + * @param bytes The audio byte data. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + * @param wipeFirst Whether to wipe the existing vocal data before loading. + */ + public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = '', wipeFirst:Bool = false):Bool + { + var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; + if (wipeFirst) wipeVocalData(state); + state.audioVocalTrackData.set(trackId, bytes); + return true; + } + + /** + * Loads and stores byte data for an instrumental track from an absolute file path + * + * @param path The absolute path to the audio file. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = '', wipeFirst:Bool = false):Bool + { + #if sys + var fileBytes:Bytes = sys.io.File.getBytes(path.toString()); + return loadInstFromBytes(state, fileBytes, instId, wipeFirst); + #else + trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way."); + return false; + #end + } + + /** + * Loads and stores byte data for an instrumental track from an asset + * + * @param path The path to the asset. Use `Paths` to build this. + * @param instId The instrumental this vocal track will be for. + * @return Success or failure. + */ + public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = '', wipeFirst:Bool = false):Bool + { + var trackData:Null = Assets.getBytes(path); + if (trackData != null) + { + return loadInstFromBytes(state, trackData, instId, wipeFirst); + } + return false; + } + + /** + * Loads and stores byte data for a vocal track + * + * @param bytes The audio byte data. + * @param charId The character this vocal track will be for. + * @param instId The instrumental this vocal track will be for. + */ + public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = '', wipeFirst:Bool = false):Bool + { + if (instId == '') instId = 'default'; + if (wipeFirst) wipeInstrumentalData(state); + state.audioInstTrackData.set(instId, bytes); + return true; + } + + public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool + { + var perfA:Float = TimerUtil.start(); + + var result:Bool = playInstrumental(state, instId); + if (!result) return false; + + var perfB:Float = TimerUtil.start(); + + stopExistingVocals(state); + + var perfC:Float = TimerUtil.start(); + + result = playVocals(state, BF, playerId, instId); + + var perfD:Float = TimerUtil.start(); + + // if (!result) return false; + result = playVocals(state, DAD, opponentId, instId); + // if (!result) return false; + + var perfE:Float = TimerUtil.start(); + + state.hardRefreshOffsetsToolbox(); + + var perfF:Float = TimerUtil.start(); + + state.hardRefreshFreeplayToolbox(); + + var perfG:Float = TimerUtil.start(); + + trace('Switched to instrumental in ${TimerUtil.seconds(perfA, perfB)}.'); + trace('Stopped existing vocals in ${TimerUtil.seconds(perfB, perfC)}.'); + trace('Played BF vocals in ${TimerUtil.seconds(perfC, perfD)}.'); + trace('Played DAD vocals in ${TimerUtil.seconds(perfD, perfE)}.'); + trace('Hard refreshed offsets toolbox in ${TimerUtil.seconds(perfE, perfF)}.'); + trace('Hard refreshed freeplay toolbox in ${TimerUtil.seconds(perfF, perfG)}.'); + + return true; + } + + /** + * Tell the Chart Editor to select a specific instrumental track, that is already loaded. + */ + public static function playInstrumental(state:ChartEditorState, instId:String = ''):Bool + { + if (instId == '') instId = 'default'; + var instTrackData:Null = state.audioInstTrackData.get(instId); + var perfStart:Float = TimerUtil.start(); + var instTrack:Null = SoundUtil.buildSoundFromBytes(instTrackData); + trace('Built instrumental track in ${TimerUtil.seconds(perfStart)} seconds.'); + if (instTrack == null) return false; + + stopExistingInstrumental(state); + state.audioInstTrack = instTrack; + state.postLoadInstrumental(); + // Workaround for a bug where FlxG.sound.music.update() was being called twice. + FlxG.sound.list.remove(instTrack); + return true; + } + + public static function stopExistingInstrumental(state:ChartEditorState):Void + { + if (state.audioInstTrack != null) + { + state.audioInstTrack.stop(); + state.audioInstTrack.destroy(); + state.audioInstTrack = null; + } + } + + /** + * Tell the Chart Editor to select a specific vocal track, that is already loaded. + */ + public static function playVocals(state:ChartEditorState, charType:CharacterType, charId:String, instId:String = ''):Bool + { + var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}'; + var vocalTrackData:Null = state.audioVocalTrackData.get(trackId); + var perfStart:Float = TimerUtil.start(); + var vocalTrack:Null = SoundUtil.buildSoundFromBytes(vocalTrackData); + trace('Built vocal track in ${TimerUtil.seconds(perfStart)}.'); + + if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup(); + + if (vocalTrack != null) + { + switch (charType) + { + case BF: + state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); + + var perfStart:Float = TimerUtil.start(); + var waveformData:Null = vocalTrack.waveformData; + trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.'); + + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 840; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } + + state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer; + return true; + case DAD: + state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); + + var perfStart:Float = TimerUtil.start(); + var waveformData:Null = vocalTrack.waveformData; + trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.'); + + if (waveformData != null) + { + var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001; + var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE); + waveformSprite.x = 360; + waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD); + waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16; + waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2; + waveformSprite.time = 0; + waveformSprite.duration = duration; + state.audioWaveforms.add(waveformSprite); + } + else + { + trace('[WARN] Failed to parse waveform data for vocal track.'); + } + + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent; + + return true; + case OTHER: + state.audioVocalTrackGroup.add(vocalTrack); + // TODO: Add offset for other characters. + return true; + default: + // Do nothing. + } + } + + return false; + } + + public static function stopExistingVocals(state:ChartEditorState):Void + { + state.audioVocalTrackGroup.clear(); + if (state.audioWaveforms != null) + { + state.audioWaveforms.clear(); + } + } + + /** + * Play a sound effect. + * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. + * @param path The path to the sound effect. Use `Paths` to build this. + */ + public static function playSound(_state:ChartEditorState, path:String, volume:Float = 1.0):Void + { + var asset:Null = FlxG.sound.cache(path); + if (asset == null) + { + trace('WARN: Failed to play sound $path, asset not found.'); + return; + } + var snd:Null = FunkinSound.load(asset); + if (snd == null) return; + snd.autoDestroy = true; + snd.play(true); + snd.volume = volume; + } + + public static function wipeInstrumentalData(state:ChartEditorState):Void + { + state.audioInstTrackData.clear(); + stopExistingInstrumental(state); + } + + public static function wipeVocalData(state:ChartEditorState):Void + { + state.audioVocalTrackData.clear(); + stopExistingVocals(state); + } + + /** + * Create a list of ZIP file entries from the current loaded instrumental tracks in the chart eidtor. + * @param state The chart editor state. + * @return `Array` + */ + public static function makeZIPEntriesFromInstrumentals(state:ChartEditorState):Array + { + var zipEntries = []; + + var instTrackIds = state.audioInstTrackData.keys().array(); + for (key in instTrackIds) + { + if (key == 'default') + { + var data:Null = state.audioInstTrackData.get('default'); + if (data == null) + { + trace('[WARN] Failed to access inst track ($key)'); + continue; + } + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', data)); + } + else + { + var data:Null = state.audioInstTrackData.get(key); + if (data == null) + { + trace('[WARN] Failed to access inst track ($key)'); + continue; + } + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst-${key}.ogg', data)); + } + } + + return zipEntries; + } + + /** + * Create a list of ZIP file entries from the current loaded vocal tracks in the chart eidtor. + * @param state The chart editor state. + * @return `Array` + */ + public static function makeZIPEntriesFromVocals(state:ChartEditorState):Array + { + var zipEntries = []; + + var vocalTrackIds = state.audioVocalTrackData.keys().array(); + for (key in state.audioVocalTrackData.keys()) + { + var data:Null = state.audioVocalTrackData.get(key); + if (data == null) + { + trace('[WARN] Failed to access vocal track ($key)'); + continue; + } + zipEntries.push(FileUtil.makeZIPEntryFromBytes('Voices-${key}.ogg', data)); + } + + return zipEntries; + } +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx new file mode 100644 index 00000000..c1eea537 --- /dev/null +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx @@ -0,0 +1,82 @@ +package funkin.ui.debug.charting.handlers; + +import funkin.ui.debug.charting.contextmenus.ChartEditorDefaultContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorEventContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorHoldNoteContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorNoteContextMenu; +import funkin.ui.debug.charting.contextmenus.ChartEditorSelectionContextMenu; +import haxe.ui.containers.menus.Menu; +import haxe.ui.core.Screen; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongEventData; + +/** + * Handles context menus (the little menus that appear when you right click on stuff) for the new Chart Editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorContextMenuHandler +{ + static var existingMenus:Array = []; + + public static function openDefaultContextMenu(state:ChartEditorState, xPos:Float, yPos:Float) + { + displayMenu(state, new ChartEditorDefaultContextMenu(state, xPos, yPos)); + } + + /** + * Opened when shift+right-clicking a selection of multiple items. + */ + public static function openSelectionContextMenu(state:ChartEditorState, xPos:Float, yPos:Float) + { + displayMenu(state, new ChartEditorSelectionContextMenu(state, xPos, yPos)); + } + + /** + * Opened when shift+right-clicking a single note. + */ + public static function openNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData) + { + displayMenu(state, new ChartEditorNoteContextMenu(state, xPos, yPos, data)); + } + + /** + * Opened when shift+right-clicking a single hold note. + */ + public static function openHoldNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData) + { + displayMenu(state, new ChartEditorHoldNoteContextMenu(state, xPos, yPos, data)); + } + + /** + * Opened when shift+right-clicking a single event. + */ + public static function openEventContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongEventData) + { + displayMenu(state, new ChartEditorEventContextMenu(state, xPos, yPos, data)); + } + + static function displayMenu(state:ChartEditorState, targetMenu:Menu) + { + // Close any existing menus + closeAllMenus(state); + + // Show the new menu + Screen.instance.addComponent(targetMenu); + existingMenus.push(targetMenu); + } + + public static function closeMenu(state:ChartEditorState, targetMenu:Menu) + { + // targetMenu.close(); + existingMenus.remove(targetMenu); + } + + public static function closeAllMenus(state:ChartEditorState) + { + for (existingMenu in existingMenus) + { + closeMenu(state, existingMenu); + } + } +} diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx new file mode 100644 index 00000000..59d4286a --- /dev/null +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -0,0 +1,1365 @@ +package funkin.ui.debug.charting.handlers; + +import flixel.util.FlxTimer; +import funkin.data.song.importer.FNFLegacyData; +import funkin.data.song.importer.FNFLegacyImporter; +import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongChartData; +import funkin.data.song.SongData.SongMetadata; +import funkin.data.song.SongData.SongTimeChange; +import funkin.data.song.SongRegistry; +import funkin.input.Cursor; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.ui.debug.charting.dialogs.ChartEditorAboutDialog; +import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget; +import funkin.ui.debug.charting.dialogs.ChartEditorCharacterIconSelectorMenu; +import funkin.ui.debug.charting.dialogs.ChartEditorUploadChartDialog; +import funkin.ui.debug.charting.dialogs.ChartEditorWelcomeDialog; +import funkin.ui.debug.charting.dialogs.ChartEditorUploadVocalsDialog; +import funkin.ui.debug.charting.util.ChartEditorDropdowns; +import funkin.util.Constants; +import funkin.util.DateUtil; +import funkin.util.FileUtil; +import funkin.util.VersionUtil; +import haxe.io.Path; +import haxe.ui.components.Button; +import haxe.ui.components.DropDown; +import haxe.ui.components.Label; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.Slider; +import haxe.ui.components.TextField; +import haxe.ui.containers.Box; +import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.containers.Form; +import haxe.ui.containers.menus.Menu; +import haxe.ui.core.Component; +import haxe.ui.events.UIEvent; +import haxe.ui.RuntimeComponentBuilder; +import thx.semver.Version; + +using Lambda; + +/** + * Handles dialogs for the new Chart Editor. + */ +@:nullSafety +@:access(funkin.ui.debug.charting.ChartEditorState) +class ChartEditorDialogHandler +{ + // Paths to HaxeUI layout files for each dialog. + static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst'); + static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata'); + static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts'); + static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts-entry'); + static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart'); + static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT:String = Paths.ui('chart-editor/dialogs/user-guide'); + static final CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-variation'); + static final CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-difficulty'); + static final CHART_EDITOR_DIALOG_BACKUP_AVAILABLE_LAYOUT:String = Paths.ui('chart-editor/dialogs/backup-available'); + + /** + * Builds and opens a dialog giving brief credits for the chart editor. + * @param state The current chart editor state. + * @return The dialog that was opened. + */ + public static function openAboutDialog(state:ChartEditorState):Null + { + var dialog = ChartEditorAboutDialog.build(state); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens a dialog letting the user create a new chart, open a recent chart, or load from a template. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Null + { + var dialog = ChartEditorWelcomeDialog.build(state, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + state.fadeInWelcomeMusic(); + + return dialog; + } + + /** + * Builds and opens a dialog letting the user browse for a chart file to open. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null + { + var dialog = ChartEditorUploadChartDialog.build(state, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens a dialog where the user uploads vocals for the current song. + * @param state The current chart editor state. + * @param closable Whether the dialog can be closed by the user. + * @return The dialog that was opened. + */ + public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog + { + var charData:SongCharacterData = state.currentSongMetadata.playData.characters; + + var hasClearedVocals:Bool = false; + + var charIdsForVocals:Array = [charData.player, charData.opponent]; + + var dialog = ChartEditorUploadVocalsDialog.build(state, charIdsForVocals, closable); + + dialog.zIndex = 1000; + state.isHaxeUIDialogOpen = true; + + return dialog; + } + + /** + * Builds and opens the dialog for selecting a character. + */ + public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null + { + var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition); + + menu.zIndex = 1000; + + return menu; + } + + /** + * Builds and opens a dialog letting the user know a backup is available, and prompting them to load it. + */ + public static function openBackupAvailableDialog(state:ChartEditorState, welcomeDialog:Null):Null + { + var dialog:Null = openDialog(state, CHART_EDITOR_DIALOG_BACKUP_AVAILABLE_LAYOUT, true, true); + if (dialog == null) throw 'Could not locate Backup Available dialog'; + dialog.onDialogClosed = function(event) { + state.isHaxeUIDialogOpen = false; + if (event.button == DialogButton.APPLY) + { + // User loaded the backup! Close the welcome dialog behind this. + if (welcomeDialog != null) welcomeDialog.hideDialog(DialogButton.APPLY); + } + else + { + // User cancelled the dialog, don't close the welcome dialog so we aren't in a broken state. + } + }; + + state.isHaxeUIDialogOpen = true; + + var backupTimeLabel:Null +') +class BackupAvailableDialog extends Dialog +{ + override public function new(state:StageEditorState, filePath:String) + { + super(); + + if (!FileUtil.fileExists(filePath)) return; + + // time text + var fileDate = Path.withoutExtension(Path.withoutDirectory(filePath)); + var dateParts = fileDate.split("-"); + + while (dateParts.length < 8) + dateParts.push("0"); + + var year:Int = Std.parseInt(dateParts[2]) ?? 0; // copied parts from ChartEditorImportExportHandler.hx + var month:Int = Std.parseInt(dateParts[3]) ?? 1; + var day:Int = Std.parseInt(dateParts[4]) ?? 0; + var hour:Int = Std.parseInt(dateParts[5]) ?? 0; + var minute:Int = Std.parseInt(dateParts[6]) ?? 0; + var second:Int = Std.parseInt(dateParts[7]) ?? 0; + + backupTimeLabel.text = DateUtil.generateCleanTimestamp(new Date(year, month - 1, day, hour, minute, second)); + + // button callbacks + dialogCancel.onClick = function(_) hideDialog(DialogButton.CANCEL); + + buttonGoToFolder.onClick = function(_) { + // :[ + #if sys + var absoluteBackupsPath:String = Path.join([Sys.getCwd(), StageEditorState.BACKUPS_PATH]); + FileUtil.openFolder(absoluteBackupsPath); + #end + } + + buttonOpenBackup.onClick = function(_) { + 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); + } + hideDialog(DialogButton.APPLY); + } + + // uhhh + onDialogClosed = function(event) { + if (event.button == DialogButton.APPLY) + { + if (state.welcomeDialog != null) state.welcomeDialog.hideDialog(DialogButton.APPLY); + } + }; + } +} diff --git a/source/funkin/ui/debug/stageeditor/components/ExitConfirmDialog.hx b/source/funkin/ui/debug/stageeditor/components/ExitConfirmDialog.hx new file mode 100644 index 00000000..91669dc4 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/ExitConfirmDialog.hx @@ -0,0 +1,31 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/exit-confirm.xml")) +class ExitConfirmDialog extends Dialog +{ + var onComplete:Void->Void = null; + + override public function new(onComp:Void->Void) + { + super(); + + onComplete = onComp; + + buttons = DialogButton.CANCEL | "{{Proceed}}"; + defaultButton = "{{Proceed}}"; + + destroyOnClose = true; + } + + public override function validateDialog(button:DialogButton, fn:Bool->Void) + { + if (button == "{{Proceed}}" && onComplete != null) + { + onComplete(); + } + + fn(true); + } +} diff --git a/source/funkin/ui/debug/stageeditor/components/FindObjDialog.hx b/source/funkin/ui/debug/stageeditor/components/FindObjDialog.hx new file mode 100644 index 00000000..bb5b7ec5 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/FindObjDialog.hx @@ -0,0 +1,103 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.components.TextField; +import haxe.ui.components.CheckBox; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/find-object.xml")) +class FindObjDialog extends Dialog +{ + var stageEditorState:StageEditorState; + + var assets:Array = []; + var curSelected:Int = 0; + var field:TextField; + + var checkWord:CheckBox; + var checkCaps:CheckBox; + + override public function new(state:StageEditorState, searchFor:String = "") + { + super(); + + stageEditorState = state; + nameField.text = searchFor; + + this.field = nameField; + this.checkWord = wordCheck; + this.checkCaps = capsCheck; + + field.onChange = function(_) updateIndicator(); + indicator.hide(); + + top = 20; + left = FlxG.width - width - 20; + + buttons = DialogButton.CANCEL | "{{Find Next}}"; + defaultButton = "{{Find Next}}"; + } + + public function updateIndicator() + { + var prevObjCheck = assets[curSelected]; + + assets = []; + + for (ass in stageEditorState.spriteArray) + { + var name = ass.name; + var checkFor = field.text; + + if (!checkCaps.selected) + { + name = name.toLowerCase(); + checkFor = checkFor.toLowerCase(); + } + + if (((name.contains(checkFor) && !checkWord.selected) || (name == checkFor && checkWord.selected)) && ass.visible) assets.push(ass); + } + + if (assets.length > 0 && prevObjCheck == null) + { + stageEditorState.selectedSprite = assets[0]; + } + + if (assets.length > 0) + { + indicator.text = "Selected: " + (assets.indexOf(stageEditorState.selectedSprite) + 1) + " / " + assets.length; + } + else + { + indicator.text = "No Matches Found"; + } + + if (field.text != "" && field.text != null) indicator.show(); + else + indicator.hide(); + } + + public override function validateDialog(button:DialogButton, fn:Bool->Void) + { + var done = true; + + if (button == "{{Find Next}}") + { + done = false; + + if (assets.length > 0) + { + curSelected = assets.indexOf(stageEditorState.selectedSprite); + curSelected++; + + if (curSelected >= assets.length) curSelected = 0; + + stageEditorState.selectedSprite = assets[curSelected]; + indicator.text = "Selected: " + (assets.indexOf(stageEditorState.selectedSprite) + 1) + " / " + assets.length; + + stageEditorState.camFollow.x = assets[curSelected].getMidpoint().x; + stageEditorState.camFollow.y = assets[curSelected].getMidpoint().y; + } + } + fn(done); + } +} diff --git a/source/funkin/ui/debug/stageeditor/components/LoadFromUrlDialog.hx b/source/funkin/ui/debug/stageeditor/components/LoadFromUrlDialog.hx new file mode 100644 index 00000000..28fc0881 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/LoadFromUrlDialog.hx @@ -0,0 +1,69 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; +import lime.utils.Bytes; +import haxe.ui.components.TextField; +import openfl.net.URLLoader; +import openfl.net.URLRequest; +import openfl.events.Event; +import openfl.events.IOErrorEvent; +import openfl.events.SecurityErrorEvent; +import openfl.utils.ByteArray; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/load-url.xml")) +class LoadFromUrlDialog extends Dialog +{ + var urlField:TextField; + var loader:URLLoader; + + override public function new(successCallback:Bytes->Void = null, failCallback:String->Void = null) + { + super(); + destroyOnClose = true; + + loader = new URLLoader(); + loader.dataFormat = BINARY; + + urlField.text = ""; + + loader.addEventListener(Event.COMPLETE, function(event:Event) { + var bytes:Bytes = cast(loader.data, ByteArray); + + if (successCallback != null) successCallback(bytes); + + trace("loaded the image and did success callback"); + + @:privateAccess + loader.__removeAllListeners(); + + hideDialog(DialogButton.CANCEL); + }); + + loader.addEventListener(IOErrorEvent.IO_ERROR, function(event:IOErrorEvent) { + if (failCallback != null) failCallback(urlField.text); + + trace("error with this shit"); + }); + + loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, function(event:SecurityErrorEvent) { + if (failCallback != null) failCallback(urlField.text); + + trace("error with this shit"); + }); + + buttons = DialogButton.CANCEL | "{{Load}}"; + defaultButton = "{{Load}}"; + } + + override public function validateDialog(button:DialogButton, fn:Bool->Void) + { + if (button == DialogButton.CANCEL) + { + fn(true); + } + else + { + loader.load(new URLRequest(urlField.text)); + } + } +} diff --git a/source/funkin/ui/debug/stageeditor/components/NewObjDialog.hx b/source/funkin/ui/debug/stageeditor/components/NewObjDialog.hx new file mode 100644 index 00000000..45632a9a --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/NewObjDialog.hx @@ -0,0 +1,84 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; +import funkin.ui.debug.stageeditor.handlers.AssetDataHandler; +import openfl.display.BitmapData; +import haxe.ui.notifications.NotificationType; +import haxe.ui.notifications.NotificationManager; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/new-object.xml")) +class NewObjDialog extends Dialog +{ + var stageEditorState:StageEditorState; + var bitmap:BitmapData; + + override public function new(state:StageEditorState, img:BitmapData = null) + { + super(); + + stageEditorState = state; + bitmap = img; + + field.onChange = function(_) { + field.removeClasses(["invalid-value", "valid-value"]); + } + + buttons = DialogButton.CANCEL | "{{Create}}"; + defaultButton = "{{Create}}"; + + destroyOnClose = true; + } + + public override function validateDialog(button:DialogButton, fn:Bool->Void) + { + var done = true; + + if (button == "{{Create}}") + { + var objNames = [for (obj in StageEditorState.instance.spriteArray) obj.name]; + + if (field.text == "" || field.text == null || objNames.contains(field.text)) + { + field.swapClass("invalid-value", "valid-value"); + done = false; + NotificationManager.instance.addNotification( + { + title: "Problem Creating an Object", + body: objNames.contains(field.text) ? "Object with the Name " + field.text + " already exists!" : "Invalid Object Name!", + type: NotificationType.Error + }); + } + else + { + var spr = new StageEditorObject(); + + if (bitmap != null) + { + var bitToLoad = stageEditorState.addBitmap(bitmap); + spr.loadGraphic(stageEditorState.bitmaps[bitToLoad]); + } + else + spr.loadGraphic(AssetDataHandler.getDefaultGraphic()); + + spr.name = field.text; + spr.screenCenter(); + spr.zIndex = 0; + + stageEditorState.selectedSprite = spr; + stageEditorState.createAndPushAction(OBJECT_CREATED); + + stageEditorState.add(spr); + stageEditorState.updateArray(); + stageEditorState.saved = false; + + NotificationManager.instance.addNotification( + { + title: "Object Creating Successful", + body: "Successfully created an Object with the Name " + field.text + "!", + type: NotificationType.Success + }); + } + } + fn(done); + } +} diff --git a/source/funkin/ui/debug/stageeditor/components/UserGuideDialog.hx b/source/funkin/ui/debug/stageeditor/components/UserGuideDialog.hx new file mode 100644 index 00000000..19026718 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/UserGuideDialog.hx @@ -0,0 +1,6 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/user-guide.xml")) +class UserGuideDialog extends Dialog {} diff --git a/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx new file mode 100644 index 00000000..f1ef1439 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx @@ -0,0 +1,143 @@ +package funkin.ui.debug.stageeditor.components; + +import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.dialogs.Dialogs; +import haxe.ui.containers.dialogs.MessageBox.MessageBoxType; +import haxe.ui.components.Link; +import funkin.save.Save; +import funkin.util.FileUtil; +import flixel.FlxG; +import funkin.data.stage.StageData; +import funkin.data.stage.StageRegistry; +import funkin.ui.debug.stageeditor.StageEditorState.StageEditorDialogType; + +using funkin.util.tools.FloatTools; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/dialogs/welcome.xml")) +class WelcomeDialog extends Dialog +{ + var stageEditorState:StageEditorState; + + override public function new(state:StageEditorState) + { + super(); + + stageEditorState = state; + + buttonNew.onClick = function(_) { + stageEditorState.clearAssets(); + stageEditorState.loadDummyData(); + stageEditorState.currentFile = ""; + killDaDialog(); + } + + for (file in Save.instance.stageEditorPreviousFiles) + { + trace(file); + + if (!FileUtil.fileExists(file)) continue; // whats the point of loading something that doesnt exist + + var patj = new haxe.io.Path(file); + + var fileText = new Link(); + fileText.percentWidth = 100; + fileText.text = patj.file + "." + patj.ext; + fileText.onClick = function(_) loadFromFilePath(file); + + #if sys + var stat = sys.FileSystem.stat(file); + var sizeInMB = (stat.size / 1000000).round(2); + + fileText.tooltip = "Full Name: " + file + "\nLast Modified: " + stat.mtime.toString() + "\nSize: " + sizeInMB + "MB"; + #end + + contentRecent.addComponent(fileText); + } + + boxDrag.onClick = function(_) FileUtil.browseForSaveFile([FileUtil.FILE_FILTER_FNFS], loadFromFilePath, null, null, "Open Stage Data"); + + var defaultStages:Array = StageRegistry.instance.listBaseGameEntryIds(); + defaultStages.sort(funkin.util.SortUtil.alphabetically); + + for (stage in defaultStages) + { + var baseStage = StageRegistry.instance.parseEntryDataWithMigration(stage, StageRegistry.instance.fetchEntryVersion(stage)); + if (baseStage == null) continue; + + var link = new Link(); // this is how the legend of zelda started btw + link.percentWidth = 100; + link.text = baseStage.name; + + link.onClick = function(_) loadFromPreset(baseStage); + + contentPresets.addComponent(link); + } + + FlxG.stage.window.onDropFile.add(loadFromFilePath); + } + + public function loadFromPreset(data:StageData) + { + if (data == null) return; + + if (!stageEditorState.saved) + { + Dialogs.messageBox("This will destroy all of your Unsaved Work.\n\nAre you sure? This cannot be undone.", "Load Stage", MessageBoxType.TYPE_YESNO, true, + function(btn:DialogButton) { + if (btn == DialogButton.YES) + { + stageEditorState.saved = true; + loadFromPreset(data); + } + }); + + return; + } + + stageEditorState.clearAssets(); + stageEditorState.currentFile = ""; + stageEditorState.loadFromDataRaw(data); + killDaDialog(); + } + + public function loadFromFilePath(file:String) + { + if (!stageEditorState.saved) + { + Dialogs.messageBox("This will destroy all of your Unsaved Work.\n\nAre you sure? This cannot be undone.", "Load Stage", MessageBoxType.TYPE_YESNO, true, + function(btn:DialogButton) { + if (btn == DialogButton.YES) + { + stageEditorState.saved = true; + loadFromFilePath(file); + } + }); + + return; + } + + var bytes = FileUtil.readBytesFromPath(file); + + if (bytes == null) + { + stageEditorState.notifyChange("Problem Loading the Stage", "The Stage File could not be loaded.", true); + return; + } + + stageEditorState.clearAssets(); + stageEditorState.currentFile = file; + stageEditorState.unpackShitFromZip(bytes); + killDaDialog(); + } + + function killDaDialog() + { + stageEditorState.updateDialog(StageEditorDialogType.OBJECT); + stageEditorState.updateDialog(StageEditorDialogType.CHARACTER); + stageEditorState.updateDialog(StageEditorDialogType.STAGE); + + FlxG.stage.window.onDropFile.remove(loadFromFilePath); + hide(); + destroy(); + } +} diff --git a/source/funkin/ui/debug/stageeditor/handlers/AssetDataHandler.hx b/source/funkin/ui/debug/stageeditor/handlers/AssetDataHandler.hx new file mode 100644 index 00000000..a3b65922 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/handlers/AssetDataHandler.hx @@ -0,0 +1,204 @@ +package funkin.ui.debug.stageeditor.handlers; + +import openfl.display.BitmapData; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import openfl.display.BlendMode; +import funkin.data.stage.StageData.StageDataProp; + +using StringTools; + +/** + * Handles the Stage Props and Datas - being able to convert one to the other. + */ +class AssetDataHandler +{ + static var state:StageEditorState; + + public static function init(state:StageEditorState) + { + AssetDataHandler.state = state; + } + + /** + * Turns an Object into Data. + * @param obj the Object whose data to read. + * @param useBitmaps Whether to Save object's BitmapData directly. + * @return Data of the Object + */ + public static function toData(obj:StageEditorObject, useBitmaps:Bool = false):StageEditorObjectData + { + var outputData:StageEditorObjectData = + { + name: obj.name, + assetPath: "", + position: [obj.x, obj.y], + zIndex: obj.zIndex, + isPixel: !obj.antialiasing, + scale: obj.scale.x == obj.scale.y ? Left(obj.scale.x) : Right([obj.scale.x, obj.scale.y]), + alpha: obj.alpha, + danceEvery: obj.animation.getNameList().length > 0 ? obj.danceEvery : 0, + scroll: [obj.scrollFactor.x, obj.scrollFactor.y], + animations: [for (n => d in obj.animDatas) d], + startingAnimation: obj.startingAnimation, + animType: "sparrow", // automatically making sparrow atlases yeah + angle: obj.angle, + blend: obj.blend == null ? "" : Std.string(obj.blend), + color: obj.color.toWebString(), + xmlData: obj.generateXML() + } + + if (useBitmaps) + { + outputData.bitmap = obj.pixels.clone(); + return outputData; + } + + for (name => bit in state.bitmaps) + { + if (areTheseBitmapsEqual(bit, obj.pixels)) + { + outputData.assetPath = name; + return outputData; + } + } + + outputData.assetPath = "#FFFFFF"; + + return outputData; + } + + /** + * Modifies an Object based on the Data. + * @param object Object to modify. Set to null to create a new one. + * @param data The Data used for the Object. + */ + public static function fromData(object:StageEditorObject, data:StageEditorObjectData) + { + if (data.bitmap != null) + { + var bitToLoad = state.addBitmap(data.bitmap.clone()); + object.loadGraphic(state.bitmaps[bitToLoad]); + } + else + { + if (data.animations != null && data.animations.length > 0) // considering we're unpacking we might as well just do this instead of switch + { + object.frames = flixel.graphics.frames.FlxAtlasFrames.fromSparrow(state.bitmaps[data.assetPath].clone(), data.xmlData); + } + else if (data.assetPath.startsWith("#")) + { + object.loadGraphic(getDefaultGraphic()); + object.color = FlxColor.fromString(data.assetPath); + } + else + object.loadGraphic(state.bitmaps[data.assetPath].clone()); + } + + object.name = data.name; + object.setPosition(data.position[0], data.position[1]); + object.zIndex = data.zIndex; + object.antialiasing = !data.isPixel; + object.alpha = data.alpha; + object.danceEvery = data.danceEvery; + object.scrollFactor.set(data.scroll[0], data.scroll[1]); + object.startingAnimation = data.startingAnimation; + object.angle = data.angle; + object.blend = blendFromString(data.blend); + if (!data.assetPath.startsWith("#")) object.color = FlxColor.fromString(data.color); + + // yeah + object.pixelPerfectRender = data.isPixel; + object.pixelPerfectPosition = data.isPixel; + + for (anim in data.animations) + { + object.addAnim(anim.name, anim.prefix, anim.offsets ?? [0, 0], anim.frameIndices ?? [], anim.frameRate ?? 24, anim.looped ?? false, anim.flipX ?? false, + anim.flipY ?? false); + } + + if (object.animation.getNameList().contains(data.startingAnimation)) object.startingAnimation = data.startingAnimation; + + switch (data.scale) + { + case Left(value): + object.scale.set(value, value); + + case Right(values): + object.scale.set(values[0], values[1]); + } + object.updateHitbox(); + + object.playAnim(object.startingAnimation); + + flixel.util.FlxTimer.wait(StageEditorState.TIME_BEFORE_ANIM_STOP, function() { + if (object != null && object.animation.curAnim != null) object.animation.stop(); + }); + + return object; + } + + /** + * Returns a default BitmapData to be used for all the props. + * @return BitmapData + */ + public static function getDefaultGraphic():BitmapData + { + return new FlxSprite().makeGraphic(1, 1, FlxColor.WHITE).pixels.clone(); + } + + /** + * Returns OpenFL's BlendMode based on the Name. + * @param blend the BlendMode Name. + * @return BlendMode + */ + public static function blendFromString(blend:String):BlendMode + { + // originally this was a MASSIVE and I do mean MASSIVE switch case, though then I found out that blendmode already has one implemented + @:privateAccess + return BlendMode.fromString(blend.toLowerCase().trim()); + } + + public static function generateXML(obj:StageEditorObject) + { + // the last check is for if the only frame is the standard graphic frame + if (obj == null || obj.frames.frames.length == 0 || obj.frames.frames[0].name == null) return ""; + + var xml = [ + "", + '', + '' + ].join("\n"); + + for (daFrame in obj.frames.frames) + { + xml += ' \n'; + } + + xml += ""; + return xml; + } + + // I am aware OpenFL has it's own compare bitmap function, though I find this to be better ngl + static function areTheseBitmapsEqual(bitmap1:BitmapData, bitmap2:BitmapData) + { + if (bitmap1.width != bitmap2.width || bitmap1.height != bitmap2.height) return false; + + for (px in 0...bitmap1.width) + { + for (py in 0...bitmap1.height) + { + if (bitmap1.getPixel32(px, py) != bitmap2.getPixel32(px, py)) return false; + } + } + + return true; + } +} + +typedef StageEditorObjectData = +{ + > StageDataProp, + var xmlData:String; + var ?bitmap:BitmapData; +} diff --git a/source/funkin/ui/debug/stageeditor/handlers/StageDataHandler.hx b/source/funkin/ui/debug/stageeditor/handlers/StageDataHandler.hx new file mode 100644 index 00000000..e2265788 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/handlers/StageDataHandler.hx @@ -0,0 +1,363 @@ +package funkin.ui.debug.stageeditor.handlers; + +import haxe.io.Bytes; +import funkin.util.FileUtil; +import openfl.display.BitmapData; +import haxe.zip.Entry; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.BaseCharacter; +import funkin.data.stage.StageData; +import funkin.data.stage.StageData.StageDataCharacter; +import funkin.data.stage.StageRegistry; +import openfl.utils.Assets as OpenFLAssets; +import lime.utils.Assets as LimeAssets; + +using StringTools; + +class StageDataHandler +{ + public static function checkForCharacter(char:BaseCharacter) + return char != null; + + public static function packShitToZip(state:StageEditorState) + { + // step 1: data + var endData:StageData = new StageData(); + endData.name = state.stageName; + endData.cameraZoom = state.stageZoom; + endData.directory = state.stageFolder; + + // step 1 phase 1: object data + var xmlMap:Map = []; + + for (obj in state.spriteArray) + { + var data = obj.toData(false); + endData.props.push( + { + name: data.name, + assetPath: data.assetPath.startsWith("#") ? data.color : data.assetPath, + position: data.position.copy(), + zIndex: data.zIndex, + isPixel: data.isPixel, + scale: data.scale, + alpha: data.alpha, + danceEvery: data.danceEvery, + scroll: data.scroll.copy(), + animations: data.animations, + startingAnimation: data.startingAnimation, + animType: data.animType, + angle: data.angle, + blend: data.blend, + color: data.assetPath.startsWith("#") ? "#FFFFFF" : data.color + }); + + if (!xmlMap.exists(data.assetPath) && data.xmlData != "") xmlMap.set(data.assetPath, data.xmlData); + } + + // step 1 phase 2: character data + endData.characters.bf.zIndex = state.charGroups[CharacterType.BF].zIndex; + endData.characters.dad.zIndex = state.charGroups[CharacterType.DAD].zIndex; + endData.characters.gf.zIndex = state.charGroups[CharacterType.GF].zIndex; + + endData.characters.bf.scale = state.bf.scale.x / state.bf.getBaseScale(); + endData.characters.dad.scale = state.dad.scale.x / state.dad.getBaseScale(); + endData.characters.gf.scale = state.gf.scale.x / state.gf.getBaseScale(); + + endData.characters.bf.cameraOffsets = state.charCamOffsets[CharacterType.BF].copy(); + endData.characters.gf.cameraOffsets = state.charCamOffsets[CharacterType.GF].copy(); + endData.characters.dad.cameraOffsets = state.charCamOffsets[CharacterType.DAD].copy(); + + endData.characters.bf.position = [ + state.bf.feetPosition.x - state.bf.globalOffsets[0], + state.bf.feetPosition.y - state.bf.globalOffsets[1] + ]; + + endData.characters.gf.position = [ + state.gf.feetPosition.x - state.gf.globalOffsets[0], + state.gf.feetPosition.y - state.gf.globalOffsets[1] + ]; + + endData.characters.dad.position = [ + state.dad.feetPosition.x - state.dad.globalOffsets[0], + state.dad.feetPosition.y - state.dad.globalOffsets[1] + ]; + + // step 2: saving everything to entryList + var entryList = new Array(); + + // step 2 phase 1: images + state.removeUnusedBitmaps(); + for (name => img in state.bitmaps) + { + var bytes = img?.image?.encode(PNG); + if (bytes == null) continue; + + var entry:Entry = + { + fileName: name + ".png", + fileSize: bytes.length, + fileTime: Date.now(), + compressed: false, + dataSize: bytes.length, + data: bytes, + crc32: null // apparently fileutil.hx does not like crc32, idk why but i dont even know what crc32 is + } + + entryList.push(entry); + } + + // step 2 phase 2: xmls + for (obj in endData.props) + { + if (!xmlMap.exists(obj.assetPath)) continue; // damn + + var bytes = Bytes.ofString(xmlMap[obj.assetPath]); + + var entry:Entry = + { + fileName: obj.assetPath + ".xml", + fileSize: bytes.length, + fileTime: Date.now(), + compressed: false, + dataSize: bytes.length, + data: bytes, + crc32: null + } + + entryList.push(entry); + } + + // step 2 phase 3: the main data + var stageBytes = Bytes.ofString(endData.serialize()); + entryList.push( + { + fileName: "yourstagename.json", + fileSize: stageBytes.length, + fileTime: Date.now(), + compressed: false, + dataSize: stageBytes.length, + data: stageBytes, + crc32: null + }); + + var zipFileBytes = FileUtil.createZIPFromEntries(entryList); + return zipFileBytes; + } + + public static function unpackShitFromZip(state:StageEditorState, zip:Bytes) + { + state.clearAssets(); + state.bitmaps.clear(); + + var entries = FileUtil.readZIPFromBytes(zip); + var stageData:StageData = new StageData(); + + var xmls:Map = []; + + for (stuff in entries) + { + var ext = stuff.fileName.split(".")[1]; + + switch (ext) + { + case "png": + var data = BitmapData.fromBytes(stuff.data); + state.bitmaps.set(stuff.fileName.replace(".png", ""), data); + + case "xml": + xmls.set(stuff.fileName.replace(".xml", ""), stuff.data.toString()); + + case "json": + stageData = StageRegistry.instance.parseEntryDataRaw(stuff.data.toString(), stuff.fileName); + } + } + + if (stageData == null) + { + // TODO: throw an error, then load a dummy data + loadDummyData(state); + return; + } + + // actual data unpacking + state.stageName = stageData.name; + state.stageZoom = stageData.cameraZoom; + state.stageFolder = stageData.directory ?? "shared"; + + // chars + state.loadCharDatas(stageData); + + // objects + for (objData in stageData.props) + { + // make the data and roll with it + var spr = new StageEditorObject(); + spr.fromData( + { + name: objData.name ?? "Unnamed", + assetPath: objData.assetPath, + animations: objData.animations.copy(), + scale: objData.scale, + position: objData.position, + alpha: objData.alpha, + angle: objData.angle, + zIndex: objData.zIndex, + danceEvery: objData.danceEvery, + isPixel: objData.isPixel, + scroll: objData.scroll.copy(), + color: objData.color, + blend: objData.blend, + startingAnimation: objData.startingAnimation, + xmlData: xmls[objData.assetPath] ?? "" + }); + + state.add(spr); + } + + state.updateArray(); + state.sortAssets(); + state.updateMarkerPos(); + } + + static function loadCharDatas(state:StageEditorState, data:StageData) + { + var chars = state.getCharacters(); + for (char in chars) + { + var charData:StageDataCharacter = null; + + switch (char.characterType) + { + case CharacterType.BF: + charData = data.characters.bf; + case CharacterType.GF: + charData = data.characters.gf; + case CharacterType.DAD: + charData = data.characters.dad; + default: // nothing rip + } + + char.resetCharacter(true); + + if (charData == null) continue; + + char.x = charData.position[0] - char.characterOrigin.x + char.globalOffsets[0]; + char.y = charData.position[1] - char.characterOrigin.y + char.globalOffsets[1]; + state.charGroups[char.characterType].zIndex = charData.zIndex; + + char.setScale(char.getBaseScale() * charData.scale); + char.cameraFocusPoint.x += charData.cameraOffsets[0]; + char.cameraFocusPoint.y += charData.cameraOffsets[1]; + + state.charCamOffsets[char.characterType] = charData.cameraOffsets.copy(); + } + } + + public static function loadFromDataRaw(state:StageEditorState, data:StageData) + { + state.clearAssets(); + state.bitmaps.clear(); + + if (data == null) + { + loadDummyData(state); + return; + } + @:privateAccess + if (!LimeAssets.libraryPaths.exists(data.directory)) + { + loadDummyData(state); + return; + } + + Paths.setCurrentLevel(data.directory); + + if (OpenFLAssets.getLibrary(data.directory) == null) + { + OpenFLAssets.loadLibrary(data.directory).onComplete(function(_) { + loadFromDataRaw(state, data); + }); + return; + } + + state.stageName = data.name; + state.stageZoom = data.cameraZoom; + state.stageFolder = data.directory ?? "shared"; + + state.loadCharDatas(data); + + for (objData in data.props) + { + var spr = new StageEditorObject(); + if (!objData.assetPath.startsWith("#")) state.bitmaps.set(objData.assetPath, Assets.getBitmapData(Paths.image(objData.assetPath))); + + spr.fromData( + { + name: objData.name ?? "Unnamed", + assetPath: objData.assetPath, + animations: objData.animations.copy(), + scale: objData.scale, + position: objData.position, + alpha: objData.alpha, + angle: objData.angle, + zIndex: objData.zIndex, + danceEvery: objData.danceEvery, + isPixel: objData.isPixel, + scroll: objData.scroll.copy(), + color: objData.color, + blend: objData.blend, + startingAnimation: objData.startingAnimation, + xmlData: Assets.exists(Paths.file("images/" + objData.assetPath + ".xml")) ? Assets.getText(Paths.file("images/" + objData.assetPath + ".xml")) : "" + }); + + state.add(spr); + } + + state.updateArray(); + state.sortAssets(); + state.updateMarkerPos(); + } + + public static function loadDummyData(state:StageEditorState) + { + state.clearAssets(); + + state.stageName = "Unnamed"; + state.stageZoom = 1.0; + state.stageFolder = "shared"; + + state.charCamOffsets = StageEditorState.DEFAULT_CAMERA_OFFSETS.copy(); + state.charPos = StageEditorState.DEFAULT_POSITIONS.copy(); + + state.gf.resetCharacter(true); + state.dad.resetCharacter(true); + state.bf.resetCharacter(true); + + state.charGroups[CharacterType.BF].zIndex = 300; + state.charGroups[CharacterType.DAD].zIndex = 200; + state.charGroups[CharacterType.GF].zIndex = 100; + + state.gf.x = state.charPos[CharacterType.GF][0] - state.gf.characterOrigin.x + state.gf.globalOffsets[0]; + state.gf.y = state.charPos[CharacterType.GF][1] - state.gf.characterOrigin.y + state.gf.globalOffsets[1]; + state.dad.x = state.charPos[CharacterType.DAD][0] - state.dad.characterOrigin.x + state.dad.globalOffsets[0]; + state.dad.y = state.charPos[CharacterType.DAD][1] - state.dad.characterOrigin.y + state.dad.globalOffsets[1]; + state.bf.x = state.charPos[CharacterType.BF][0] - state.bf.characterOrigin.x + state.bf.globalOffsets[0]; + state.bf.y = state.charPos[CharacterType.BF][1] - state.bf.characterOrigin.y + state.bf.globalOffsets[1]; + + state.gf.setScale(state.gf.getBaseScale()); + state.dad.setScale(state.dad.getBaseScale()); + state.bf.setScale(state.bf.getBaseScale()); + + state.gf.cameraFocusPoint.x += state.charCamOffsets[CharacterType.GF][0]; + state.gf.cameraFocusPoint.y += state.charCamOffsets[CharacterType.GF][1]; + state.dad.cameraFocusPoint.x += state.charCamOffsets[CharacterType.DAD][0]; + state.dad.cameraFocusPoint.y += state.charCamOffsets[CharacterType.DAD][1]; + state.bf.cameraFocusPoint.x += state.charCamOffsets[CharacterType.BF][0]; + state.bf.cameraFocusPoint.y += state.charCamOffsets[CharacterType.BF][1]; + + // no props :p + + state.updateMarkerPos(); + } +} diff --git a/source/funkin/ui/debug/stageeditor/handlers/UndoRedoHandler.hx b/source/funkin/ui/debug/stageeditor/handlers/UndoRedoHandler.hx new file mode 100644 index 00000000..2fc38aab --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/handlers/UndoRedoHandler.hx @@ -0,0 +1,191 @@ +package funkin.ui.debug.stageeditor.handlers; + +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.ui.debug.stageeditor.handlers.AssetDataHandler.StageEditorObjectData; +import funkin.ui.debug.stageeditor.StageEditorState.StageEditorDialogType; + +class UndoRedoHandler +{ + public static function performLastAction(state:StageEditorState, redo:Bool = false):Void + { + if (state == null || (state.undoArray.length <= 0 && !redo) || (state.redoArray.length <= 0 && redo)) return; + var actionToDo = redo ? state.redoArray.pop() : state.undoArray.pop(); + + switch (actionToDo.type) + { + case CHARACTER_MOVED: + createAndPushAction(state, actionToDo.type, !redo); + + var type = actionToDo.data.type == null ? CharacterType.BF : actionToDo.data.type; + var pos = actionToDo.data.pos == null ? [0, 0] : actionToDo.data.pos; + + for (char in state.getCharacters()) + { + if (char.characterType == type) state.selectedChar = char; + } + + state.selectedChar.x = pos[0] - state.selectedChar.characterOrigin.x + state.selectedChar.globalOffsets[0]; + state.selectedChar.y = pos[1] - state.selectedChar.characterOrigin.y + state.selectedChar.globalOffsets[1]; + + state.updateMarkerPos(); + state.updateDialog(StageEditorDialogType.CHARACTER); + + case OBJECT_MOVED: + var id = actionToDo.data.ID ?? -1; + var pos = actionToDo.data.pos ?? [0, 0]; + + for (obj in state.spriteArray) + { + if (obj.ID == id) state.selectedSprite = obj; + } + + if (state.selectedSprite != null) + { + createAndPushAction(state, actionToDo.type, !redo); + + state.selectedSprite.x = pos[0]; + state.selectedSprite.y = pos[1]; + + state.updateDialog(StageEditorDialogType.OBJECT); + } + + case OBJECT_CREATED: // this removes the object + var id = actionToDo.data.ID ?? -1; + + for (obj in state.spriteArray) + { + if (obj.ID == id) + { + state.selectedSprite = obj; + createAndPushAction(state, OBJECT_DELETED, !redo); + + state.selectedSprite = null; + + obj.kill(); + state.remove(obj, true); + obj.destroy(); + + state.updateArray(); + state.updateDialog(StageEditorDialogType.OBJECT); + trace("found object"); + + continue; + } + } + + case OBJECT_DELETED: // this creates the object + if (actionToDo.data.data == null) return; + + var id = actionToDo.data.ID ?? -1; + var data:StageEditorObjectData = cast actionToDo.data.data; + + var obj = new StageEditorObject().fromData(data); + obj.ID = id; + state.selectedSprite = obj; + + createAndPushAction(state, OBJECT_CREATED, !redo); + state.add(obj); + + state.updateDialog(StageEditorDialogType.OBJECT); + state.updateArray(); + + case OBJECT_ROTATED: // primarily copied from OBJECT_MOVED + var id = actionToDo.data.ID ?? -1; + var angle = actionToDo.data.angle ?? 0; + + for (obj in state.spriteArray) + { + if (obj.ID == id) state.selectedSprite = obj; + } + + if (state.selectedSprite != null) + { + createAndPushAction(state, actionToDo.type, !redo); + state.selectedSprite.angle = angle; + state.updateDialog(StageEditorDialogType.OBJECT); + } + + default: // do nothing dumbass + } + } + + public static function createAndPushAction(state:StageEditorState, action:UndoActionType, redo:Bool = false) + { + if (state == null) return; + + var finalAction:UndoAction = {type: action, data: null}; + + if (!redo && state.redoArray.length > 0) state.redoArray = []; // incorporate resetting as well + + switch (action) + { + case CHARACTER_MOVED: + var char = state.selectedChar.characterType; + finalAction.data = {type: char, pos: state.charPos[char].copy()}; + + case OBJECT_MOVED: + finalAction.data = {ID: state.selectedSprite.ID, pos: [state.selectedSprite.x, state.selectedSprite.y]} + + case OBJECT_CREATED: + finalAction.data = {ID: state.selectedSprite.ID} + + case OBJECT_DELETED: + finalAction.data = + { + ID: state.selectedSprite.ID, + data: state.selectedSprite.toData(true) + } + + case OBJECT_ROTATED: + finalAction.data = {ID: state.selectedSprite.ID, angle: state.selectedSprite.angle} + + default: // nop + } + + if (finalAction.data == null) return; + + if (redo) state.redoArray.push(finalAction); + else if (!redo) state.undoArray.push(finalAction); + } +} + +typedef UndoAction = +{ + /** + * The Type of Undo Action to store. + */ + var type:UndoActionType; + + /** + * The added Data of the Action. + */ + var data:Dynamic; +} + +enum abstract UndoActionType(String) from String +{ + /** + * Triggerred when an Object is deleted. + */ + var OBJECT_DELETED = "object_deleted"; + + /** + * Triggerred when an Object is created. + */ + var OBJECT_CREATED = "object_created"; + + /** + * Triggerred when an Object is moved. + */ + var OBJECT_MOVED = "object_moved"; + + /** + * Triggerred when a Character is moved. + */ + var CHARACTER_MOVED = "character_moved"; + + /** + * Triggerred when an Object is rotated. + */ + var OBJECT_ROTATED = "object_rotated"; +} diff --git a/source/funkin/ui/debug/stageeditor/import.hx b/source/funkin/ui/debug/stageeditor/import.hx new file mode 100644 index 00000000..194f501b --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/import.hx @@ -0,0 +1,7 @@ +package funkin.ui.debug.stageeditor; + +#if !macro +using funkin.ui.debug.stageeditor.handlers.StageDataHandler; +using funkin.ui.debug.stageeditor.handlers.AssetDataHandler; +using funkin.ui.debug.stageeditor.handlers.UndoRedoHandler; +#end diff --git a/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorCharacterToolbox.hx b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorCharacterToolbox.hx new file mode 100644 index 00000000..0194f3dc --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorCharacterToolbox.hx @@ -0,0 +1,236 @@ +package funkin.ui.debug.stageeditor.toolboxes; + +import haxe.ui.components.NumberStepper; +import funkin.play.character.BaseCharacter.CharacterType; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.util.SortUtil; +import haxe.ui.components.Button; +import haxe.ui.components.Slider; +import haxe.ui.containers.menus.Menu; +import haxe.ui.core.Screen; +import flixel.tweens.FlxTween; +import flixel.tweens.FlxEase; +import haxe.ui.containers.Grid; +import funkin.play.character.CharacterData; + +using StringTools; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/toolboxes/character-properties.xml")) +class StageEditorCharacterToolbox extends StageEditorDefaultToolbox +{ + var characterPosXStepper:NumberStepper; + var characterPosYStepper:NumberStepper; + var characterPosReset:Button; + + var characterZIdxStepper:NumberStepper; + var characterZIdxReset:Button; + + var characterCamXStepper:NumberStepper; + var characterCamYStepper:NumberStepper; + var characterCamReset:Button; + + var characterScaleSlider:Slider; + var characterScaleReset:Button; + + var characterTypeButton:Button; + var charMenu:StageEditorCharacterMenu; + + override public function new(state:StageEditorState) + { + super(state); + + // position + characterPosXStepper.onChange = characterPosYStepper.onChange = function(_) { + repositionCharacter(); + state.saved = false; + } + + characterPosReset.onClick = function(_) { + if (!StageEditorState.DEFAULT_POSITIONS.exists(state.selectedChar.characterType)) return; + + var oldPositions = StageEditorState.DEFAULT_POSITIONS[state.selectedChar.characterType]; + characterPosXStepper.pos = oldPositions[0]; + characterPosYStepper.pos = oldPositions[1]; + } + + // zidx + characterZIdxStepper.max = StageEditorState.MAX_Z_INDEX; + characterZIdxStepper.onChange = function(_) { + state.charGroups[state.selectedChar.characterType].zIndex = Std.int(characterZIdxStepper.pos); + state.saved = false; + state.sortAssets(); + } + + characterZIdxReset.onClick = function(_) { + var thingies = [CharacterType.GF, CharacterType.DAD, CharacterType.BF]; + var thingIdxies = thingies.indexOf(state.selectedChar.characterType); + + characterZIdxStepper.pos = (thingIdxies * 100); + } + + // camera + characterCamXStepper.onChange = characterCamYStepper.onChange = function(_) { + state.charCamOffsets[state.selectedChar.characterType] = [characterCamXStepper.pos, characterCamYStepper.pos]; + state.updateMarkerPos(); + state.saved = false; + } + + characterCamReset.onClick = function(_) characterCamXStepper.pos = characterCamYStepper.pos = 0; // lol + + // scale + characterScaleSlider.onChange = function(_) { + state.selectedChar.setScale(state.selectedChar.getBaseScale() * characterScaleSlider.pos); + repositionCharacter(); + state.saved = false; + } + + characterScaleReset.onChange = function(_) characterScaleSlider.pos = 1; + + // character button + characterTypeButton.onClick = function(_) { + charMenu = new StageEditorCharacterMenu(state, this); + Screen.instance.addComponent(charMenu); + } + + refresh(); + } + + override public function refresh() + { + var name = stageEditorState.selectedChar.characterType; + + characterPosXStepper.step = characterPosYStepper.step = stageEditorState.moveStep; + characterCamXStepper.step = characterCamYStepper.step = stageEditorState.moveStep; + + if (characterPosXStepper.pos != stageEditorState.charPos[name][0]) characterPosXStepper.pos = stageEditorState.charPos[name][0]; + if (characterPosYStepper.pos != stageEditorState.charPos[name][1]) characterPosYStepper.pos = stageEditorState.charPos[name][1]; + + if (characterZIdxStepper.pos != stageEditorState.charGroups[stageEditorState.selectedChar.characterType].zIndex) + characterZIdxStepper.pos = stageEditorState.charGroups[stageEditorState.selectedChar.characterType].zIndex; + + if (characterCamXStepper.pos != stageEditorState.charCamOffsets[name][0]) characterCamXStepper.pos = stageEditorState.charCamOffsets[name][0]; + if (characterCamYStepper.pos != stageEditorState.charCamOffsets[name][1]) characterCamYStepper.pos = stageEditorState.charCamOffsets[name][1]; + + if (characterScaleSlider.pos != stageEditorState.selectedChar.scale.x / stageEditorState.selectedChar.getBaseScale()) + characterScaleSlider.pos = stageEditorState.selectedChar.scale.x / stageEditorState.selectedChar.getBaseScale(); + + var prevText = characterTypeButton.text; + + var charData = CharacterDataParser.fetchCharacterData(stageEditorState.selectedChar.characterId); + characterTypeButton.icon = (charData == null ? null : CharacterDataParser.getCharPixelIconAsset(stageEditorState.selectedChar.characterId)); + characterTypeButton.text = (charData == null ? "None" : charData.name.length > 6 ? '${charData.name.substr(0, 6)}.' : '${charData.name}'); + + if (prevText != characterTypeButton.text) + { + Screen.instance.removeComponent(charMenu); + } + } + + public function repositionCharacter() + { + stageEditorState.selectedChar.x = characterPosXStepper.pos - stageEditorState.selectedChar.characterOrigin.x + + stageEditorState.selectedChar.globalOffsets[0]; + stageEditorState.selectedChar.y = characterPosYStepper.pos - stageEditorState.selectedChar.characterOrigin.y + + stageEditorState.selectedChar.globalOffsets[1]; + + stageEditorState.selectedChar.setScale(stageEditorState.selectedChar.getBaseScale() * characterScaleSlider.pos); + + stageEditorState.updateMarkerPos(); + } +} + +@:xml(' + + + + + +') +class StageEditorCharacterMenu extends Menu // copied from chart editor +{ + override public function new(state:StageEditorState, parent:StageEditorCharacterToolbox) + { + super(); + + this.x = Screen.instance.currentMouseX; + this.y = Screen.instance.currentMouseY; + + var charGrid = new Grid(); + charGrid.columns = 5; + charGrid.width = this.width; + charSelectScroll.addComponent(charGrid); + + var charIds = CharacterDataParser.listCharacterIds(); + charIds.sort(SortUtil.alphabetically); + + var defaultText:String = '(choose a character)'; + + for (charIndex => charId in charIds) + { + var charData:CharacterData = CharacterDataParser.fetchCharacterData(charId); + + var charButton = new haxe.ui.components.Button(); + charButton.width = 70; + charButton.height = 70; + charButton.padding = 8; + charButton.iconPosition = "top"; + + if (charId == state.selectedChar.characterId) + { + // Scroll to the character if it is already selected. + charSelectScroll.hscrollPos = Math.floor(charIndex / 5) * 80; + charButton.selected = true; + + defaultText = '${charData.name} [${charId}]'; + } + + var LIMIT = 6; + charButton.icon = CharacterDataParser.getCharPixelIconAsset(charId); + charButton.text = charData.name.length > LIMIT ? '${charData.name.substr(0, LIMIT)}.' : '${charData.name}'; + + charButton.onClick = _ -> { + var type = state.selectedChar.characterType; + if (state.selectedChar.characterId == charId) return; // saves on memory + + var group = state.charGroups[type]; + group.killMembers(); + for (member in group.members) + { + member.kill(); + group.remove(member, true); + member.destroy(); + } + group.clear(); + + // okay i think that was enough cleaning phew you can see how clean this group is now!!! + // anyways new character!!!! + + var newChar = CharacterDataParser.fetchCharacter(charId, true); + newChar.characterType = type; + + newChar.resetCharacter(true); + newChar.flipX = type == CharacterType.BF ? !newChar.getDataFlipX() : newChar.getDataFlipX(); + + state.selectedChar = newChar; + group.add(newChar); + + parent.repositionCharacter(); + }; + + charButton.onMouseOver = _ -> { + charIconName.text = '${charData.name} [${charId}]'; + }; + charButton.onMouseOut = _ -> { + charIconName.text = defaultText; + }; + charGrid.addComponent(charButton); + } + + charIconName.text = defaultText; + + this.alpha = 0; + this.y -= 10; + FlxTween.tween(this, {alpha: 1, y: this.y + 10}, 0.2, {ease: FlxEase.quartOut}); + } +} diff --git a/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorDefaultToolbox.hx b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorDefaultToolbox.hx new file mode 100644 index 00000000..2e5b3048 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorDefaultToolbox.hx @@ -0,0 +1,44 @@ +package funkin.ui.debug.stageeditor.toolboxes; + +import haxe.ui.containers.dialogs.CollapsibleDialog; +import funkin.audio.FunkinSound; + +@:access(funkin.ui.debug.stageeditor.StageEditorState) +class StageEditorDefaultToolbox extends CollapsibleDialog +{ + var stageEditorState:StageEditorState; + + public var dialogVisible:Bool = false; + + private function new(stageEditorState:StageEditorState) + { + super(); + + this.stageEditorState = stageEditorState; + + closable = false; + modal = true; + destroyOnClose = false; + } + + /** + * Handles the Sound and Visibility + * @param on + */ + public function toggle(on:Bool) + { + if (!dialogVisible && on) FunkinSound.playOnce(Paths.sound('chartingSounds/openWindow')); + else if (dialogVisible && !on) FunkinSound.playOnce(Paths.sound('chartingSounds/exitWindow')); + + if (on) showDialog(false); + else + hide(); + + dialogVisible = on; + } + + /** + * Override to implement this. + */ + public function refresh() {} +} diff --git a/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorObjectToolbox.hx b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorObjectToolbox.hx new file mode 100644 index 00000000..6987138b --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorObjectToolbox.hx @@ -0,0 +1,577 @@ +package funkin.ui.debug.stageeditor.toolboxes; + +import haxe.ui.components.HorizontalSlider; +import haxe.ui.components.NumberStepper; +import haxe.ui.components.TextField; +import haxe.ui.components.TextArea; +import haxe.ui.components.Button; +import haxe.ui.components.Image; +import haxe.ui.containers.dialogs.Dialogs.FileDialogTypes; +import haxe.ui.ToolkitAssets; +import haxe.ui.containers.dialogs.Dialogs; +import funkin.ui.debug.stageeditor.handlers.AssetDataHandler; +import flixel.graphics.frames.FlxAtlasFrames; +import haxe.ui.components.DropDown; +import haxe.ui.containers.ListView; +import haxe.ui.components.CheckBox; +import flixel.util.FlxTimer; +import haxe.ui.data.ArrayDataSource; +import haxe.ui.components.ColorPicker; +import flixel.util.FlxColor; +import haxe.ui.util.Color; +import flixel.graphics.frames.FlxFrame; +import funkin.util.FileUtil; +import openfl.display.BitmapData; + +using StringTools; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/toolboxes/object-properties.xml")) +class StageEditorObjectToolbox extends StageEditorDefaultToolbox +{ + var linkedObject:StageEditorObject = null; + + var objectImagePreview:Image; + var objectLoadImageButton:Button; + var objectLoadInternetButton:Button; + var objectDownloadImageButton:Button; + var objectResetImageButton:Button; + var objectZIdxStepper:NumberStepper; + var objectZIdxReset:Button; + + var objectPosXStepper:NumberStepper; + var objectPosYStepper:NumberStepper; + var objectPosResetButton:Button; + var objectAlphaSlider:HorizontalSlider; + var objectAlphaResetButton:Button; + var objectAngleSlider:HorizontalSlider; + var objectAngleResetButton:Button; + var objectScaleXStepper:NumberStepper; + var objectScaleYStepper:NumberStepper; + var objectScaleResetButton:Button; + var objectSizeXStepper:NumberStepper; + var objectSizeYStepper:NumberStepper; + var objectSizeResetButton:Button; + var objectScrollXSlider:HorizontalSlider; + var objectScrollYSlider:HorizontalSlider; + var objectScrollResetButton:Button; + + var objectFrameText:TextArea; + var objectFrameTextLoad:Button; + var objectFrameTextSparrow:Button; + var objectFrameTextPacker:Button; + var objectFrameImageWidth:NumberStepper; + var objectFrameImageHeight:NumberStepper; + var objectFrameImageSetter:Button; + var objectFrameReset:Button; + + var objectAnimDropdown:DropDown; + var objectAnimName:TextField; + var objectAnimFrameList:ListView; + var objectAnimPrefix:TextField; + var objectAnimFrames:TextField; + var objectAnimLooped:CheckBox; + var objectAnimFlipX:CheckBox; + var objectAnimFlipY:CheckBox; + var objectAnimFramerate:NumberStepper; + var objectAnimOffsetX:NumberStepper; + var objectAnimOffsetY:NumberStepper; + var objectAnimDanceBeat:NumberStepper; + var objectAnimDanceBeatReset:Button; + var objectAnimStart:TextField; + var objectAnimStartReset:Button; + + var objectMiscAntialias:CheckBox; + var objectMiscAntialiasReset:Button; + var objectMiscFlipReset:Button; + var objectMiscBlendDrop:DropDown; + var objectMiscBlendReset:Button; + var objectMiscColor:ColorPicker; + var objectMiscColorReset:Button; + + override public function new(state:StageEditorState) + { + super(state); + + // basic callbacks + objectLoadImageButton.onClick = function(_) { + if (linkedObject == null) return; + + Dialogs.openBinaryFile("Open Image File", FileDialogTypes.IMAGES, function(selectedFile) { + if (selectedFile == null) return; + + objectImagePreview.resource = null; + + ToolkitAssets.instance.imageFromBytes(selectedFile.bytes, function(imageInfo) { + if (imageInfo == null) return; + + objectImagePreview.resource = imageInfo.data; + + linkedObject.frame = imageInfo.data; + + var bit = linkedObject.updateFramePixels(); + var bitToLoad = state.addBitmap(bit); + + linkedObject.loadGraphic(state.bitmaps[bitToLoad]); + linkedObject.updateHitbox(); + + // update size stuff + objectSizeXStepper.pos = linkedObject.width; + objectSizeYStepper.pos = linkedObject.height; + + // remove unused bitmaps + state.removeUnusedBitmaps(); + }); + }); + } + + objectLoadInternetButton.onClick = function(_) { + if (linkedObject == null) return; + + state.createURLDialog(function(bytes:lime.utils.Bytes) { + linkedObject.loadGraphic(BitmapData.fromBytes(bytes)); + linkedObject.updateHitbox(); + refresh(); + }); + } + + objectDownloadImageButton.onClick = function(_) { + if (linkedObject == null) return; + + FileUtil.saveFile(linkedObject.pixels.image.encode(PNG), [FileUtil.FILE_FILTER_PNG], null, null, + linkedObject.name + "-graphic.png"); // i'on need any callbacks + } + + objectZIdxStepper.max = StageEditorState.MAX_Z_INDEX; + objectZIdxStepper.onChange = function(_) { + if (linkedObject != null) + { + linkedObject.zIndex = Std.int(objectZIdxStepper.pos); + state.sortAssets(); + } + } + + // numeric callbacks + objectPosXStepper.onChange = function(_) { + if (linkedObject != null) linkedObject.x = objectPosXStepper.pos; + }; + + objectPosYStepper.onChange = function(_) { + if (linkedObject != null) linkedObject.y = objectPosYStepper.pos; + }; + + objectAlphaSlider.onChange = function(_) { + if (linkedObject != null) linkedObject.alpha = objectAlphaSlider.pos; + }; + + objectAngleSlider.onChange = function(_) { + if (linkedObject != null) linkedObject.angle = objectAngleSlider.pos; + }; + + objectScaleXStepper.onChange = objectScaleYStepper.onChange = function(_) { + if (linkedObject != null) + { + linkedObject.scale.set(objectScaleXStepper.pos, objectScaleYStepper.pos); + linkedObject.updateHitbox(); + objectSizeXStepper.pos = linkedObject.width; + objectSizeYStepper.pos = linkedObject.height; + + linkedObject.playAnim(linkedObject.animation.name); // load offsets + } + }; + + objectSizeXStepper.onChange = objectSizeYStepper.onChange = function(_) { + if (linkedObject != null) + { + linkedObject.setGraphicSize(Std.int(objectSizeXStepper.pos), Std.int(objectSizeYStepper.pos)); + linkedObject.updateHitbox(); + objectScaleXStepper.pos = linkedObject.scale.x; + objectScaleYStepper.pos = linkedObject.scale.y; + + linkedObject.playAnim(linkedObject.animation.name); // load offsets + } + }; + + objectScrollXSlider.onChange = objectScrollYSlider.onChange = function(_) { + if (linkedObject != null) linkedObject.scrollFactor.set(objectScrollXSlider.pos, objectScrollYSlider.pos); + }; + + // frame callbacks + objectFrameTextLoad.onClick = function(_) { + Dialogs.openTextFile("Open Text File", FileDialogTypes.TEXTS, function(selectedFile) { + if (selectedFile.text == null || (!selectedFile.name.endsWith(".xml") && !selectedFile.name.endsWith(".txt"))) return; + + objectFrameText.text = selectedFile.text; + + state.notifyChange("Frame Text Loaded", "The Text File " + selectedFile.name + " has been loaded."); + }); + } + + objectFrameTextSparrow.onClick = function(_) { + if (linkedObject == null || objectFrameText.text == null || objectFrameText.text == "") return; + + try + { + linkedObject.frames = FlxAtlasFrames.fromSparrow(linkedObject.graphic, objectFrameText.text); + } + catch (e) + { + state.notifyChange("Frame Setup Error", e.toString(), true); + return; + } + + // might as well clear animations because frames SUCK + linkedObject.animDatas.clear(); + linkedObject.animation.destroyAnimations(); + linkedObject.updateHitbox(); + refresh(); + + state.notifyChange("Frame Setup Done", "Finished the Sparrow Frame Setup for the Object " + linkedObject.name + "."); + } + + objectFrameTextPacker.onClick = function(_) { + if (linkedObject == null || objectFrameText.text == null || objectFrameText.text == "") return; + + try // crash prevention + { + linkedObject.frames = FlxAtlasFrames.fromSpriteSheetPacker(linkedObject.graphic, objectFrameText.text); + } + catch (e) + { + state.notifyChange("Frame Setup Error", e.toString(), true); + return; + } + + // might as well clear animations because frames SUCK + linkedObject.animDatas.clear(); + linkedObject.animation.destroyAnimations(); + + linkedObject.updateHitbox(); + refresh(); + + state.notifyChange("Frame Setup Done", "Finished the Packer Frame Setup for the Object " + linkedObject.name + "."); + } + + objectFrameImageSetter.onClick = function(_) { + if (linkedObject == null) return; + + linkedObject.loadGraphic(linkedObject.graphic, true, Std.int(objectFrameImageWidth.pos), Std.int(objectFrameImageHeight.pos)); + linkedObject.updateHitbox(); + + // set da names + for (i in 0...linkedObject.frames.frames.length) + { + linkedObject.frames.framesHash.set("Frame" + i, linkedObject.frames.frames[i]); + linkedObject.frames.frames[i].name = "Frame" + i; + } + + // might as well clear animations because frames SUCK + linkedObject.animDatas.clear(); + linkedObject.animation.destroyAnimations(); + refresh(); + + state.notifyChange("Frame Setup Done", "Finished the Image Frame Setup for the Object " + linkedObject.name + "."); + } + + // animation + objectAnimDropdown.onChange = function(_) { + if (linkedObject == null) return; + + if (objectAnimDropdown.selectedIndex == -1) // RESET EVERYTHING INSTANTENEOUSLY + { + objectAnimName.text = ""; + objectAnimLooped.selected = objectAnimFlipX.selected = objectAnimFlipY.selected = false; + objectAnimFramerate.pos = 24; + objectAnimOffsetX.pos = objectAnimOffsetY.pos = 0; + objectAnimFrames.text = ""; + + return; + } + + var animData = linkedObject.animDatas[objectAnimDropdown.selectedItem.text]; + if (animData == null) return; + + objectAnimName.text = objectAnimDropdown.selectedItem.text; + objectAnimPrefix.text = animData.prefix ?? ""; + objectAnimFrames.text = (animData.frameIndices != null && animData.frameIndices.length > 0 ? animData.frameIndices.join(", ") : ""); + + objectAnimLooped.selected = animData.looped ?? false; + objectAnimFlipX.selected = animData.flipX ?? false; + objectAnimFlipY.selected = animData.flipY ?? false; + objectAnimFramerate.pos = animData.frameRate ?? 24; + + objectAnimOffsetX.pos = (animData.offsets != null && animData.offsets.length == 2 ? animData.offsets[0] : 0); + objectAnimOffsetY.pos = (animData.offsets != null && animData.offsets.length == 2 ? animData.offsets[1] : 0); + } + + objectAnimSave.onClick = function(_) { + if (linkedObject == null) return; + + if (objectAnimName.text == null || objectAnimName.text == "") + { + state.notifyChange("Animation Saving Error", "Invalid Animation Name!", true); + return; + } + + if (objectAnimPrefix.text == null || objectAnimPrefix.text == "") + { + state.notifyChange("Animation Saving Error", "Missing Animation Prefix!", true); + return; + } + + if (linkedObject.animation.getNameList().contains(objectAnimName.text)) linkedObject.animation.remove(objectAnimName.text); + + var indices = []; + + if (objectAnimFrames.text != null && objectAnimFrames.text != "") + { + var splitter = objectAnimFrames.text.replace(" ", "").split(","); + + for (num in splitter) + { + indices.push(Std.parseInt(num)); + } + } + + var shouldDoIndices:Bool = (indices.length > 0 && !indices.contains(null)); + + linkedObject.addAnim(objectAnimName.text, objectAnimPrefix.text, [objectAnimOffsetX.pos, objectAnimOffsetY.pos], (shouldDoIndices ? indices : []), + Std.int(objectAnimFramerate.pos), objectAnimLooped.selected, objectAnimFlipX.selected, objectAnimFlipY.selected); + + if (linkedObject.animation.getByName(objectAnimName.text) == null) + { + state.notifyChange("Animation Saving Error", "Invalid Frames!", true); + return; + } + + linkedObject.playAnim(objectAnimName.text); + + state.notifyChange("Animation Saving Done", "Animation " + objectAnimName.text + " has been saved to the Object " + linkedObject.name + "."); + updateAnimList(); + + // stops the animation preview if animation is looped for too long + FlxTimer.wait(StageEditorState.TIME_BEFORE_ANIM_STOP, function() { + if (linkedObject != null && linkedObject.animation.curAnim != null) + linkedObject.animation.stop(); // null check cuz if we stop an anim for a null object the game crashes :[ + }); + } + + objectAnimDelete.onClick = function(_) { + if (linkedObject == null || linkedObject.animation.getNameList().length <= 0 || objectAnimDropdown.selectedIndex < 0) return; + + linkedObject.animation.pause(); + linkedObject.animation.stop(); + linkedObject.animation.curAnim = null; + + var daAnim = linkedObject.animation.getNameList()[objectAnimDropdown.selectedIndex]; + + linkedObject.animation.remove(daAnim); + linkedObject.animDatas.remove(daAnim); + linkedObject.offset.set(); + + state.notifyChange("Animation Deletion Done", + "Animation " + + objectAnimDropdown.selectedItem.text + + " has been removed from the Object " + + linkedObject.name + + "."); + + updateAnimList(); + + objectAnimDropdown.selectedIndex = objectAnimDropdown.dataSource.size - 1; + } + + objectAnimDanceBeat.onChange = function(_) { + if (linkedObject != null) linkedObject.danceEvery = Std.int(objectAnimDanceBeat.pos); + } + + objectAnimStart.onChange = function(_) { + if (linkedObject != null) + { + if (linkedObject.animation.getNameList().contains(objectAnimStart.text)) objectAnimStart.styleString = "color: white"; + else + objectAnimStart.styleString = "color: indianred"; + + linkedObject.startingAnimation = objectAnimStart.text; + } + } + + // misc + objectMiscAntialias.onClick = function(_) { + if (linkedObject != null) linkedObject.antialiasing = objectMiscAntialias.selected; + } + + objectMiscBlendDrop.onChange = function(_) { + if (linkedObject != null) + linkedObject.blend = objectMiscBlendDrop.selectedItem.text == "NONE" ? null : AssetDataHandler.blendFromString(objectMiscBlendDrop.selectedItem.text); + } + + objectMiscColor.onChange = function(_) { + if (linkedObject != null) linkedObject.color = FlxColor.fromRGB(objectMiscColor.currentColor.r, objectMiscColor.currentColor.g, + objectMiscColor.currentColor.b); + } + + // reset button callbacks + objectResetImageButton.onClick = function(_) { + if (linkedObject != null) + { + linkedObject.loadGraphic(AssetDataHandler.getDefaultGraphic()); + linkedObject.updateHitbox(); + refresh(); + + // remove unused bitmaps + state.removeUnusedBitmaps(); + } + } + + objectZIdxReset.onClick = function(_) { + if (linkedObject != null) objectZIdxStepper.pos = 0; // corner cutting because onChange will activate with this + } + + objectPosResetButton.onClick = function(_) { + if (linkedObject != null) + { + linkedObject.screenCenter(); + objectPosXStepper.pos = linkedObject.x; + objectPosYStepper.pos = linkedObject.y; + } + } + + objectAlphaResetButton.onClick = function(_) { + if (linkedObject != null) linkedObject.alpha = objectAlphaSlider.pos = 1; + } + + objectAngleResetButton.onClick = function(_) { + if (linkedObject != null) linkedObject.angle = objectAngleSlider.pos = 0; + } + + objectScaleResetButton.onClick = objectSizeResetButton.onClick = function(_) // the corner cutting goes crazy + { + if (linkedObject != null) + { + linkedObject.scale.set(1, 1); + refresh(); // refreshes like multiple shit + } + } + + objectScrollResetButton.onClick = function(_) { + if (linkedObject != null) linkedObject.scrollFactor.x = linkedObject.scrollFactor.y = objectScrollXSlider.pos = objectScrollYSlider.pos = 1; + } + + objectFrameReset.onClick = function(_) { + if (linkedObject == null) return; + + linkedObject.loadGraphic(linkedObject.pixels); + linkedObject.animDatas.clear(); + linkedObject.animation.destroyAnimations(); + refresh(); + } + + objectMiscAntialiasReset.onClick = function(_) { + if (linkedObject != null) objectMiscAntialias.selected = true; + } + + objectMiscBlendReset.onClick = function(_) { + if (linkedObject != null) objectMiscBlendDrop.selectedItem = "NORMAL"; + } + + objectMiscColorReset.onClick = function(_) { + if (linkedObject != null) objectMiscColor.currentColor = Color.fromString("white"); + } + + objectAnimDanceBeatReset.onClick = function(_) { + if (linkedObject != null) objectAnimDanceBeat.pos = 0; + } + + objectAnimStartReset.onClick = function(_) { + if (linkedObject != null) objectAnimStart.text = ""; + } + + refresh(); + } + + var prevFrames:Array = []; + var prevAnims:Array = []; + + override public function refresh() + { + linkedObject = stageEditorState.selectedSprite; + + objectPosXStepper.step = stageEditorState.moveStep; + objectPosYStepper.step = stageEditorState.moveStep; + objectAngleSlider.step = funkin.save.Save.instance.stageEditorAngleStep; + + if (linkedObject == null) + { + updateFrameList(); + updateAnimList(); + return; + } + + // saving fps + if (objectImagePreview.resource != linkedObject.frame) objectImagePreview.resource = linkedObject.frame; + + if (objectZIdxStepper.pos != linkedObject.zIndex) objectZIdxStepper.pos = linkedObject.zIndex; + if (objectPosXStepper.pos != linkedObject.x) objectPosXStepper.pos = linkedObject.x; + if (objectPosYStepper.pos != linkedObject.y) objectPosYStepper.pos = linkedObject.y; + if (objectAlphaSlider.pos != linkedObject.alpha) objectAlphaSlider.pos = linkedObject.alpha; + if (objectAngleSlider.pos != linkedObject.angle) objectAngleSlider.pos = linkedObject.angle; + if (objectScaleXStepper.pos != linkedObject.scale.x) objectScaleXStepper.pos = linkedObject.scale.x; + if (objectScaleYStepper.pos != linkedObject.scale.y) objectScaleYStepper.pos = linkedObject.scale.y; + if (objectSizeXStepper.pos != linkedObject.width) objectSizeXStepper.pos = linkedObject.width; + if (objectSizeYStepper.pos != linkedObject.height) objectSizeYStepper.pos = linkedObject.height; + if (objectScrollXSlider.pos != linkedObject.scrollFactor.x) objectScrollXSlider.pos = linkedObject.scrollFactor.x; + if (objectScrollYSlider.pos != linkedObject.scrollFactor.y) objectScrollYSlider.pos = linkedObject.scrollFactor.y; + if (objectMiscAntialias.selected != linkedObject.antialiasing) objectMiscAntialias.selected = linkedObject.antialiasing; + + if (objectMiscColor.currentColor != Color.fromString(linkedObject.color.toHexString() ?? "white")) + objectMiscColor.currentColor = Color.fromString(linkedObject.color.toHexString()); + + if (objectAnimDanceBeat.pos != linkedObject.danceEvery) objectAnimDanceBeat.pos = linkedObject.danceEvery; + if (objectAnimStart.text != linkedObject.startingAnimation) objectAnimStart.text = linkedObject.startingAnimation; + + var objBlend = Std.string(linkedObject.blend) ?? "NONE"; + if (objectMiscBlendDrop.selectedItem != objBlend.toUpperCase()) objectMiscBlendDrop.selectedItem = objBlend.toUpperCase(); + + // ough the max + if (objectFrameImageWidth.max != linkedObject.pixels.width) objectFrameImageWidth.max = linkedObject.graphic.width; + if (objectFrameImageHeight.max != linkedObject.pixels.height) objectFrameImageHeight.max = linkedObject.graphic.height; + + // update some anim shit + if (prevFrames != linkedObject.frames.frames.copy()) updateFrameList(); + if (prevAnims != linkedObject.animation.getNameList().copy()) updateAnimList(); + } + + function updateFrameList() + { + prevFrames = []; + objectAnimFrameList.dataSource = new ArrayDataSource(); + + if (linkedObject == null) return; + + for (fname in linkedObject.frames.frames) + { + if (fname != null) objectAnimFrameList.dataSource.add({name: fname.name, tooltip: fname.name}); + + prevFrames.push(fname); + } + } + + function updateAnimList() + { + objectAnimDropdown.dataSource.clear(); + prevAnims = []; + if (linkedObject == null) return; + + for (aname in linkedObject.animation.getNameList()) + { + objectAnimDropdown.dataSource.add({text: aname}); + prevAnims.push(aname); + } + + if (linkedObject.animation.getNameList().contains(objectAnimStart.text)) objectAnimStart.styleString = "color: white"; + else + objectAnimStart.styleString = "color: indianred"; + + linkedObject.startingAnimation = objectAnimStart.text; + } +} diff --git a/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorStageToolbox.hx b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorStageToolbox.hx new file mode 100644 index 00000000..5d7770c0 --- /dev/null +++ b/source/funkin/ui/debug/stageeditor/toolboxes/StageEditorStageToolbox.hx @@ -0,0 +1,60 @@ +package funkin.ui.debug.stageeditor.toolboxes; + +import haxe.ui.components.NumberStepper; +import haxe.ui.components.TextField; +import haxe.ui.components.DropDown; +import funkin.util.SortUtil; + +@:build(haxe.ui.macros.ComponentMacros.build("assets/exclude/data/ui/stage-editor/toolboxes/stage-settings.xml")) +class StageEditorStageToolbox extends StageEditorDefaultToolbox +{ + var stageNameText:TextField; + var stageZoomStepper:NumberStepper; + var stageLibraryDrop:DropDown; + + override public function new(state:StageEditorState) + { + super(state); + + stageNameText.onChange = function(_) { + state.stageName = stageNameText.text; + state.saved = false; + } + + stageZoomStepper.onChange = function(_) { + state.stageZoom = stageZoomStepper.pos; + state.updateMarkerPos(); + state.saved = false; + } + + final EXCLUDE_LIBS = ["art", "default", "vlc", "videos", "songs"]; + var allLibs = []; + + @:privateAccess + { + for (lib => idk in lime.utils.Assets.libraryPaths) + { + if (!EXCLUDE_LIBS.contains(lib)) allLibs.push(lib); + } + } + allLibs.sort(SortUtil.alphabetically); // this system is VERY stupid, it relies on the possibility that the future libraries will be named week(end)[x] + + for (lib in allLibs) + { + stageLibraryDrop.dataSource.add({text: lib}); + } + + stageLibraryDrop.onChange = function(_) { + state.stageFolder = stageLibraryDrop.selectedItem.text; + } + + refresh(); + } + + override public function refresh() + { + stageNameText.text = stageEditorState.stageName; + stageZoomStepper.pos = stageEditorState.stageZoom; + stageLibraryDrop.selectedItem = stageEditorState.stageFolder; + } +} diff --git a/source/funkin/ui/freeplay/Album.hx b/source/funkin/ui/freeplay/Album.hx new file mode 100644 index 00000000..264094d6 --- /dev/null +++ b/source/funkin/ui/freeplay/Album.hx @@ -0,0 +1,86 @@ +package funkin.ui.freeplay; + +import funkin.data.freeplay.album.AlbumData; +import funkin.data.freeplay.album.AlbumRegistry; +import funkin.data.animation.AnimationData; +import funkin.data.IRegistryEntry; +import flixel.graphics.FlxGraphic; + +/** + * A class representing the data for an album as displayed in Freeplay. + */ +class Album implements IRegistryEntry +{ + public function new(id:String) + { + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse album data for id: $id'; + } + } + + /** + * Return the name of the album. + * @ + */ + public function getAlbumName():String + { + return _data.name; + } + + /** + * Return the artists of the album. + * @return The list of artists + */ + public function getAlbumArtists():Array + { + return _data.artists; + } + + /** + * Get the asset key for the album art. + * @return The asset key + */ + public function getAlbumArtAssetKey():String + { + return _data.albumArtAsset; + } + + /** + * Get the album art as a graphic, ready to apply to a sprite. + * @return The built graphic + */ + public function getAlbumArtGraphic():FlxGraphic + { + return FlxG.bitmap.add(Paths.image(getAlbumArtAssetKey())); + } + + /** + * Get the asset key for the album title. + */ + public function getAlbumTitleAssetKey():String + { + return _data.albumTitleAsset; + } + + /** + * Get the offsets for the album title. + */ + public function getAlbumTitleOffsets():Null> + { + return _data.albumTitleOffsets; + } + + public function hasAlbumTitleAnimations() + { + return _data.albumTitleAnimations.length > 0; + } + + public function getAlbumTitleAnimations():Array + { + return _data.albumTitleAnimations; + } +} diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx new file mode 100644 index 00000000..2996458e --- /dev/null +++ b/source/funkin/ui/freeplay/AlbumRoll.hx @@ -0,0 +1,253 @@ +package funkin.ui.freeplay; + +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import flixel.group.FlxSpriteGroup; +import flixel.util.FlxSort; +import flixel.util.FlxTimer; +import funkin.data.freeplay.album.AlbumRegistry; +import funkin.graphics.FunkinSprite; +import funkin.util.SortUtil; + +/** + * The graphic for the album roll in the FreeplayState. + * Simply set `albumID` to fetch the required data and update the textures. + */ +class AlbumRoll extends FlxSpriteGroup +{ + /** + * The ID of the album to display. + * Modify this value to automatically update the album art and title. + */ + public var albumId(default, set):Null; + + function set_albumId(value:Null):Null + { + if (this.albumId != value || value == null) + { + this.albumId = value; + updateAlbum(); + } + + return value; + } + + var newAlbumArt:FlxAtlasSprite; + var albumTitle:FunkinSprite; + + var difficultyStars:DifficultyStars; + var _exitMovers:Null; + var _exitMoversCharSel:Null; + + var albumData:Album; + + final animNames:Map = [ + "volume1-active" => "ENTRANCE", + "volume2-active" => "ENTRANCE VOL2", + "volume3-active" => "ENTRANCE VOL3", + "volume1-trans" => "VOL1 TRANS", + "volume2-trans" => "VOL2 TRANS", + "volume3-trans" => "VOL3 TRANS", + "volume1-idle" => "VOL1 STILL", + "volume2-idle" => "VOL2 STILL", + "volume3-idle" => "VOL3 STILL", + ]; + + public function new() + { + super(); + + newAlbumArt = new FlxAtlasSprite(640, 360, Paths.animateAtlas("freeplay/albumRoll/freeplayAlbum")); + newAlbumArt.visible = false; + newAlbumArt.onAnimationComplete.add(onAlbumFinish); + + add(newAlbumArt); + + difficultyStars = new DifficultyStars(140, 39); + difficultyStars.visible = false; + add(difficultyStars); + + buildAlbumTitle("freeplay/albumRoll/volume1-text"); + albumTitle.visible = false; + } + + function onAlbumFinish(animName:String):Void + { + // Play the idle animation for the current album. + if (animName != "idle") + { + newAlbumArt.playAnimation('idle', true, false, true); + } + else + { + newAlbumArt.cleanupAnimation('idle'); + } + } + + /** + * Load the album data by ID and update the textures. + */ + function updateAlbum():Void + { + if (albumId == null) + { + this.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; + }; + + // Update the album art. + var albumGraphic = Paths.image(albumData.getAlbumArtAssetKey()); + newAlbumArt.replaceFrameGraphic(0, albumGraphic); + + buildAlbumTitle(albumData.getAlbumTitleAssetKey(), albumData.getAlbumTitleOffsets()); + applyExitMovers(); + refresh(); + } + + public function refresh():Void + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + + /** + * Apply exit movers for the album roll. + * @param exitMovers The exit movers to apply. + */ + public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + if (exitMovers == null) + { + exitMovers = _exitMovers; + } + else + { + _exitMovers = exitMovers; + } + + if (exitMovers == null) return; + + if (exitMoversCharSel == null) + { + exitMoversCharSel = _exitMoversCharSel; + } + else + { + _exitMoversCharSel = exitMoversCharSel; + } + + if (exitMoversCharSel == null) return; + + exitMovers.set([newAlbumArt, difficultyStars], + { + x: FlxG.width, + speed: 0.4, + wait: 0 + }); + + exitMoversCharSel.set([newAlbumArt, difficultyStars], + { + y: -175, + speed: 0.8, + wait: 0.1 + }); + } + + var titleTimer:Null = null; + + /** + * Play the intro animation on the album art. + */ + public function playIntro():Void + { + albumTitle.visible = false; + newAlbumArt.visible = true; + newAlbumArt.playAnimation('intro', true); + + difficultyStars.visible = false; + new FlxTimer().start(0.75, function(_) { + showTitle(); + showStars(); + albumTitle.animation.play('switch'); + }); + } + + public function skipIntro():Void + { + // Weird workaround + newAlbumArt.playAnimation('switch', true); + albumTitle.animation.play('switch'); + } + + public function showTitle():Void + { + albumTitle.visible = true; + } + + public function buildAlbumTitle(assetKey:String, ?titleOffsets:Null>):Void + { + if (albumTitle != null) + { + remove(albumTitle); + albumTitle = null; + } + + if (titleOffsets == null) + { + titleOffsets = [0, 0]; + } + + albumTitle = FunkinSprite.createSparrow(925, 500, assetKey); + albumTitle.visible = albumTitle.frames != null && newAlbumArt.visible; + albumTitle.animation.addByPrefix('idle', 'idle0', 24, true); + albumTitle.animation.addByPrefix('switch', 'switch0', 24, false); + add(albumTitle); + + albumTitle.animation.onFinish.add(function(name) { + if (name == 'switch') albumTitle.animation.play('idle'); + }); + albumTitle.animation.play('idle'); + + albumTitle.zIndex = 1000; + + albumTitle.x += titleOffsets[0]; + albumTitle.y += titleOffsets[1]; + + if (_exitMovers != null) _exitMovers.set([albumTitle], + { + x: FlxG.width, + speed: 0.4, + wait: 0 + }); + + if (_exitMoversCharSel != null) _exitMoversCharSel.set([albumTitle], + { + y: -190, + speed: 0.8, + wait: 0.1 + }); + } + + public function setDifficultyStars(?difficulty:Int):Void + { + if (difficulty == null) return; + difficultyStars.difficulty = difficulty; + } + + /** + * Make the album stars visible. + */ + public function showStars():Void + { + difficultyStars.visible = true; // true; + difficultyStars.flameCheck(); + } +} diff --git a/source/funkin/ui/freeplay/BGScrollingText.hx b/source/funkin/ui/freeplay/BGScrollingText.hx new file mode 100644 index 00000000..cfcdd39a --- /dev/null +++ b/source/funkin/ui/freeplay/BGScrollingText.hx @@ -0,0 +1,107 @@ +package funkin.ui.freeplay; + +import flixel.FlxObject; +import flixel.group.FlxSpriteGroup; +import flixel.text.FlxText; +import flixel.util.FlxSort; + +// its kinda like marqeee html lol! +class BGScrollingText extends FlxSpriteGroup +{ + var grpTexts:FlxTypedSpriteGroup; + + public var widthShit:Float = FlxG.width; + public var placementOffset:Float = 20; + public var speed:Float = 1; + public var size(default, set):Int = 48; + + public var funnyColor(default, set):Int = 0xFFFFFFFF; + + public function new(x:Float, y:Float, text:String, widthShit:Float = 100, ?bold:Bool = false, ?size:Int = 48) + { + super(x, y); + + this.widthShit = widthShit; + if (size != null) this.size = size; + + grpTexts = new FlxTypedSpriteGroup(); + add(grpTexts); + + var testText:FlxText = new FlxText(0, 0, 0, text, this.size); + testText.font = "5by7"; + testText.bold = bold; + testText.updateHitbox(); + grpTexts.add(testText); + + var needed:Int = Math.ceil(widthShit / testText.frameWidth) + 1; + + for (i in 0...needed) + { + var lmfao:Int = i + 1; + + var coolText:FlxText = new FlxText((lmfao * testText.frameWidth) + (lmfao * 20), 0, 0, text, this.size); + + coolText.font = "5by7"; + coolText.bold = bold; + coolText.updateHitbox(); + grpTexts.add(coolText); + } + } + + function set_size(value:Int):Int + { + if (grpTexts != null) + { + grpTexts.forEach(function(txt:FlxText) { + txt.size = value; + }); + } + this.size = value; + return value; + } + + function set_funnyColor(col:Int):Int + { + grpTexts.forEach(function(txt) { + txt.color = col; + }); + + return col; + } + + override public function update(elapsed:Float) + { + for (txt in grpTexts.group) + { + txt.x -= 1 * (speed * (elapsed / (1 / 60))); + + if (speed > 0) + { + if (txt.x < -txt.frameWidth) + { + txt.x = grpTexts.group.members[grpTexts.length - 1].x + grpTexts.group.members[grpTexts.length - 1].frameWidth + placementOffset; + + sortTextShit(); + } + } + else + { + if (txt.x > txt.frameWidth * 2) + { + txt.x = grpTexts.group.members[0].x - grpTexts.group.members[0].frameWidth - placementOffset; + + sortTextShit(); + } + } + } + + super.update(elapsed); + } + + function sortTextShit():Void + { + grpTexts.sort(function(Order:Int, Obj1:FlxObject, Obj2:FlxObject) { + return FlxSort.byValues(Order, Obj1.x, Obj2.x); + }); + } +} diff --git a/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx new file mode 100644 index 00000000..0987c465 --- /dev/null +++ b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx @@ -0,0 +1,191 @@ +package funkin.ui.freeplay; + +import funkin.graphics.shaders.PureColor; +import funkin.input.Controls; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.FunkinSprite; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import flixel.text.FlxText; +import flixel.text.FlxText.FlxTextAlign; + +@:nullSafety +class CapsuleOptionsMenu extends FlxSpriteGroup +{ + var capsuleMenuBG:FunkinSprite; + var parent:FreeplayState; + + var queueDestroy:Bool = false; + + var instrumentalIds:Array = ['']; + var currentInstrumentalIndex:Int = 0; + + var currentInstrumental:FlxText; + + var busy:Bool = false; + + var leftArrow:InstrumentalSelector; + + var rightArrow:InstrumentalSelector; + + public function new(parent:FreeplayState, x:Float = 0, y:Float = 0, instIds:Array):Void + { + super(x, y); + + this.parent = parent; + this.instrumentalIds = instIds; + + capsuleMenuBG = FunkinSprite.createSparrow(0, 0, 'freeplay/instBox/instBox'); + + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + capsuleMenuBG.animation.addByPrefix('idle', 'idle0', 24, true); + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + + currentInstrumental = new FlxText(0, 36, capsuleMenuBG.width, ''); + currentInstrumental.setFormat('VCR OSD Mono', 40, FlxTextAlign.CENTER, true); + + final PAD = 4; + leftArrow = new InstrumentalSelector(parent, PAD, 30, false, parent.getControls()); + rightArrow = new InstrumentalSelector(parent, capsuleMenuBG.width - leftArrow.width - PAD, 30, true, parent.getControls()); + + var label:FlxText = new FlxText(0, 5, capsuleMenuBG.width, 'INSTRUMENTAL'); + label.setFormat('VCR OSD Mono', 24, FlxTextAlign.CENTER, true); + + add(capsuleMenuBG); + add(leftArrow); + add(rightArrow); + add(label); + add(currentInstrumental); + + capsuleMenuBG.animation.finishCallback = function(_) { + capsuleMenuBG.animation.play('idle', true); + }; + capsuleMenuBG.animation.play('open', true); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (queueDestroy) + { + destroy(); + return; + } + var changedInst = false; + + if (!busy) + { + @:privateAccess + if (parent.controls.BACK) + { + close(); + return; + } + + if (parent.getControls().UI_LEFT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex + 1) % instrumentalIds.length; + changedInst = true; + } + if (parent.getControls().UI_RIGHT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex - 1 + instrumentalIds.length) % instrumentalIds.length; + changedInst = true; + } + if (parent.getControls().ACCEPT) + { + busy = true; + onConfirm(instrumentalIds[currentInstrumentalIndex] ?? ''); + } + } + + if (!changedInst && currentInstrumental.text == '') changedInst = true; + + if (changedInst) + { + currentInstrumental.text = instrumentalIds[currentInstrumentalIndex].toTitleCase() ?? ''; + if (currentInstrumental.text == '') currentInstrumental.text = 'Default'; + } + } + + public function close():Void + { + // Play in reverse. + capsuleMenuBG.animation.play('open', true, true); + if (leftArrow.moveShitDownTimer != null) leftArrow.moveShitDownTimer.cancel(); + if (rightArrow.moveShitDownTimer != null) rightArrow.moveShitDownTimer.cancel(); + capsuleMenuBG.animation.finishCallback = function(_) { + parent.cleanupCapsuleOptionsMenu(); + queueDestroy = true; + }; + } + + /** + * Override this with `capsuleOptionsMenu.onConfirm = myFunction;` + */ + public dynamic function onConfirm(targetInstId:String):Void + { + throw 'onConfirm not implemented!'; + } +} + +/** + * The difficulty selector arrows to the left and right of the difficulty. + */ +class InstrumentalSelector extends FunkinSprite +{ + var controls:Controls; + var whiteShader:PureColor; + + var parent:FreeplayState; + + var baseScale:Float = 0.6; + + public var moveShitDownTimer:FlxTimer; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls) + { + super(x, y); + + this.parent = parent; + + this.controls = controls; + + frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); + animation.addByPrefix('shine', 'arrow pointer loop', 24); + animation.play('shine'); + + whiteShader = new PureColor(FlxColor.WHITE); + + shader = whiteShader; + + flipX = flipped; + + scale.x = scale.y = 1 * baseScale; + updateHitbox(); + } + + override function update(elapsed:Float):Void + { + if (flipX && controls.UI_RIGHT_P) moveShitDown(); + if (!flipX && controls.UI_LEFT_P) moveShitDown(); + + super.update(elapsed); + } + + function moveShitDown():Void + { + offset.y -= 5; + + whiteShader.colorSet = true; + + scale.x = scale.y = 0.5 * baseScale; + + moveShitDownTimer = new FlxTimer().start(2 / 24, function(tmr) { + scale.x = scale.y = 1 * baseScale; + whiteShader.colorSet = false; + updateHitbox(); + }); + } +} diff --git a/source/funkin/ui/freeplay/CapsuleText.hx b/source/funkin/ui/freeplay/CapsuleText.hx new file mode 100644 index 00000000..1c96dbc1 --- /dev/null +++ b/source/funkin/ui/freeplay/CapsuleText.hx @@ -0,0 +1,218 @@ +package funkin.ui.freeplay; + +import openfl.filters.BitmapFilterQuality; +import flixel.text.FlxText; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.shaders.GaussianBlurShader; +import funkin.graphics.shaders.LeftMaskShader; +import flixel.math.FlxRect; +import flixel.tweens.FlxEase; +import flixel.util.FlxTimer; +import flixel.tweens.FlxTween; +import openfl.display.BlendMode; +import flixel.util.FlxColor; + +class CapsuleText extends FlxSpriteGroup +{ + public var blurredText:FlxText; + + var whiteText:FlxText; + + public var text(default, set):String; + + var maskShaderSongName:LeftMaskShader = new LeftMaskShader(); + + public var clipWidth(default, set):Int = 255; + + public var tooLong:Bool = false; + + var glowColor:FlxColor = 0xFF00ccff; + + // 255, 27 normal + // 220, 27 favourited + + public function new(x:Float, y:Float, songTitle:String, size:Float) + { + super(x, y); + + blurredText = initText(songTitle, size); + blurredText.shader = new GaussianBlurShader(1); + whiteText = initText(songTitle, size); + // whiteText.shader = new GaussianBlurShader(0.3); + text = songTitle; + + blurredText.color = glowColor; + whiteText.color = 0xFFFFFFFF; + add(blurredText); + add(whiteText); + } + + function initText(songTitle, size:Float):FlxText + { + var text:FlxText = new FlxText(0, 0, 0, songTitle, Std.int(size)); + text.font = "5by7"; + return text; + } + + public function applyStyle(styleData:FreeplayStyle):Void + { + glowColor = styleData.getCapsuleSelCol(); + blurredText.color = glowColor; + whiteText.textField.filters = [ + new openfl.filters.GlowFilter(glowColor, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) + ]; + } + + // ???? none + // 255, 27 normal + // 220, 27 favourited + + function set_clipWidth(value:Int):Int + { + resetText(); + checkClipWidth(value); + return clipWidth = value; + } + + /** + * Checks if the text if it's too long, and clips if it is + * @param wid + */ + function checkClipWidth(?wid:Int):Void + { + if (wid == null) wid = clipWidth; + + if (whiteText.width > wid) + { + tooLong = true; + + blurredText.clipRect = new FlxRect(0, 0, wid, blurredText.height); + whiteText.clipRect = new FlxRect(0, 0, wid, whiteText.height); + } + else + { + tooLong = false; + + blurredText.clipRect = null; + whiteText.clipRect = null; + } + } + + function set_text(value:String):String + { + if (value == null) return value; + if (blurredText == null || whiteText == null) + { + trace('WARN: Capsule not initialized properly'); + return text = value; + } + + blurredText.text = value; + whiteText.text = value; + checkClipWidth(); + whiteText.textField.filters = [ + new openfl.filters.GlowFilter(glowColor, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) + ]; + + return text = value; + } + + var moveTimer:FlxTimer = new FlxTimer(); + var moveTween:FlxTween; + + public function initMove():Void + { + moveTimer.start(0.6, (timer) -> { + moveTextRight(); + }); + } + + function moveTextRight():Void + { + var distToMove:Float = whiteText.width - clipWidth; + moveTween = FlxTween.tween(whiteText.offset, {x: distToMove}, 2, + { + onUpdate: function(_) { + whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height); + blurredText.offset = whiteText.offset; + blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, blurredText.height); + }, + onComplete: function(_) { + moveTimer.start(0.3, (timer) -> { + moveTextLeft(); + }); + }, + ease: FlxEase.sineInOut + }); + } + + function moveTextLeft():Void + { + moveTween = FlxTween.tween(whiteText.offset, {x: 0}, 2, + { + onUpdate: function(_) { + whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height); + blurredText.offset = whiteText.offset; + blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, blurredText.height); + }, + onComplete: function(_) { + moveTimer.start(0.3, (timer) -> { + moveTextRight(); + }); + }, + ease: FlxEase.sineInOut + }); + } + + public function resetText():Void + { + scale.x = 1; + scale.y = 1; + if (moveTween != null) moveTween.cancel(); + if (moveTimer != null) moveTimer.cancel(); + whiteText.offset.x = 0; + whiteText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height); + blurredText.clipRect = new FlxRect(whiteText.offset.x, 0, clipWidth, whiteText.height); + } + + var flickerState:Bool = false; + var flickerTimer:FlxTimer; + + public function flickerText():Void + { + resetText(); + flickerTimer = new FlxTimer().start(1 / 24, flickerProgress, 19); + } + + function flickerProgress(timer:FlxTimer):Void + { + if (flickerState == true) + { + whiteText.blend = BlendMode.ADD; + blurredText.blend = BlendMode.ADD; + blurredText.color = 0xFFFFFFFF; + whiteText.color = 0xFFFFFFFF; + whiteText.textField.filters = [ + new openfl.filters.GlowFilter(0xFFFFFF, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) + ]; + } + else + { + blurredText.color = glowColor; + whiteText.color = 0xFFDDDDDD; + whiteText.textField.filters = [ + new openfl.filters.GlowFilter(0xDDDDDD, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) + ]; + } + flickerState = !flickerState; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + } +} diff --git a/source/funkin/ui/freeplay/DifficultyStars.hx b/source/funkin/ui/freeplay/DifficultyStars.hx new file mode 100644 index 00000000..e7a2b888 --- /dev/null +++ b/source/funkin/ui/freeplay/DifficultyStars.hx @@ -0,0 +1,111 @@ +package funkin.ui.freeplay; + +import flixel.group.FlxSpriteGroup; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.shaders.HSVShader; + +class DifficultyStars extends FlxSpriteGroup +{ + /** + * Internal handler var for difficulty... ranges from 0... to 15 + * 0 is 1 star... 15 is 0 stars! + */ + var curDifficulty(default, set):Int = 0; + + /** + * Range between 0 and 15 + */ + public var difficulty(default, set):Int = 1; + + public var stars:FlxAtlasSprite; + + public var flames:FreeplayFlames; + + var hsvShader:HSVShader; + + public function new(x:Float, y:Float) + { + super(x, y); + + hsvShader = new HSVShader(); + + flames = new FreeplayFlames(0, 0); + add(flames); + + stars = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/freeplayStars")); + stars.anim.play("diff stars"); + add(stars); + + stars.shader = hsvShader; + + for (memb in flames.members) + memb.shader = hsvShader; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // "loops" the current animation + // for clarity, the animation file looks like + // frame : stars + // 0-99: 1 star + // 100-199: 2 stars + // ...... + // 1300-1499: 15 stars + // 1500 : 0 stars + if (curDifficulty < 15 && stars.anim.curFrame >= (curDifficulty + 1) * 100) + { + stars.anim.play("diff stars", true, false, curDifficulty * 100); + } + } + + function set_difficulty(value:Int):Int + { + difficulty = value; + + if (difficulty <= 0) + { + difficulty = 0; + curDifficulty = 15; + } + else if (difficulty <= 15) + { + difficulty = value; + curDifficulty = difficulty - 1; + } + else + { + difficulty = 15; + curDifficulty = difficulty - 1; + } + + flameCheck(); + + return difficulty; + } + + public function flameCheck():Void + { + if (difficulty > 10) flames.flameCount = difficulty - 10; + else + flames.flameCount = 0; + } + + function set_curDifficulty(value:Int):Int + { + curDifficulty = value; + if (curDifficulty == 15) + { + stars.anim.play("diff stars", true, false, 1500); + stars.anim.pause(); + } + else + { + stars.anim.curFrame = Std.int(curDifficulty * 100); + stars.anim.play("diff stars", true, false, curDifficulty * 100); + } + + return curDifficulty; + } +} diff --git a/source/funkin/ui/freeplay/FreeplayDJ.hx b/source/funkin/ui/freeplay/FreeplayDJ.hx new file mode 100644 index 00000000..be7e332c --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayDJ.hx @@ -0,0 +1,521 @@ +package funkin.ui.freeplay; + +import flixel.util.FlxSignal; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.freeplay.player.PlayerData.PlayerFreeplayDJData; + +class FreeplayDJ extends FlxAtlasSprite +{ + // Represents the sprite's current status. + // Without state machines I would have driven myself crazy years ago. + // Made this PRIVATE so we can keep track of everything that can alter the state! + // Add a function to this class if you want to edit this value from outside. + private var currentState:FreeplayDJState = Intro; + + // A callback activated when the intro animation finishes. + public var onIntroDone:FlxSignal = new FlxSignal(); + + // A callback activated when the idle easter egg plays. + public var onIdleEasterEgg:FlxSignal = new FlxSignal(); + + var seenIdleEasterEgg:Bool = false; + + static final IDLE_EGG_PERIOD:Float = 60.0; + static final IDLE_CARTOON_PERIOD:Float = 120.0; + + // Time since last special idle animation you. + var timeIdling:Float = 0; + + final characterId:String = Constants.DEFAULT_CHARACTER; + final playableCharData:PlayerFreeplayDJData; + + public function new(x:Float, y:Float, characterId:String) + { + this.characterId = characterId; + + var playableChar = PlayerRegistry.instance.fetchEntry(characterId); + playableCharData = playableChar.getFreeplayDJData(); + + super(x, y, playableCharData.getAtlasPath()); + + onAnimationFrame.add(function(name, number) { + if (name == playableCharData.getAnimationPrefix('cartoon')) + { + if (number == playableCharData.getCartoonSoundClickFrame()) + { + FunkinSound.playOnce(Paths.sound('remote_click')); + } + if (number == playableCharData.getCartoonSoundCartoonFrame()) + { + runTvLogic(); + } + } + }); + + FlxG.debugger.track(this); + FlxG.console.registerObject("dj", this); + + onAnimationComplete.add(onFinishAnim); + onAnimationLoop.add(onFinishAnim); + + FlxG.console.registerFunction("freeplayCartoon", function() { + currentState = Cartoon; + }); + } + + override public function listAnimations():Array + { + var anims:Array = []; + @:privateAccess + for (animKey in anim.symbolDictionary) + { + anims.push(animKey.name); + } + return anims; + } + + var lowPumpLoopPoint:Int = 4; + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + switch (currentState) + { + case Intro: + // Play the intro animation then leave this state immediately. + var animPrefix = playableCharData.getAnimationPrefix('intro'); + if (getCurrentAnimation() != animPrefix || !this.anim.isPlaying) playFlashAnimation(animPrefix, true); + timeIdling = 0; + case Idle: + // We are in this state the majority of the time. + var animPrefix = playableCharData.getAnimationPrefix('idle'); + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } + timeIdling += elapsed; + case NewUnlock: + var animPrefix = playableCharData.getAnimationPrefix('newUnlock'); + if (!hasAnimation(animPrefix)) + { + currentState = Idle; + } + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } + case Confirm: + var animPrefix = playableCharData.getAnimationPrefix('confirm'); + if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, false); + timeIdling = 0; + case FistPumpIntro: + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpIntroEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpIntroBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + } + else + { + FlxG.log.warn("Unrecognized animation in FistPumpIntro: " + getCurrentAnimation()); + } + + case FistPump: + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpLoopEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpLoopBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); + } + } + else + { + FlxG.log.warn("Unrecognized animation in FistPump: " + getCurrentAnimation()); + } + + case IdleEasterEgg: + var animPrefix = playableCharData.getAnimationPrefix('idleEasterEgg'); + if (getCurrentAnimation() != animPrefix) + { + onIdleEasterEgg.dispatch(); + playFlashAnimation(animPrefix, false); + seenIdleEasterEgg = true; + } + timeIdling = 0; + case Cartoon: + var animPrefix = playableCharData.getAnimationPrefix('cartoon'); + if (animPrefix == null) + { + currentState = IdleEasterEgg; + } + else + { + if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, true); + timeIdling = 0; + } + default: + // I shit myself. + } + } + + function onFinishAnim(name:String):Void + { + // var name = anim.curSymbol.name; + + if (name == playableCharData.getAnimationPrefix('intro')) + { + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + } + else + { + currentState = Idle; + } + onIntroDone.dispatch(); + } + else if (name == playableCharData.getAnimationPrefix('idle')) + { + // trace('Finished idle'); + + if (timeIdling >= IDLE_EGG_PERIOD && !seenIdleEasterEgg) + { + currentState = IdleEasterEgg; + } + else if (timeIdling >= IDLE_CARTOON_PERIOD) + { + currentState = Cartoon; + } + } + else if (name == playableCharData.getAnimationPrefix('confirm')) + { + // trace('Finished confirm'); + } + else if (name == playableCharData.getAnimationPrefix('fistPump')) + { + // trace('Finished fist pump'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('idleEasterEgg')) + { + // trace('Finished spook'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('loss')) + { + // trace('Finished loss reaction'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('cartoon')) + { + // trace('Finished cartoon'); + + var frame:Int = FlxG.random.bool(33) ? playableCharData.getCartoonLoopBlinkFrame() : playableCharData.getCartoonLoopFrame(); + + // Character switches channels when the video ends, or at a 10% chance each time his idle loops. + if (FlxG.random.bool(5)) + { + frame = playableCharData.getCartoonChannelChangeFrame(); + // boyfriend switches channel code? + // runTvLogic(); + } + trace('Replay idle: ${frame}'); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, frame); + // trace('Finished confirm'); + } + else if (name == playableCharData.getAnimationPrefix('newUnlock')) + { + // Animation should loop. + } + else if (name == playableCharData.getAnimationPrefix('charSelect')) + { + onCharSelectComplete(); + } + else + { + trace('Finished ${name}'); + } + } + + public function resetAFKTimer():Void + { + timeIdling = 0; + seenIdleEasterEgg = false; + } + + /** + * Dynamic function, it's actually a variable you can reassign! + * `dj.onCharSelectComplete = function() {};` + */ + public dynamic function onCharSelectComplete():Void + { + trace('onCharSelectComplete()'); + } + + var offsetX:Float = 0.0; + var offsetY:Float = 0.0; + + var cartoonSnd:Null = null; + + public var playingCartoon:Bool = false; + + public function runTvLogic() + { + if (cartoonSnd == null) + { + // tv is OFF, but getting turned on + FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() { + loadCartoon(); + }); + } + else + { + // plays it smidge after the click + FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() { + cartoonSnd.destroy(); + loadCartoon(); + }); + } + + // loadCartoon(); + } + + function loadCartoon() + { + cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, 60); + }); + + // Fade out music to 40% volume over 1 second. + // This helps make the TV a bit more audible. + FlxG.sound.music.fadeOut(1.0, 0.1); + + // Play the cartoon at a random time between the start and 5 seconds from the end. + cartoonSnd.time = FlxG.random.float(0, Math.max(cartoonSnd.length - (5 * Constants.MS_PER_SEC), 0.0)); + } + + final cartoonList:Array = openfl.utils.Assets.list().filter(function(path) return path.startsWith("assets/sounds/cartoons/")); + + function getRandomFlashToon():String + { + var randomFile = FlxG.random.getObject(cartoonList); + + // Strip folder prefix + randomFile = randomFile.replace("assets/sounds/", ""); + // Strip file extension + randomFile = randomFile.substring(0, randomFile.length - 4); + + return randomFile; + } + + public function confirm():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = Confirm; + } + + public function toCharSelect():Void + { + if (hasAnimation(playableCharData.getAnimationPrefix('charSelect'))) + { + currentState = CharSelect; + var animPrefix = playableCharData.getAnimationPrefix('charSelect'); + playFlashAnimation(animPrefix, true, false, false, 0); + } + else + { + FlxG.log.warn("Freeplay character does not have 'charSelect' animation!"); + currentState = Confirm; + // Call this immediately; otherwise, we get locked out of Character Select. + onCharSelectComplete(); + } + } + + public function fistPumpIntro():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } + + public function fistPump():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPump; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + + public function fistPumpLossIntro():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + + public function fistPumpLoss():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPump; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); + } + + override public function getCurrentAnimation():String + { + if (this.anim == null || this.anim.curSymbol == null) return ""; + return this.anim.curSymbol.name; + } + + public function playFlashAnimation(id:String, Force:Bool = false, Reverse:Bool = false, Loop:Bool = false, Frame:Int = 0):Void + { + playAnimation(id, Force, Reverse, Loop, Frame); + applyAnimOffset(); + } + + function applyAnimOffset() + { + var AnimName = getCurrentAnimation(); + var daOffset = playableCharData.getAnimationOffsetsByPrefix(AnimName); + if (daOffset != null) + { + var xValue = daOffset[0]; + var yValue = daOffset[1]; + if (AnimName == "Boyfriend DJ watchin tv OG") + { + xValue += offsetX; + yValue += offsetY; + } + + trace('Successfully applied offset ($AnimName): ' + xValue + ', ' + yValue); + offset.set(xValue, yValue); + } + else + { + trace('No offset found ($AnimName), defaulting to: 0, 0'); + offset.set(0, 0); + } + } + + public override function destroy():Void + { + super.destroy(); + + if (cartoonSnd != null) + { + cartoonSnd.destroy(); + cartoonSnd = null; + } + } +} + +enum FreeplayDJState +{ + /** + * Character enters the frame and transitions to Idle. + */ + Intro; + + /** + * Character loops in idle. + */ + Idle; + + /** + * Plays an easter egg animation after a period in Idle, then reverts to Idle. + */ + IdleEasterEgg; + + /** + * Plays an elaborate easter egg animation. Does not revert until another animation is triggered. + */ + Cartoon; + + /** + * Player has selected a song. + */ + Confirm; + + /** + * Character preps to play the fist pump animation; plays after the Results screen. + * The actual frame label that gets played may vary based on the player's success. + */ + FistPumpIntro; + + /** + * Character plays the fist pump animation. + * The actual frame label that gets played may vary based on the player's success. + */ + FistPump; + + /** + * Plays an animation to indicate that the player has a new unlock in Character Select. + * Overrides all idle animations as well as the fist pump. Only Confirm and CharSelect will override this. + */ + NewUnlock; + + /** + * Plays an animation to transition to the Character Select screen. + */ + CharSelect; +} diff --git a/source/funkin/ui/freeplay/FreeplayFlames.hx b/source/funkin/ui/freeplay/FreeplayFlames.hx new file mode 100644 index 00000000..f6b6f5c3 --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayFlames.hx @@ -0,0 +1,136 @@ +package funkin.ui.freeplay; + +import flixel.group.FlxSpriteGroup; +import flixel.FlxSprite; +import flixel.util.FlxTimer; + +class FreeplayFlames extends FlxSpriteGroup +{ + var flameX(default, set):Float = 917; + var flameY(default, set):Float = 103; + var flameSpreadX(default, set):Float = 29; + var flameSpreadY(default, set):Float = 6; + + public var flameCount(default, set):Int = 0; + + var flameTimer:Float = 0.25; + + public function new(x:Float, y:Float) + { + super(x, y); + + for (i in 0...5) + { + var flame:FlxSprite = new FlxSprite(flameX + (flameSpreadX * i), flameY + (flameSpreadY * i)); + flame.frames = Paths.getSparrowAtlas("freeplay/freeplayFlame"); + flame.animation.addByPrefix("flame", "fire loop full instance 1", FlxG.random.int(23, 25), false); + flame.animation.play("flame"); + flame.visible = false; + flameCount = 0; + + // sets the loop... maybe better way to do this lol! + flame.animation.finishCallback = function(_) { + flame.animation.play("flame", true, false, 2); + }; + add(flame); + } + } + + var properPositions:Bool = false; + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + // doesn't work in create()/new() for some reason + // so putting it here bwah! + if (!properPositions) + { + setFlamePositions(); + properPositions = true; + } + } + + var timers:Array = []; + + function set_flameCount(value:Int):Int + { + // Stop all existing timers. + // This fixes a bug where quickly switching difficulties would show flames. + for (timer in timers) + { + timer.active = false; + timer.destroy(); + timers.remove(timer); + } + + this.flameCount = value; + var visibleCount:Int = 0; + for (i in 0...5) + { + if (members[i] == null) continue; + var flame:FlxSprite = members[i]; + if (i < flameCount) + { + if (!flame.visible) + { + var nextTimer:FlxTimer = new FlxTimer().start(flameTimer * visibleCount, function(currentTimer:FlxTimer) { + if (i >= this.flameCount) + { + trace('EARLY EXIT'); + return; + } + timers.remove(currentTimer); + flame.animation.play("flame", true); + flame.visible = true; + }); + timers.push(nextTimer); + + visibleCount++; + } + } + else + { + flame.visible = false; + } + } + return this.flameCount; + } + + function setFlamePositions() + { + for (i in 0...5) + { + var flame:FlxSprite = members[i]; + flame.x = flameX + (flameSpreadX * i); + flame.y = flameY + (flameSpreadY * i); + } + } + + function set_flameX(value:Float):Float + { + this.flameX = value; + setFlamePositions(); + return this.flameX; + } + + function set_flameY(value:Float):Float + { + this.flameY = value; + setFlamePositions(); + return this.flameY; + } + + function set_flameSpreadX(value:Float):Float + { + this.flameSpreadX = value; + setFlamePositions(); + return this.flameSpreadX; + } + + function set_flameSpreadY(value:Float):Float + { + this.flameSpreadY = value; + setFlamePositions(); + return this.flameSpreadY; + } +} diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx new file mode 100644 index 00000000..0725bf1f --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayScore.hx @@ -0,0 +1,140 @@ +package funkin.ui.freeplay; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; + +class FreeplayScore extends FlxTypedSpriteGroup +{ + public var scoreShit(default, set):Int = 0; + + function set_scoreShit(val):Int + { + if (group == null || group.members == null) return val; + var loopNum:Int = group.members.length - 1; + 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; + + // var funnyNum = group.members[loopNum]; + // prevNum = group.members[loopNum + 1]; + + // if (prevNum != null) + // { + // funnyNum.x = prevNum.x - (funnyNum.width * 0.7); + // } + + // funnyNum.y = (funnyNum.baseY - (funnyNum.height / 2)) + 73; + // funnyNum.x = (funnyNum.baseX - (funnyNum.width / 2)) + 450; // this plus value is hand picked lol! + + dumbNumb = Math.floor(dumbNumb / 10); + loopNum--; + } + + while (loopNum >= 0) + { + group.members[loopNum].digit = 0; + loopNum--; + } + + return val; + } + + public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100, ?styleData:FreeplayStyle) + { + super(x, y); + + for (i in 0...digitCount) + { + if (styleData == null) + { + add(new ScoreNum(x + (45 * i), y, 0)); + } + else + { + add(new ScoreNum(x + (45 * i), y, 0, styleData)); + } + } + + this.scoreShit = scoreShit; + } + + public function updateScore(scoreNew:Int) + { + scoreShit = scoreNew; + } +} + +class ScoreNum extends FlxSprite +{ + public var digit(default, set):Int = 0; + + function set_digit(val):Int + { + if (animation.curAnim != null && animation.curAnim.name != numToString[val]) + { + animation.play(numToString[val], true, false, 0); + updateHitbox(); + + switch (val) + { + case 1: + offset.x -= 15; + case 5: + // set offsets + // offset.x += 0; + // offset.y += 10; + + case 7: + // offset.y += 6; + case 4: + // offset.y += 5; + case 9: + // offset.y += 5; + default: + centerOffsets(false); + } + } + + return val; + } + + public var baseY:Float = 0; + public var baseX:Float = 0; + + var numToString:Array = ["ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE"]; + + public function new(x:Float, y:Float, ?initDigit:Int = 0, ?styleData:FreeplayStyle) + { + super(x, y); + + baseY = y; + baseX = x; + + if (styleData == null) + { + frames = Paths.getSparrowAtlas('digital_numbers'); + } + else + { + frames = Paths.getSparrowAtlas(styleData.getNumbersAssetKey()); + } + + for (i in 0...10) + { + var stringNum:String = numToString[i]; + animation.addByPrefix(stringNum, '$stringNum DIGITAL', 24, false); + } + + this.digit = initDigit; + + animation.play(numToString[digit], true); + + setGraphicSize(Std.int(width * 0.4)); + updateHitbox(); + } +} diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx new file mode 100644 index 00000000..89486718 --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -0,0 +1,2518 @@ +package funkin.ui.freeplay; + +import funkin.ui.freeplay.backcards.*; +import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.input.touch.FlxTouch; +import flixel.math.FlxAngle; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.song.SongRegistry; +import funkin.data.story.level.LevelRegistry; +import funkin.effects.IntervalShake; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.graphics.shaders.AngleMask; +import funkin.graphics.shaders.HSVShader; +import funkin.graphics.shaders.PureColor; +import funkin.graphics.shaders.BlueFade; +import funkin.graphics.shaders.StrokeShader; +import openfl.filters.ShaderFilter; +import funkin.input.Controls; +import funkin.play.PlayStatePlaylist; +import funkin.play.scoring.Scoring.ScoringRank; +import funkin.play.song.Song; +import funkin.save.Save; +import funkin.save.Save.SaveScoreData; +import funkin.ui.AtlasText; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.story.Level; +import funkin.ui.transition.LoadingState; +import funkin.ui.transition.stickers.StickerSubState; +import funkin.util.MathUtil; +import funkin.util.SortUtil; +import openfl.display.BlendMode; +import funkin.data.freeplay.style.FreeplayStyleRegistry; +#if FEATURE_DISCORD_RPC +import funkin.api.discord.DiscordClient; +#end + +/** + * The state for the freeplay menu, allowing the player to select any song to play. + */ +@:nullSafety +class FreeplayState extends MusicBeatSubState +{ + // + // Params + // + + /** + * The current character for this FreeplayState. + * You can't change this without transitioning to a new FreeplayState. + */ + final currentCharacterId:String; + + final currentCharacter:PlayableCharacter; + + /** + * For the audio preview, the duration of the fade-in effect. + */ + public static final FADE_IN_DURATION:Float = 0.5; + + /** + * For the audio preview, the duration of the fade-out effect. + * + */ + public static final FADE_OUT_DURATION:Float = 0.25; + + /** + * For the audio preview, the volume at which the fade-in starts. + */ + public static final FADE_IN_START_VOLUME:Float = 0.25; + + /** + * For the audio preview, the volume at which the fade-in ends. + */ + public static final FADE_IN_END_VOLUME:Float = 1.0; + + /** + * For the audio preview, the volume at which the fade-out starts. + */ + public static final FADE_OUT_END_VOLUME:Float = 0.0; + + var songs:Array> = []; + + var curSelected:Int = 0; + + /** + * Currently selected difficulty, in string form. + */ + var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * Current variation: default, erect, pico, bf, etc. + */ + var currentVariation:String = Constants.DEFAULT_VARIATION; + + public var fp:FreeplayScore; + + var txtCompletion:AtlasText; + var lerpCompletion:Float = 0; + var intendedCompletion:Float = 0; + var lerpScore:Float = 0; + var intendedScore:Int = 0; + + var grpDifficulties:FlxTypedSpriteGroup; + + var coolColors:Array = [ + 0xFF9271FD, + 0xFF9271FD, + 0xFF223344, + 0xFF941653, + 0xFFFC96D7, + 0xFFA0D1FF, + 0xFFFF78BF, + 0xFFF6B604 + ]; + + var grpCapsules:FlxTypedGroup; + var curPlaying:Bool = false; + + var dj:Null = null; + + var ostName:FlxText; + var albumRoll:AlbumRoll; + + var charSelectHint:FlxText; + + var letterSort:LetterSort; + var exitMovers:ExitMoverData = new Map(); + + var exitMoversCharSel:ExitMoverData = new Map(); + + var stickerSubState:Null = null; + + /** + * The difficulty we were on when this menu was last accessed. + */ + public static var rememberedDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * The song we were on when this menu was last accessed. + * NOTE: `null` if the last song was `Random`. + */ + public static var rememberedSongId:Null = 'tutorial'; + + /** + * The character we were on when this menu was last accessed. + */ + public static var rememberedCharacterId:String = Constants.DEFAULT_CHARACTER; + + /** + * The remembered variation we were on when this menu was last accessed. + */ + public static var rememberedVariation:String = Constants.DEFAULT_VARIATION; + + var allDifficulties:Array = Constants.DEFAULT_DIFFICULTY_LIST_FULL; + + var funnyCam:FunkinCamera; + var rankCamera:FunkinCamera; + var rankBg:FunkinSprite; + var rankVignette:FlxSprite; + + var backingCard:Null = null; + + /** + * The backing card that has the toned dots, right now we just use that one dad graphic dave cooked up + */ + public var backingImage:FunkinSprite; + + public var angleMaskShader:AngleMask = new AngleMask(); + + var fadeShader:BlueFade = new BlueFade(); + + var fromResultsParams:Null = null; + var prepForNewRank:Bool = false; + var styleData:Null = null; + var fromCharSelect:Bool = false; + + public function new(?params:FreeplayStateParams, ?stickers:StickerSubState) + { + currentCharacterId = params?.character ?? rememberedCharacterId; + styleData = FreeplayStyleRegistry.instance.fetchEntry(currentCharacterId); + + var fetchPlayableCharacter = function():PlayableCharacter { + var targetCharId = params?.character ?? rememberedCharacterId; + var result = PlayerRegistry.instance.fetchEntry(targetCharId); + if (result == null) throw 'No valid playable character with id ${targetCharId}'; + return result; + }; + + currentCharacter = fetchPlayableCharacter(); + currentVariation = rememberedVariation; + currentDifficulty = rememberedDifficulty; + styleData = FreeplayStyleRegistry.instance.fetchEntry(currentCharacter.getFreeplayStyleID()); + rememberedCharacterId = currentCharacter?.id ?? Constants.DEFAULT_CHARACTER; + fromCharSelect = params?.fromCharSelect ?? false; + fromResultsParams = params?.fromResults; + prepForNewRank = fromResultsParams?.playRankAnim ?? false; + + super(FlxColor.TRANSPARENT); + + if (stickers?.members != null) stickerSubState = stickers; + + switch (currentCharacterId) + { + case(PlayerRegistry.instance.hasNewCharacter()) => true: + backingCard = new NewCharacterCard(currentCharacter); + case 'bf': + backingCard = new BoyfriendCard(currentCharacter); + case 'pico': + backingCard = new PicoCard(currentCharacter); + default: + backingCard = new BackingCard(currentCharacter); + } + + // We build a bunch of sprites BEFORE create() so we can guarantee they aren't null later on. + albumRoll = new AlbumRoll(); + fp = new FreeplayScore(460, 60, 7, 100, styleData); + rankCamera = new FunkinCamera('rankCamera', 0, 0, FlxG.width, FlxG.height); + funnyCam = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height); + grpCapsules = new FlxTypedGroup(); + grpDifficulties = new FlxTypedSpriteGroup(-300, 80); + letterSort = new LetterSort(400, 75); + rankBg = new FunkinSprite(0, 0); + rankVignette = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/rankVignette')); + sparks = new FlxSprite(0, 0); + sparksADD = new FlxSprite(0, 0); + txtCompletion = new AtlasText(1185, 87, '69', AtlasFont.FREEPLAY_CLEAR); + + ostName = new FlxText(8, 8, FlxG.width - 8 - 8, 'OFFICIAL OST', 48); + charSelectHint = new FlxText(-40, 18, FlxG.width - 8 - 8, 'Press [ LOL ] to change characters', 32); + + backingImage = FunkinSprite.create(backingCard.pinkBack.width * 0.74, 0, styleData == null ? 'freeplay/freeplayBGweek1-bf' : styleData.getBgAssetKey()); + } + + override function create():Void + { + super.create(); + + FlxG.state.persistentUpdate = false; + FlxTransitionableState.skipNextTransIn = true; + + var fadeShaderFilter:ShaderFilter = new ShaderFilter(fadeShader); + funnyCam.filters = [fadeShaderFilter]; + + if (stickerSubState != null) + { + this.persistentUpdate = true; + this.persistentDraw = true; + + openSubState(stickerSubState); + stickerSubState.degenStickers(); + } + + #if FEATURE_DISCORD_RPC + // Updating Discord Rich Presence + DiscordClient.instance.setPresence({state: 'In the Menus', details: null}); + #end + + var isDebug:Bool = false; + + #if FEATURE_DEBUG_FUNCTIONS + isDebug = true; + #end + + // Block input until the intro finishes. + busy = true; + + // Add a null entry that represents the RANDOM option + songs.push(null); + + // programmatically adds the songs via LevelRegistry and SongRegistry + for (levelId in LevelRegistry.instance.listSortedLevelIds()) + { + var level:Null = LevelRegistry.instance.fetchEntry(levelId); + + if (level == null) + { + trace('[WARN] Could not find level with id (${levelId})'); + continue; + } + + for (songId in level.getSongs()) + { + var song:Null = SongRegistry.instance.fetchEntry(songId); + + if (song == null) + { + trace('[WARN] Could not find song with id (${songId})'); + continue; + } + + songs.push(new FreeplaySongData(song, level)); + } + } + + // LOAD MUSIC + + // LOAD CHARACTERS + + trace(FlxG.width); + trace(FlxG.camera.zoom); + trace(FlxG.camera.initialZoom); + trace(FlxCamera.defaultZoom); + + if (backingCard != null) + { + add(backingCard); + backingCard.init(); + backingCard.applyExitMovers(exitMovers, exitMoversCharSel); + backingCard.instance = this; + } + + if (currentCharacter?.getFreeplayDJData() != null) + { + dj = new FreeplayDJ(640, 366, currentCharacterId); + exitMovers.set([dj], + { + x: -dj.width * 1.6, + speed: 0.5 + }); + add(dj); + exitMoversCharSel.set([dj], + { + y: -175, + speed: 0.8, + wait: 0.1 + }); + } + + backingImage.shader = angleMaskShader; + backingImage.visible = false; + + var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(backingImage.width), Std.int(backingImage.height), FlxColor.BLACK); + add(blackOverlayBullshitLOLXD); // used to mask the text lol! + + // this makes the texture sizes consistent, for the angle shader + backingImage.setGraphicSize(0, FlxG.height); + blackOverlayBullshitLOLXD.setGraphicSize(0, FlxG.height); + + backingImage.updateHitbox(); + blackOverlayBullshitLOLXD.updateHitbox(); + + exitMovers.set([blackOverlayBullshitLOLXD, backingImage], + { + x: FlxG.width * 1.5, + speed: 0.4, + wait: 0 + }); + + exitMoversCharSel.set([blackOverlayBullshitLOLXD, backingImage], + { + y: -100, + speed: 0.8, + wait: 0.1 + }); + + add(backingImage); + // backingCard.pinkBack.width * 0.74 + + blackOverlayBullshitLOLXD.shader = backingImage.shader; + + rankBg.makeSolidColor(FlxG.width, FlxG.height, 0xD3000000); + add(rankBg); + + add(grpCapsules); + + add(grpDifficulties); + + exitMovers.set([grpDifficulties], + { + x: -300, + speed: 0.25, + wait: 0 + }); + + exitMoversCharSel.set([grpDifficulties], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + + for (diffId in Constants.DEFAULT_DIFFICULTY_LIST_FULL) + { + var diffSprite:DifficultySprite = new DifficultySprite(diffId); + diffSprite.difficultyId = diffId; + diffSprite.visible = diffId == Constants.DEFAULT_DIFFICULTY; + grpDifficulties.add(diffSprite); + } + + albumRoll.albumId = null; + add(albumRoll); + + var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 164, FlxColor.BLACK); + overhangStuff.y -= overhangStuff.height; + + if (fromCharSelect) + { + blackOverlayBullshitLOLXD.x = 387.76; + overhangStuff.y = -100; + backingCard?.skipIntroTween(); + } + else + { + albumRoll.applyExitMovers(exitMovers, exitMoversCharSel); + FlxTween.tween(overhangStuff, {y: -100}, 0.3, {ease: FlxEase.quartOut}); + FlxTween.tween(blackOverlayBullshitLOLXD, {x: 387.76}, 0.7, {ease: FlxEase.quintOut}); + } + + var fnfFreeplay:FlxText = new FlxText(8, 8, 0, 'FREEPLAY', 48); + fnfFreeplay.font = 'VCR OSD Mono'; + fnfFreeplay.visible = false; + + ostName.font = 'VCR OSD Mono'; + ostName.alignment = RIGHT; + ostName.visible = false; + + charSelectHint.alignment = CENTER; + charSelectHint.font = "5by7"; + charSelectHint.color = 0xFF5F5F5F; + charSelectHint.text = 'Press [ ${controls.getDialogueNameFromControl(FREEPLAY_CHAR_SELECT, true)} ] to change characters'; + charSelectHint.y -= 100; + FlxTween.tween(charSelectHint, {y: charSelectHint.y + 100}, 0.8, {ease: FlxEase.quartOut}); + + exitMovers.set([overhangStuff, fnfFreeplay, ostName, charSelectHint], + { + y: -overhangStuff.height, + x: 0, + speed: 0.2, + wait: 0 + }); + + exitMoversCharSel.set([overhangStuff, fnfFreeplay, ostName, charSelectHint], + { + y: -300, + speed: 0.8, + wait: 0.1 + }); + + var sillyStroke:StrokeShader = new StrokeShader(0xFFFFFFFF, 2, 2); + fnfFreeplay.shader = sillyStroke; + ostName.shader = sillyStroke; + + var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70); + fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); + fnfHighscoreSpr.animation.addByPrefix('highscore', 'highscore small instance 1', 24, false); + fnfHighscoreSpr.visible = false; + fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1)); + fnfHighscoreSpr.updateHitbox(); + add(fnfHighscoreSpr); + + new FlxTimer().start(FlxG.random.float(12, 50), function(tmr) { + fnfHighscoreSpr.animation.play('highscore'); + tmr.time = FlxG.random.float(20, 60); + }, 0); + + fp.visible = false; + add(fp); + + var clearBoxSprite:FlxSprite = new FlxSprite(1165, 65).loadGraphic(Paths.image('freeplay/clearBox')); + clearBoxSprite.visible = false; + add(clearBoxSprite); + + txtCompletion.visible = false; + add(txtCompletion); + + add(letterSort); + letterSort.visible = false; + + exitMovers.set([letterSort], + { + y: -100, + speed: 0.3 + }); + + exitMoversCharSel.set([letterSort], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + + // Reminder, this is a callback function being set, rather than these being called here in create() + letterSort.changeSelectionCallback = (str) -> { + var curSong:Null = grpCapsules.members[curSelected]?.freeplayData; + + switch (str) + { + case 'fav': + generateSongList({filterType: FAVORITE}, true, false); + case 'ALL': + generateSongList(null, true, false); + case '#': + generateSongList({filterType: REGEXP, filterData: '0-9'}, true, false); + default: + generateSongList({filterType: REGEXP, filterData: str}, true, false); + } + + // If the current song is still in the list, or if it was random, we'll land on it + // Otherwise we want to land on the first song of the group, rather than random song when changing letter sorts + // that is, only if there's more than one song in the group! + if (curSong == null || currentFilteredSongs.contains(curSong)) + { + changeSelection(); + } + else if (grpCapsules.members.length > 0) + { + FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + curSelected = 1; + changeSelection(); + } + }; + + exitMovers.set([fp, txtCompletion, fnfHighscoreSpr, clearBoxSprite], + { + x: FlxG.width, + speed: 0.3 + }); + + exitMoversCharSel.set([fp, txtCompletion, fnfHighscoreSpr, clearBoxSprite], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + + var diffSelLeft:DifficultySelector = new DifficultySelector(this, 20, grpDifficulties.y - 10, false, controls, styleData); + var diffSelRight:DifficultySelector = new DifficultySelector(this, 325, grpDifficulties.y - 10, true, controls, styleData); + diffSelLeft.visible = false; + diffSelRight.visible = false; + add(diffSelLeft); + add(diffSelRight); + + // putting these here to fix the layering + add(overhangStuff); + add(fnfFreeplay); + add(ostName); + + if (PlayerRegistry.instance.hasNewCharacter()) + { + add(charSelectHint); + } + + // be careful not to "add()" things in here unless it's to a group that's already added to the state + // otherwise it won't be properly attatched to funnyCamera (relavent code should be at the bottom of create()) + var onDJIntroDone = function() { + busy = false; + + // when boyfriend hits dat shiii + + albumRoll.playIntro(); + var daSong = grpCapsules.members[curSelected].freeplayData; + albumRoll.albumId = daSong?.data.getAlbumId(currentDifficulty, currentVariation); + + if (!fromCharSelect) + { + // render optimisation + if (_parentState != null) _parentState.persistentDraw = false; + + FlxTween.color(backingImage, 0.6, 0xFF000000, 0xFFFFFFFF, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + angleMaskShader.extraColor = backingImage.color; + } + }); + } + + FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut}); + + diffSelLeft.visible = true; + diffSelRight.visible = true; + letterSort.visible = true; + + exitMovers.set([diffSelLeft, diffSelRight], + { + x: -diffSelLeft.width * 2, + speed: 0.26 + }); + + exitMoversCharSel.set([diffSelLeft, diffSelRight], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + + new FlxTimer().start(1 / 24, function(handShit) { + fnfHighscoreSpr.visible = true; + fnfFreeplay.visible = true; + ostName.visible = true; + fp.visible = true; + fp.updateScore(0); + + clearBoxSprite.visible = true; + txtCompletion.visible = true; + intendedCompletion = 0; + + new FlxTimer().start(1.5 / 24, function(bold) { + sillyStroke.width = 0; + sillyStroke.height = 0; + changeSelection(); + }); + }); + + backingImage.visible = true; + backingCard?.introDone(); + + if (prepForNewRank && fromResultsParams != null) + { + rankAnimStart(fromResultsParams, grpCapsules.members[curSelected]); + } + }; + + if (dj != null) + { + dj.onIntroDone.add(onDJIntroDone); + } + else + { + onDJIntroDone(); + } + + allDifficulties = SongRegistry.instance.listAllDifficulties(currentCharacterId); + + // Generates song list with the starter params (who our current character is, last remembered difficulty, etc.) + // Set this to false if you prefer the 50% transparency on the capsules when they first appear. + generateSongList(null, true); + + // dedicated camera for the state so we don't need to fuk around with camera scrolls from the mainmenu / elsewhere + funnyCam.bgColor = FlxColor.TRANSPARENT; + FlxG.cameras.add(funnyCam, false); + + rankVignette.scale.set(2, 2); + rankVignette.updateHitbox(); + rankVignette.blend = BlendMode.ADD; + // rankVignette.cameras = [rankCamera]; + add(rankVignette); + rankVignette.alpha = 0; + + forEach(function(bs) { + bs.cameras = [funnyCam]; + }); + + rankCamera.bgColor = FlxColor.TRANSPARENT; + FlxG.cameras.add(rankCamera, false); + rankBg.cameras = [rankCamera]; + rankBg.alpha = 0; + + if (prepForNewRank) + { + rankCamera.fade(0xFF000000, 0, false, null, true); + } + + if (fromCharSelect) + { + enterFromCharSel(); + onDJIntroDone(); + } + } + + var currentFilter:Null = null; + var currentFilteredSongs:Array> = []; + + /** + * Given the current filter, rebuild the current song list and display it. + * Automatically takes into account currentDifficulty, character, and variation + * + * @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite) + * @param force Whether the capsules should "jump" back in or not using their animation + * @param onlyIfChanged Only apply the filter if the song list has changed + */ + public function generateSongList(filterStuff:Null, force:Bool = false, onlyIfChanged:Bool = true):Void + { + var tempSongs:Array> = songs; + + if (filterStuff != null) tempSongs = sortSongs(tempSongs, filterStuff); + + tempSongs = tempSongs.filter(song -> { + if (song == null) return true; // Random + + // Available variations for current character. We get this since bf is usually `default` variation, and `pico` is `pico` + // but sometimes pico can be the default variation (weekend 1 songs), and bf can be `bf` variation (darnell) + var characterVariations:Array = song.data.getVariationsByCharacter(currentCharacter); + + // Gets all available difficulties for our character, via our available variations + var difficultiesAvailable:Array = song.data.listDifficulties(null, characterVariations); + return difficultiesAvailable.contains(currentDifficulty); + }); + + if (onlyIfChanged) + { + if (tempSongs.isEqualUnordered(currentFilteredSongs)) + { + // If the song list is the same, we don't need to generate a new list. + + // Instead, we just apply the jump-in animation to the existing capsules. + for (capsule in grpCapsules.members) + { + capsule.initPosition(FlxG.width, 0); + capsule.initJumpIn(0, force); + } + + // Stop processing. + return; + } + } + + // Only now do we know that the filter is actually changing. + currentFilter = filterStuff; + + currentFilteredSongs = tempSongs; + curSelected = 0; + + // If curSelected is 0, the result will be null and fall back to the rememberedSongId. + // We set this so if we change the filter, we'd remain on the same song if it's still in the list. + rememberedSongId = grpCapsules.members[curSelected]?.freeplayData?.data.id ?? rememberedSongId; + + grpCapsules.killMembers(); + + // Initialize the random capsule, with empty/blank info (which we display once bf/pico does his hand) + var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem); + randomCapsule.initPosition(FlxG.width, 0); + randomCapsule.initData(null, styleData, 1); + randomCapsule.y = randomCapsule.intendedY(0) + 10; + randomCapsule.targetPos.x = randomCapsule.x; + randomCapsule.alpha = 0.5; + randomCapsule.songText.visible = false; + randomCapsule.favIcon.visible = false; + randomCapsule.favIconBlurred.visible = false; + randomCapsule.ranking.visible = false; + randomCapsule.onConfirm = function() { + capsuleOnConfirmRandom(randomCapsule); + }; + + if (fromCharSelect) randomCapsule.forcePosition(); + else + { + randomCapsule.initJumpIn(0, force); + } + + var hsvShader:HSVShader = new HSVShader(); + randomCapsule.hsvShader = hsvShader; + grpCapsules.add(randomCapsule); + + for (i in 0...tempSongs.length) + { + var tempSong = tempSongs[i]; + if (tempSong == null) continue; + + var funnyMenu:SongMenuItem = grpCapsules.recycle(SongMenuItem); + + funnyMenu.initPosition(FlxG.width, 0); + funnyMenu.initData(tempSong, styleData, i + 1); + funnyMenu.onConfirm = function() { + capsuleOnOpenDefault(funnyMenu); + }; + funnyMenu.y = funnyMenu.intendedY(i + 1) + 10; + funnyMenu.targetPos.x = funnyMenu.x; + funnyMenu.ID = i; + funnyMenu.capsule.alpha = 0.5; + funnyMenu.hsvShader = hsvShader; + funnyMenu.newText.animation.curAnim.curFrame = 45 - ((i * 4) % 45); + + // Stop the bounce-in animation when returning to freeplay from the character selection screen. + if (fromCharSelect) funnyMenu.forcePosition(); + else + funnyMenu.initJumpIn(0, force); + + grpCapsules.add(funnyMenu); + } + + FlxG.console.registerFunction('changeSelection', changeSelection); + + rememberSelection(); + changeSelection(); + refreshCapsuleDisplays(); + } + + /** + * Filters an array of songs based on a filter + * @param songsToFilter What data to use when filtering + * @param songFilter The filter to apply + * @return Array + */ + public function sortSongs(songsToFilter:Array>, songFilter:SongFilter):Array> + { + var filterAlphabetically = function(a:Null, b:Null):Int { + return SortUtil.alphabetically(a?.data.songName ?? '', b?.data.songName ?? ''); + }; + + switch (songFilter.filterType) + { + case REGEXP: + // filterStuff.filterData has a string with the first letter of the sorting range, and the second one + // this creates a filter to return all the songs that start with a letter between those two + + // if filterData looks like "A-C", the regex should look something like this: ^[A-C].* + // to get every song that starts between A and C + var filterRegexp:EReg = new EReg('^[' + songFilter.filterData + '].*', 'i'); + songsToFilter = songsToFilter.filter(filteredSong -> { + if (filteredSong == null) return true; // Random + return filterRegexp.match(filteredSong.data.songName); + }); + + songsToFilter.sort(filterAlphabetically); + + case STARTSWITH: + // extra note: this is essentially a "search" + + songsToFilter = songsToFilter.filter(filteredSong -> { + if (filteredSong == null) return true; // Random + return filteredSong.data.songName.toLowerCase().startsWith(songFilter.filterData ?? ''); + }); + case ALL: + // no filter! + case FAVORITE: + songsToFilter = songsToFilter.filter(filteredSong -> { + if (filteredSong == null) return true; // Random + return filteredSong.isFav; + }); + + songsToFilter.sort(filterAlphabetically); + + default: + // return all on default + } + + return songsToFilter; + } + + var sparks:FlxSprite; + var sparksADD:FlxSprite; + + function rankAnimStart(fromResults:FromResultsParams, capsuleToRank:SongMenuItem):Void + { + busy = true; + // We get the current selected capsule, in-case someone changes the song selection during a timer + capsuleToRank.sparkle.alpha = 0; + // capsuleToRank.forcePosition(); + + rememberedSongId = fromResults.songId; + rememberedDifficulty = fromResults.difficultyId; + changeSelection(); + changeDiff(); + + if (fromResultsParams?.newRank == SHIT) + { + dj?.fistPumpLossIntro(); + } + else + { + dj?.fistPumpIntro(); + } + + // rankCamera.fade(FlxColor.BLACK, 0.5, true); + rankCamera.fade(0xFF000000, 0.5, true, null, true); + if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; + rankBg.alpha = 1; + + if (fromResults.oldRank != null) + { + capsuleToRank.fakeRanking.rank = fromResults.oldRank; + + sparks.frames = Paths.getSparrowAtlas('freeplay/sparks'); + sparks.animation.addByPrefix('sparks', 'sparks', 24, false); + sparks.visible = false; + sparks.blend = BlendMode.ADD; + sparks.setPosition(517, 134); + sparks.scale.set(0.5, 0.5); + add(sparks); + sparks.cameras = [rankCamera]; + + sparksADD.visible = false; + sparksADD.frames = Paths.getSparrowAtlas('freeplay/sparksadd'); + sparksADD.animation.addByPrefix('sparks add', 'sparks add', 24, false); + sparksADD.setPosition(498, 116); + sparksADD.blend = BlendMode.ADD; + sparksADD.scale.set(0.5, 0.5); + add(sparksADD); + sparksADD.cameras = [rankCamera]; + sparksADD.color = fromResults.oldRank.getRankingFreeplayColor(); + // sparksADD.color = sparks.color; + } + + capsuleToRank.doLerp = false; + + // originalPos.x = capsuleToRank.x; + // originalPos.y = capsuleToRank.y; + + originalPos.x = 320.488; + originalPos.y = 235.6; + trace(originalPos); + + capsuleToRank.ranking.visible = false; + capsuleToRank.fakeRanking.visible = false; + + rankCamera.zoom = 1.85; + FlxTween.tween(rankCamera, {"zoom": 1.8}, 0.6, {ease: FlxEase.sineIn}); + + funnyCam.zoom = 1.15; + FlxTween.tween(funnyCam, {"zoom": 1.1}, 0.6, {ease: FlxEase.sineIn}); + + capsuleToRank.cameras = [rankCamera]; + // capsuleToRank.targetPos.set((FlxG.width / 2) - (capsuleToRank.width / 2), + // (FlxG.height / 2) - (capsuleToRank.height / 2)); + + capsuleToRank.setPosition((FlxG.width / 2) - (capsuleToRank.width / 2), (FlxG.height / 2) - (capsuleToRank.height / 2)); + + new FlxTimer().start(0.5, _ -> { + rankDisplayNew(fromResults, capsuleToRank); + }); + } + + function rankDisplayNew(fromResults:Null, capsuleToRank:SongMenuItem):Void + { + capsuleToRank.ranking.visible = false; + capsuleToRank.fakeRanking.visible = false; + capsuleToRank.ranking.scale.set(20, 20); + + if (fromResults != null && fromResults.newRank != null) + { + capsuleToRank.ranking.playAnimationEach(fromResults.newRank.getFreeplayRankIconAsset(), true); + } + + FlxTween.tween(capsuleToRank.ranking, {"scale.x": 1, "scale.y": 1}, 0.1); + + new FlxTimer().start(0.1, _ -> { + capsuleToRank.ranking.visible = true; + + if (fromResults?.oldRank != null) + { + capsuleToRank.fakeRanking.visible = false; + + sparks.visible = true; + sparksADD.visible = true; + sparks.animation.play('sparks', true); + sparksADD.animation.play('sparks add', true); + + sparks.animation.finishCallback = anim -> { + sparks.visible = false; + sparksADD.visible = false; + }; + } + + switch (fromResultsParams?.newRank) + { + case SHIT: + FunkinSound.playOnce(Paths.sound('ranks/rankinbad')); + case PERFECT: + FunkinSound.playOnce(Paths.sound('ranks/rankinperfect')); + case PERFECT_GOLD: + FunkinSound.playOnce(Paths.sound('ranks/rankinperfect')); + default: + FunkinSound.playOnce(Paths.sound('ranks/rankinnormal')); + } + rankCamera.zoom = 1.3; + + FlxTween.tween(rankCamera, {"zoom": 1.5}, 0.3, {ease: FlxEase.backInOut}); + + capsuleToRank.x -= 10; + capsuleToRank.y -= 20; + + FlxTween.tween(funnyCam, {"zoom": 1.05}, 0.3, {ease: FlxEase.elasticOut}); + + capsuleToRank.capsule.angle = -3; + FlxTween.tween(capsuleToRank.capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut}); + + IntervalShake.shake(capsuleToRank.capsule, 0.3, 1 / 30, 0.1, 0, FlxEase.quadOut); + }); + + new FlxTimer().start(0.4, _ -> { + FlxTween.tween(funnyCam, {"zoom": 1}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(rankCamera, {"zoom": 1.2}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(capsuleToRank, {x: originalPos.x - 7, y: originalPos.y - 80}, 0.8 + 0.5, {ease: FlxEase.quartIn}); + }); + + new FlxTimer().start(0.6, _ -> { + rankAnimSlam(fromResults, capsuleToRank); + }); + } + + function rankAnimSlam(fromResultsParams:Null, capsuleToRank:SongMenuItem):Void + { + // FlxTween.tween(rankCamera, {"zoom": 1.9}, 0.5, {ease: FlxEase.backOut}); + FlxTween.tween(rankBg, {alpha: 0}, 0.5, {ease: FlxEase.expoIn}); + + // FlxTween.tween(capsuleToRank, {angle: 5}, 0.5, {ease: FlxEase.backIn}); + + switch (fromResultsParams?.newRank) + { + case SHIT: + FunkinSound.playOnce(Paths.sound('ranks/loss')); + case GOOD: + FunkinSound.playOnce(Paths.sound('ranks/good')); + case GREAT: + FunkinSound.playOnce(Paths.sound('ranks/great')); + case EXCELLENT: + FunkinSound.playOnce(Paths.sound('ranks/excellent')); + case PERFECT: + FunkinSound.playOnce(Paths.sound('ranks/perfect')); + case PERFECT_GOLD: + FunkinSound.playOnce(Paths.sound('ranks/perfect')); + default: + FunkinSound.playOnce(Paths.sound('ranks/loss')); + } + + FlxTween.tween(capsuleToRank, {"targetPos.x": originalPos.x, "targetPos.y": originalPos.y}, 0.5, {ease: FlxEase.expoOut}); + new FlxTimer().start(0.5, _ -> { + funnyCam.shake(0.0045, 0.35); + + if (fromResultsParams?.newRank == SHIT) + { + if (dj != null) dj.fistPumpLoss(); + } + else + { + if (dj != null) dj.fistPump(); + } + + rankCamera.zoom = 0.8; + funnyCam.zoom = 0.8; + FlxTween.tween(rankCamera, {"zoom": 1}, 1, {ease: FlxEase.elasticOut}); + FlxTween.tween(funnyCam, {"zoom": 1}, 0.8, {ease: FlxEase.elasticOut}); + + for (index => capsule in grpCapsules.members) + { + var distFromSelected:Float = Math.abs(index - curSelected) - 1; + + if (distFromSelected < 5) + { + if (index == curSelected) + { + FlxTween.cancelTweensOf(capsule); + // capsule.targetPos.x += 50; + capsule.fadeAnim(); + + rankVignette.color = capsule.getTrailColor(); + rankVignette.alpha = 1; + FlxTween.tween(rankVignette, {alpha: 0}, 0.6, {ease: FlxEase.expoOut}); + + capsule.doLerp = false; + capsule.setPosition(originalPos.x, originalPos.y); + IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12, 0, FlxEase.quadOut, function(_) { + capsule.doLerp = true; + capsule.cameras = [funnyCam]; + + // NOW we can interact with the menu + busy = false; + capsule.sparkle.alpha = 0.7; + playCurSongPreview(capsule); + }, null); + + // FlxTween.tween(capsule, {"targetPos.x": capsule.targetPos.x - 50}, 0.6, + // { + // ease: FlxEase.backInOut, + // onComplete: function(_) { + // capsule.cameras = [funnyCam]; + // } + // }); + FlxTween.tween(capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut}); + } + if (index > curSelected) + { + // capsule.color = FlxColor.RED; + new FlxTimer().start(distFromSelected / 20, _ -> { + capsule.doLerp = false; + + capsule.capsule.angle = FlxG.random.float(-10 + (distFromSelected * 2), 10 - (distFromSelected * 2)); + FlxTween.tween(capsule.capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut}); + + IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12 / (distFromSelected + 1), 0, FlxEase.quadOut, function(_) { + capsule.doLerp = true; + }); + }); + } + + if (index < curSelected) + { + // capsule.color = FlxColor.BLUE; + new FlxTimer().start(distFromSelected / 20, _ -> { + capsule.doLerp = false; + + capsule.capsule.angle = FlxG.random.float(-10 + (distFromSelected * 2), 10 - (distFromSelected * 2)); + FlxTween.tween(capsule.capsule, {angle: 0}, 0.5, {ease: FlxEase.backOut}); + + IntervalShake.shake(capsule, 0.6, 1 / 24, 0.12 / (distFromSelected + 1), 0, FlxEase.quadOut, function(_) { + capsule.doLerp = true; + }); + }); + } + } + + index += 1; + } + }); + + new FlxTimer().start(2, _ -> { + // dj.fistPump(); + prepForNewRank = false; + }); + } + + function tryOpenCharSelect():Void + { + // Check if we have ACCESS to character select! + trace('Is Pico unlocked? ${PlayerRegistry.instance.fetchEntry('pico')?.isUnlocked()}'); + trace('Number of characters: ${PlayerRegistry.instance.countUnlockedCharacters()}'); + + if (PlayerRegistry.instance.countUnlockedCharacters() > 1) + { + trace('Opening character select!'); + } + else + { + trace('Not enough characters unlocked to open character select!'); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + return; + } + + busy = true; + + FunkinSound.playOnce(Paths.sound('confirmMenu')); + + if (dj != null) + { + dj.toCharSelect(); + } + + // Get this character's transition delay, with a reasonable default. + var transitionDelay:Float = currentCharacter.getFreeplayDJData()?.getCharSelectTransitionDelay() ?? 0.25; + + new FlxTimer().start(transitionDelay, _ -> { + transitionToCharSelect(); + }); + } + + function transitionToCharSelect():Void + { + var transitionGradient = new FlxSprite(0, 720).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.updateHitbox(); + transitionGradient.cameras = [rankCamera]; + exitMoversCharSel.set([transitionGradient], + { + y: -720, + speed: 0.8, + wait: 0.1 + }); + add(transitionGradient); + for (index => capsule in grpCapsules.members) + { + var distFromSelected:Float = Math.abs(index - curSelected) - 1; + if (distFromSelected < 5) + { + capsule.doLerp = false; + exitMoversCharSel.set([capsule], + { + y: -250, + speed: 0.8, + wait: 0.1 + }); + } + } + fadeShader.fade(1.0, 0.0, 0.8, {ease: FlxEase.quadIn}); + FlxG.sound.music?.fadeOut(0.9, 0); + new FlxTimer().start(0.9, _ -> { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + }); + for (grpSpr in exitMoversCharSel.keys()) + { + var moveData:Null = exitMoversCharSel.get(grpSpr); + if (moveData == null) continue; + + for (spr in grpSpr) + { + if (spr == null) continue; + + var funnyMoveShit:MoveData = moveData; + + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; + + FlxTween.tween(spr, {y: moveDataY + spr.y}, moveDataSpeed, {ease: FlxEase.backIn}); + } + } + backingCard?.enterCharSel(); + } + + function enterFromCharSel():Void + { + busy = true; + if (_parentState != null) _parentState.persistentDraw = false; + + var transitionGradient = new FlxSprite(0, 720).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.updateHitbox(); + transitionGradient.cameras = [rankCamera]; + exitMoversCharSel.set([transitionGradient], + { + y: -720, + speed: 1.5, + wait: 0.1 + }); + add(transitionGradient); + // FlxTween.tween(transitionGradient, {alpha: 0}, 1, {ease: FlxEase.circIn}); + // for (index => capsule in grpCapsules.members) + // { + // var distFromSelected:Float = Math.abs(index - curSelected) - 1; + // if (distFromSelected < 5) + // { + // capsule.doLerp = false; + // exitMoversCharSel.set([capsule], + // { + // y: -250, + // speed: 0.8, + // wait: 0.1 + // }); + // } + // } + fadeShader.fade(0.0, 1.0, 0.8, {ease: FlxEase.quadIn}); + for (grpSpr in exitMoversCharSel.keys()) + { + var moveData:Null = exitMoversCharSel.get(grpSpr); + if (moveData == null) continue; + + for (spr in grpSpr) + { + if (spr == null) continue; + + var funnyMoveShit:MoveData = moveData; + + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; + + spr.y += moveDataY; + FlxTween.tween(spr, {y: spr.y - moveDataY}, moveDataSpeed * 1.2, + { + ease: FlxEase.expoOut, + onComplete: function(_) { + for (index => capsule in grpCapsules.members) + { + capsule.doLerp = true; + fromCharSelect = false; + busy = false; + albumRoll.applyExitMovers(exitMovers, exitMoversCharSel); + } + } + }); + } + } + } + + var touchY:Float = 0; + var touchX:Float = 0; + var dxTouch:Float = 0; + var dyTouch:Float = 0; + var velTouch:Float = 0; + + var touchTimer:Float = 0; + + var initTouchPos:FlxPoint = new FlxPoint(); + + var spamTimer:Float = 0; + var spamming:Bool = false; + + /** + * If true, disable interaction with the interface. + */ + public var busy:Bool = false; + + var originalPos:FlxPoint = new FlxPoint(); + + var hintTimer:Float = 0; + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (charSelectHint != null) + { + hintTimer += elapsed * 2; + var targetAmt:Float = (Math.sin(hintTimer) + 1) / 2; + charSelectHint.alpha = FlxMath.lerp(0.3, 0.9, targetAmt); + } + + #if FEATURE_DEBUG_FUNCTIONS + if (FlxG.keys.justPressed.P) + { + FlxG.switchState(() -> FreeplayState.build( + { + { + character: currentCharacterId == "pico" ? Constants.DEFAULT_CHARACTER : "pico", + } + })); + } + + if (FlxG.keys.justPressed.T) + { + rankAnimStart(fromResultsParams ?? + { + playRankAnim: true, + newRank: PERFECT_GOLD, + songId: "tutorial", + difficultyId: "hard" + }, grpCapsules.members[curSelected]); + } + #end // ^<-- FEATURE_DEBUG_FUNCTIONS + + if (controls.FREEPLAY_CHAR_SELECT && !busy) + { + tryOpenCharSelect(); + } + + if (controls.FREEPLAY_FAVORITE && !busy) + { + var targetSong = grpCapsules.members[curSelected]?.freeplayData; + if (targetSong != null) + { + var realShit:Int = curSelected; + var isFav = targetSong.toggleFavorite(); + if (isFav) + { + grpCapsules.members[realShit].favIcon.visible = true; + grpCapsules.members[realShit].favIconBlurred.visible = true; + grpCapsules.members[realShit].favIcon.animation.play('fav'); + grpCapsules.members[realShit].favIconBlurred.animation.play('fav'); + FunkinSound.playOnce(Paths.sound('fav'), 1); + grpCapsules.members[realShit].checkClip(); + grpCapsules.members[realShit].selected = grpCapsules.members[realShit].selected; // set selected again, so it can run it's getter function to initialize movement + busy = true; + + grpCapsules.members[realShit].doLerp = false; + FlxTween.tween(grpCapsules.members[realShit], {y: grpCapsules.members[realShit].y - 5}, 0.1, {ease: FlxEase.expoOut}); + + FlxTween.tween(grpCapsules.members[realShit], {y: grpCapsules.members[realShit].y + 5}, 0.1, + { + ease: FlxEase.expoIn, + startDelay: 0.1, + onComplete: function(_) { + grpCapsules.members[realShit].doLerp = true; + busy = false; + } + }); + } + else + { + grpCapsules.members[realShit].favIcon.animation.play('fav', true, true, 9); + grpCapsules.members[realShit].favIconBlurred.animation.play('fav', true, true, 9); + FunkinSound.playOnce(Paths.sound('unfav'), 1); + new FlxTimer().start(0.2, _ -> { + grpCapsules.members[realShit].favIcon.visible = false; + grpCapsules.members[realShit].favIconBlurred.visible = false; + grpCapsules.members[realShit].checkClip(); + }); + + busy = true; + grpCapsules.members[realShit].doLerp = false; + FlxTween.tween(grpCapsules.members[realShit], {y: grpCapsules.members[realShit].y + 5}, 0.1, {ease: FlxEase.expoOut}); + FlxTween.tween(grpCapsules.members[realShit], {y: grpCapsules.members[realShit].y - 5}, 0.1, + { + ease: FlxEase.expoIn, + startDelay: 0.1, + onComplete: function(_) { + grpCapsules.members[realShit].doLerp = true; + busy = false; + } + }); + } + } + } + + if (controls.FREEPLAY_JUMP_TO_TOP && !busy) + { + changeSelection(-curSelected); + } + + if (controls.FREEPLAY_JUMP_TO_BOTTOM && !busy) + { + changeSelection(grpCapsules.countLiving() - curSelected - 1); + } + + lerpScore = MathUtil.smoothLerp(lerpScore, intendedScore, elapsed, 0.5); + lerpCompletion = MathUtil.smoothLerp(lerpCompletion, intendedCompletion, elapsed, 0.5); + + if (Math.isNaN(lerpScore)) + { + lerpScore = intendedScore; + } + + if (Math.isNaN(lerpCompletion)) + { + lerpCompletion = intendedCompletion; + } + + fp.updateScore(Std.int(lerpScore)); + + txtCompletion.text = '${Math.floor(lerpCompletion * 100)}'; + + // Right align the completion percentage + switch (txtCompletion.text.length) + { + case 3: + txtCompletion.offset.x = 10; + case 2: + txtCompletion.offset.x = 0; + case 1: + txtCompletion.offset.x = -24; + default: + txtCompletion.offset.x = 0; + } + + handleInputs(elapsed); + + if (dj != null) FlxG.watch.addQuick('dj-anim', dj.getCurrentAnimation()); + } + + function handleInputs(elapsed:Float):Void + { + if (busy) return; + + var upP:Bool = controls.UI_UP_P; + var downP:Bool = controls.UI_DOWN_P; + var accepted:Bool = controls.ACCEPT; + + if (FlxG.onMobile) + { + for (touch in FlxG.touches.list) + { + if (touch.justPressed) + { + initTouchPos.set(touch.screenX, touch.screenY); + } + if (touch.pressed) + { + var dx:Float = initTouchPos.x - touch.screenX; + var dy:Float = initTouchPos.y - touch.screenY; + + var angle:Float = Math.atan2(dy, dx); + var length:Float = Math.sqrt(dx * dx + dy * dy); + + FlxG.watch.addQuick('LENGTH', length); + FlxG.watch.addQuick('ANGLE', Math.round(FlxAngle.asDegrees(angle))); + } + } + + if (FlxG.touches.getFirst() != null) + { + if (touchTimer >= 1.5) accepted = true; + + touchTimer += elapsed; + var touch:FlxTouch = FlxG.touches.getFirst(); + + velTouch = Math.abs((touch.screenY - dyTouch)) / 50; + + dyTouch = touch.screenY - touchY; + dxTouch = touch.screenX - touchX; + + if (touch.justPressed) + { + touchY = touch.screenY; + dyTouch = 0; + velTouch = 0; + + touchX = touch.screenX; + dxTouch = 0; + } + + if (Math.abs(dxTouch) >= 100) + { + touchX = touch.screenX; + if (dxTouch != 0) dxTouch < 0 ? changeDiff(1) : changeDiff(-1); + } + + if (Math.abs(dyTouch) >= 100) + { + touchY = touch.screenY; + + if (dyTouch != 0) dyTouch < 0 ? changeSelection(1) : changeSelection(-1); + } + } + else + { + touchTimer = 0; + } + } + + #if mobile + for (touch in FlxG.touches.list) + { + if (touch.justPressed) + { + // accepted = true; + } + } + #end + + if ((controls.UI_UP || controls.UI_DOWN)) + { + if (spamming) + { + if (spamTimer >= 0.07) + { + spamTimer = 0; + + if (controls.UI_UP) + { + changeSelection(-1); + } + else + { + changeSelection(1); + } + } + } + else if (spamTimer >= 0.9) + { + spamming = true; + } + else if (spamTimer <= 0) + { + if (controls.UI_UP) + { + changeSelection(-1); + } + else + { + changeSelection(1); + } + } + + spamTimer += elapsed; + if (dj != null) dj.resetAFKTimer(); + } + else + { + spamming = false; + spamTimer = 0; + } + + #if !html5 + if (FlxG.mouse.wheel != 0) + { + if (dj != null) dj.resetAFKTimer(); + changeSelection(-Math.round(FlxG.mouse.wheel)); + } + #else + if (FlxG.mouse.wheel < 0) + { + if (dj != null) dj.resetAFKTimer(); + changeSelection(-Math.round(FlxG.mouse.wheel / 8)); + } + else if (FlxG.mouse.wheel > 0) + { + if (dj != null) dj.resetAFKTimer(); + changeSelection(-Math.round(FlxG.mouse.wheel / 8)); + } + #end + + if (controls.UI_LEFT_P) + { + if (dj != null) dj.resetAFKTimer(); + changeDiff(-1); + generateSongList(currentFilter, true, false); + } + if (controls.UI_RIGHT_P) + { + if (dj != null) dj.resetAFKTimer(); + changeDiff(1); + generateSongList(currentFilter, true, false); + } + + if (controls.BACK && !busy) + { + busy = true; + FlxTween.globalManager.clear(); + FlxTimer.globalManager.clear(); + if (dj != null) dj.onIntroDone.removeAll(); + + FunkinSound.playOnce(Paths.sound('cancelMenu')); + + var longestTimer:Float = 0; + + backingCard?.disappear(); + + for (grpSpr in exitMovers.keys()) + { + var moveData:Null = exitMovers.get(grpSpr); + if (moveData == null) continue; + + for (spr in grpSpr) + { + if (spr == null) continue; + + var funnyMoveShit:MoveData = moveData; + + var moveDataX = funnyMoveShit.x ?? spr.x; + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; + + FlxTween.tween(spr, {x: moveDataX, y: moveDataY}, moveDataSpeed, {ease: FlxEase.expoIn}); + + longestTimer = Math.max(longestTimer, moveDataSpeed + moveDataWait); + } + } + + for (caps in grpCapsules.members) + { + caps.doJumpIn = false; + caps.doLerp = false; + caps.doJumpOut = true; + } + + if (Type.getClass(_parentState) == MainMenuState) + { + _parentState.persistentUpdate = false; + _parentState.persistentDraw = true; + } + + new FlxTimer().start(longestTimer, (_) -> { + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + if (Type.getClass(_parentState) == MainMenuState) + { + FunkinSound.playMusic('freakyMenu', + { + overrideExisting: true, + restartTrack: false, + // Continue playing this music between states, until a different music track gets played. + persist: true + }); + FlxG.sound.music.fadeIn(4.0, 0.0, 1.0); + close(); + } + else + { + FlxG.switchState(() -> new MainMenuState()); + } + }); + } + + if (accepted && !busy) + { + grpCapsules.members[curSelected].onConfirm(); + } + } + + override function beatHit():Bool + { + backingCard?.beatHit(); + + return super.beatHit(); + } + + public override function destroy():Void + { + super.destroy(); + FlxG.cameras.remove(funnyCam); + } + + /** + * findClosestDiff will find the closest difficulty to the given diff. + * It will return the index of the closest song in the grpCapsules.members array. + * @param diff + * @return Int + */ + function findClosestDiff(characterVariations:Array, diff:String):Int + { + var closestIndex:Int = 0; + var closest:Int = curSelected; + + for (index in 0...grpCapsules.members.length) + { + var song:Null = grpCapsules.members[index].freeplayData; + if (song == null) continue; + var characterVar = song.data.getVariationsByCharacter(currentCharacter); + var songDiff:Null = song.data.getDifficulty(diff, null, characterVar)?.difficulty; + // if the difference between the current index and this index is the smallest so far, + // take this one as the closest index. (By comparing with the closest variable) + var c:Int = curSelected - index; + if (songDiff == diff && (Math.abs(c) < Math.abs(closestIndex - curSelected) || closestIndex == 0)) + { + // trace('Found closest diff: ${songDiff} at index ${index} (current: ${curSelected})'); + closestIndex = index; + closest = c; + } + } + + return closestIndex; + } + + /** + * changeDiff is the root of both difficulty and variation changes/management. + * It will check the difficulty of the current variation, all available variations, and all available difficulties per variation. + * It's generally recommended that after calling this you re-sort the song list, however usually it's already on the way to being sorted. + * @param change + * @param force + */ + function changeDiff(change:Int = 0, force:Bool = false):Void + { + touchTimer = 0; + var previousVariation:String = currentVariation; + var daSong:Null = grpCapsules.members[curSelected].freeplayData; + + // Available variations for current character. We get this since bf is usually `default` variation, and `pico` is `pico` + // but sometimes pico can be the default variation (weekend 1 songs), and bf can be `bf` variation (darnell) + var characterVariations:Array = daSong?.data.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST; + var difficultiesAvailable:Array = allDifficulties; + // Gets all available difficulties for our character, via our available variations + var songDifficulties:Array = daSong?.data.listDifficulties(null, characterVariations) ?? Constants.DEFAULT_DIFFICULTY_LIST; + + var currentDifficultyIndex:Int = difficultiesAvailable.indexOf(currentDifficulty); + + if (currentDifficultyIndex == -1) currentDifficultyIndex = difficultiesAvailable.indexOf(Constants.DEFAULT_DIFFICULTY); + + currentDifficultyIndex += change; + + if (currentDifficultyIndex < 0) currentDifficultyIndex = Std.int(difficultiesAvailable.length - 1); + if (currentDifficultyIndex >= difficultiesAvailable.length) currentDifficultyIndex = 0; + // Update the current difficulty + currentDifficulty = difficultiesAvailable[currentDifficultyIndex]; + // For when we change the difficulty, but the song doesn't have that difficulty! + if (daSong != null && !songDifficulties.contains(difficultiesAvailable[currentDifficultyIndex])) + { + curSelected = findClosestDiff(characterVariations, difficultiesAvailable[currentDifficultyIndex]); + daSong = grpCapsules.members[curSelected].freeplayData; + rememberedSongId = daSong?.data.id; + } + + for (variation in characterVariations) + { + if (daSong?.data.hasDifficulty(currentDifficulty, variation) ?? false) + { + currentVariation = variation; + rememberedVariation = variation; + break; + } + } + + if (daSong != null) + { + var targetSong:Null = SongRegistry.instance.fetchEntry(daSong.data.id); + if (targetSong == null) + { + FlxG.log.warn('WARN: could not find song with id (${daSong.data.id})'); + return; + } + + var songScore:Null = Save.instance.getSongScore(daSong.data.id, currentDifficulty, currentVariation); + intendedScore = songScore?.score ?? 0; + intendedCompletion = songScore == null ? 0.0 : Math.max(0, ((songScore.tallies.sick + + songScore.tallies.good - songScore.tallies.missed) / songScore.tallies.totalNotes)); + rememberedDifficulty = currentDifficulty; + grpCapsules.members[curSelected].refreshDisplay((prepForNewRank == true) ? false : true); + } + else + { + intendedScore = 0; + intendedCompletion = 0.0; + rememberedDifficulty = currentDifficulty; + } + + if (intendedCompletion == Math.POSITIVE_INFINITY || intendedCompletion == Math.NEGATIVE_INFINITY || Math.isNaN(intendedCompletion)) + { + intendedCompletion = 0; + } + + for (diffSprite in grpDifficulties.group.members) + { + if (diffSprite == null) continue; + diffSprite.visible = false; + + if (diffSprite.difficultyId == currentDifficulty) + { + diffSprite.visible = true; + + if (change != 0) + { + diffSprite.visible = true; + diffSprite.offset.y += 5; + diffSprite.alpha = 0.5; + new FlxTimer().start(1 / 24, function(swag) { + diffSprite.alpha = 1; + diffSprite.updateHitbox(); + }); + } + } + } + + if (change != 0 || force) + { + // Update the song capsules to reflect the new difficulty info. + for (songCapsule in grpCapsules.members) + { + if (songCapsule == null) continue; + + if (songCapsule.freeplayData != null) + { + songCapsule.initData(songCapsule.freeplayData); + songCapsule.checkClip(); + } + } + + // Reset the song preview in case we changed variations (normal->erect etc) + if (currentVariation != previousVariation) playCurSongPreview(); + } + + // Set the album graphic and play the animation if relevant. + var newAlbumId:Null = daSong?.data.getAlbumId(currentDifficulty, currentVariation); + if (albumRoll.albumId != newAlbumId && !busy) + { + albumRoll.albumId = newAlbumId; + albumRoll.skipIntro(); + } + + // Set difficulty star count. + albumRoll.setDifficultyStars(daSong?.data.getDifficulty(currentDifficulty, currentVariation)?.difficultyRating ?? 0); + } + + function capsuleOnConfirmRandom(randomCapsule:SongMenuItem):Void + { + trace('RANDOM SELECTED'); + + busy = true; + letterSort.inputEnabled = false; + + var availableSongCapsules:Array = grpCapsules.members.filter(function(cap:SongMenuItem) { + // Dead capsules are ones which were removed from the list when changing filters. + return cap.alive && cap.freeplayData != null; + }); + + trace('Available songs: ${availableSongCapsules.map(function(cap) { + return cap?.freeplayData?.data.songName; + })}'); + + if (availableSongCapsules.length == 0) + { + trace('No songs available!'); + busy = false; + letterSort.inputEnabled = true; + FunkinSound.playOnce(Paths.sound('cancelMenu')); + return; + } + + var targetSong:SongMenuItem = FlxG.random.getObject(availableSongCapsules); + + // Seeing if I can do an animation... + curSelected = grpCapsules.members.indexOf(targetSong); + changeSelection(0); // Trigger an update. + + // Act like we hit Confirm on that song. + capsuleOnConfirmDefault(targetSong); + } + + /** + * Called when hitting ENTER to open the instrumental list. + */ + function capsuleOnOpenDefault(cap:SongMenuItem):Void + { + busy = true; + letterSort.inputEnabled = false; + var targetSongId:String = cap?.freeplayData?.data.id ?? 'unknown'; + var targetSongNullable:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (targetSongNullable == null) + { + FlxG.log.warn('WARN: could not find song with id (${targetSongId})'); + busy = false; + letterSort.inputEnabled = true; + return; + } + var targetSong:Song = targetSongNullable; + var targetDifficultyId:String = currentDifficulty; + var targetVariation:Null = currentVariation; + trace('target song: ${targetSongId} (${targetVariation})'); + var targetLevelId:Null = cap?.freeplayData?.levelId; + PlayStatePlaylist.campaignId = targetLevelId ?? null; + + var targetDifficulty:Null = targetSong.getDifficulty(targetDifficultyId, targetVariation); + if (targetDifficulty == null) + { + FlxG.log.warn('WARN: could not find difficulty with id (${targetDifficultyId})'); + busy = false; + letterSort.inputEnabled = true; + return; + } + + trace('target difficulty: ${targetDifficultyId}'); + trace('target variation: ${targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION}'); + + var baseInstrumentalId:String = targetSong.getBaseInstrumentalId(targetDifficultyId, targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = targetSong.listAltInstrumentalIds(targetDifficultyId, + targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; + + if (altInstrumentalIds.length > 0) + { + var instrumentalIds = [baseInstrumentalId].concat(altInstrumentalIds); + openInstrumentalList(cap, instrumentalIds); + } + else + { + trace('NO ALTS'); + capsuleOnConfirmDefault(cap); + } + } + + public function getControls():Controls + { + return controls; + } + + function openInstrumentalList(cap:SongMenuItem, instrumentalIds:Array):Void + { + capsuleOptionsMenu = new CapsuleOptionsMenu(this, cap.targetPos.x + 175, cap.targetPos.y + 115, instrumentalIds); + capsuleOptionsMenu.cameras = [funnyCam]; + capsuleOptionsMenu.zIndex = 10000; + add(capsuleOptionsMenu); + + capsuleOptionsMenu.onConfirm = function(targetInstId:String) { + capsuleOnConfirmDefault(cap, targetInstId); + }; + } + + var capsuleOptionsMenu:Null = null; + + public function cleanupCapsuleOptionsMenu():Void + { + this.busy = false; + letterSort.inputEnabled = true; + + if (capsuleOptionsMenu != null) + { + remove(capsuleOptionsMenu); + capsuleOptionsMenu = null; + } + } + + /** + * Called when hitting ENTER to play the song. + */ + function capsuleOnConfirmDefault(cap:SongMenuItem, ?targetInstId:String):Void + { + PlayStatePlaylist.isStoryMode = false; + + var targetSongId:String = cap?.freeplayData?.data.id ?? 'unknown'; + var targetSongNullable:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (targetSongNullable == null) + { + FlxG.log.warn('WARN: could not find song with id (${targetSongId})'); + busy = false; + letterSort.inputEnabled = true; + return; + } + var targetSong:Song = targetSongNullable; + var targetVariation:Null = currentVariation; + var targetLevelId:Null = cap?.freeplayData?.levelId; + PlayStatePlaylist.campaignId = targetLevelId ?? null; + + var targetDifficulty:Null = targetSong.getDifficulty(currentDifficulty, currentVariation); + if (targetDifficulty == null) + { + FlxG.log.warn('WARN: could not find difficulty with id (${currentDifficulty})'); + busy = false; + letterSort.inputEnabled = true; + return; + } + + if (targetInstId == null) + { + var baseInstrumentalId:String = targetSong?.getBaseInstrumentalId(currentDifficulty, targetDifficulty.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + targetInstId = baseInstrumentalId; + } + + // Visual and audio effects. + FunkinSound.playOnce(Paths.sound('confirmMenu')); + if (dj != null) dj.confirm(); + + grpCapsules.members[curSelected].forcePosition(); + grpCapsules.members[curSelected].confirm(); + + backingCard?.confirm(); + + new FlxTimer().start(styleData?.getStartDelay(), function(tmr:FlxTimer) { + FunkinSound.emptyPartialQueue(); + + Paths.setCurrentLevel(cap?.freeplayData?.levelId); + LoadingState.loadPlayState( + { + targetSong: targetSong, + targetDifficulty: currentDifficulty, + targetVariation: currentVariation, + targetInstrumental: targetInstId, + practiceMode: false, + minimalMode: false, + + #if FEATURE_DEBUG_FUNCTIONS + botPlayMode: FlxG.keys.pressed.SHIFT, + #else + botPlayMode: false, + #end + // TODO: Make these an option! It's currently only accessible via chart editor. + // startTimestamp: 0.0, + // playbackRate: 0.5, + // botPlayMode: true, + }, true); + }); + } + + function refreshCapsuleDisplays():Void + { + grpCapsules.forEachAlive((cap:SongMenuItem) -> { + cap.refreshDisplay(); + }); + } + + function rememberSelection():Void + { + if (rememberedSongId != null) + { + curSelected = currentFilteredSongs.findIndex(function(song) { + if (song == null) return false; + return song.data.id == rememberedSongId; + }); + + if (curSelected == -1) curSelected = 0; + } + + if (rememberedDifficulty != null) + { + currentDifficulty = rememberedDifficulty; + } + + if (rememberedVariation != null) + { + currentVariation = rememberedVariation; + } + } + + function changeSelection(change:Int = 0):Void + { + var prevSelected:Int = curSelected; + + curSelected += change; + + if (!prepForNewRank && curSelected != prevSelected) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + + if (curSelected < 0) curSelected = grpCapsules.countLiving() - 1; + if (curSelected >= grpCapsules.countLiving()) curSelected = 0; + + var daSongCapsule:SongMenuItem = grpCapsules.members[curSelected]; + if (daSongCapsule.freeplayData != null) + { + var songScore:Null = Save.instance.getSongScore(daSongCapsule.freeplayData.data.id, currentDifficulty, currentVariation); + intendedScore = songScore?.score ?? 0; + intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + + songScore.tallies.good - songScore.tallies.missed) / songScore.tallies.totalNotes); + rememberedSongId = daSongCapsule.freeplayData.data.id; + changeDiff(); + daSongCapsule.refreshDisplay((prepForNewRank == true) ? false : true); + } + else + { + intendedScore = 0; + intendedCompletion = 0.0; + rememberedSongId = null; + albumRoll.albumId = null; + changeDiff(); + daSongCapsule.refreshDisplay(); + } + + for (index => capsule in grpCapsules.members) + { + index += 1; + + capsule.selected = index == curSelected + 1; + + capsule.curSelected = curSelected; + + capsule.targetPos.y = capsule.intendedY(index - curSelected); + capsule.targetPos.x = capsule.intendedX(index - curSelected); + + if (index < curSelected) capsule.targetPos.y -= 100; // another 100 for good measure + } + + if (grpCapsules.countLiving() > 0 && !prepForNewRank) + { + playCurSongPreview(daSongCapsule); + grpCapsules.members[curSelected].selected = true; + + // switchBackingImage(daSongCapsule.freeplayData); + } + } + + public function playCurSongPreview(?daSongCapsule:SongMenuItem):Void + { + if (daSongCapsule == null) daSongCapsule = grpCapsules.members[curSelected]; + if (curSelected == 0) + { + FunkinSound.playMusic('freeplayRandom', + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: false + }); + FlxG.sound.music.fadeIn(2, 0, 0.8); + } + else + { + var previewSong:Null = daSongCapsule?.freeplayData?.data; + if (previewSong == null) return; + + // Check if character-specific difficulty exists + var songDifficulty:Null = previewSong.getDifficulty(currentDifficulty, currentVariation); + + var baseInstrumentalId:String = previewSong.getBaseInstrumentalId(currentDifficulty, songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = previewSong.listAltInstrumentalIds(currentDifficulty, + songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; + var instSuffix:String = baseInstrumentalId; + #if FEATURE_DEBUG_FUNCTIONS + if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL) + { + instSuffix = altInstrumentalIds[0]; + } + #end + instSuffix = (instSuffix != '') ? '-$instSuffix' : ''; + // trace('Attempting to play partial preview: ${previewSong.id}:${instSuffix}'); + FunkinSound.playMusic(previewSong.id, + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: false, + mapTimeChanges: false, // The music metadata is not alongside the audio file so this won't work. + pathsFunction: INST, + suffix: instSuffix, + partialParams: + { + loadPartial: true, + start: 0, + end: 0.2 + }, + onLoad: function() { + FlxG.sound.music.fadeIn(2, 0, 0.4); + } + }); + if (songDifficulty != null) + { + Conductor.instance.mapTimeChanges(songDifficulty.timeChanges); + Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); + } + } + } + + public function switchBackingImage(?freeplaySongData:FreeplaySongData):Void + { + var path = Paths.image('freeplay/freeplayBG${freeplaySongData?.levelId ?? 'week1'}-${currentCharacterId ?? 'bf'}'); + if (!Assets.exists(path)) path = Paths.image('freeplay/freeplayBGweek1-bf'); + backingImage.loadTextureAsync(path); + } + + /** + * Build an instance of `FreeplayState` that is above the `MainMenuState`. + * @return The MainMenuState with the FreeplayState as a substate. + */ + public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState + { + var result:MainMenuState; + result = new MainMenuState(true); + result.openSubState(new FreeplayState(params, stickers)); + result.persistentUpdate = false; + result.persistentDraw = true; + return result; + } +} + +/** + * The difficulty selector arrows to the left and right of the difficulty. + */ +class DifficultySelector extends FlxSprite +{ + var controls:Controls; + var whiteShader:PureColor; + + var parent:FreeplayState; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls, ?styleData:FreeplayStyle = null) + { + super(x, y); + + this.parent = parent; + this.controls = controls; + + frames = Paths.getSparrowAtlas(styleData == null ? 'freeplay/freeplaySelector' : styleData.getSelectorAssetKey()); + animation.addByPrefix('shine', 'arrow pointer loop', 24); + animation.play('shine'); + + whiteShader = new PureColor(FlxColor.WHITE); + + shader = whiteShader; + + flipX = flipped; + } + + override function update(elapsed:Float):Void + { + if (flipX && controls.UI_RIGHT_P && !parent.busy) moveShitDown(); + if (!flipX && controls.UI_LEFT_P && !parent.busy) moveShitDown(); + + super.update(elapsed); + } + + function moveShitDown():Void + { + offset.y -= 5; + + whiteShader.colorSet = true; + + scale.x = scale.y = 0.5; + + new FlxTimer().start(2 / 24, function(tmr) { + scale.x = scale.y = 1; + whiteShader.colorSet = false; + updateHitbox(); + }); + } +} + +/** + * Structure for the current song filter. + */ +typedef SongFilter = +{ + var filterType:FilterType; + var ?filterData:Dynamic; +} + +/** + * Possible types to use for the song filter. + */ +enum abstract FilterType(String) +{ + /** + * Filter to songs which start with a string + */ + public var STARTSWITH; + + /** + * Filter to songs which match a regular expression + */ + public var REGEXP; + + /** + * Filter to songs which are favorited + */ + public var FAVORITE; + + /** + * Filter to all songs + */ + public var ALL; +} + +/** + * Data about a specific song in the freeplay menu. + */ +class FreeplaySongData +{ + /** + * We used to have a billion fields, but this SongMetadata variable should be all we need + * to be able to get most information about an available song. + * For example, you can get the artist via `data.songArtist` + * + * You can usually get various other particulars of a specific difficulty/variation by + * using data.getDifficulty(), and inputting specifics on your difficulty, variations, etc. + * See the getters here for songCharacter, fullSongName, and songStartingBpm for examples. + * + * @see Song + */ + public var data:Song; + + /** + * The level id of the song, useful for sorting from week1 -> week 7 + weekend1 + * and for properly loading PlayStatePlaylist for preloading on web + */ + public var levelId(get, never):Null; + + function get_levelId():Null + { + return _levelId; + } + + var _levelId:String; + + /** + * Whether or not the song has been favorited. + */ + public var isFav:Bool = false; + + /** + * Whether the player has seen/played this song before within freeplay + */ + public var isNew(get, never):Bool; + + /** + * The default opponent for the song. + * Does the getter stuff for you depending on your current (or rather, rememberd) variation and difficulty. + */ + public var songCharacter(get, never):String; + + /** + * The full song name, dynamically generated depending on your current (or rather, rememberd) variation and difficulty. + */ + public var fullSongName(get, never):String; + + /** + * The starting BPM of the song, dynamically generated depending on your current (or rather, rememberd) variation and difficulty. + */ + public var songStartingBpm(get, never):Float; + + public var difficultyRating(get, never):Int; + + public var scoringRank(get, never):Null; + + public function new(data:Song, levelData:Level) + { + this.data = data; + _levelId = levelData.id; + this.isFav = Save.instance.isSongFavorited(data.songName); + } + + /** + * Toggle whether or not the song is favorited, then flush to save data. + * @return Whether or not the song is now favorited. + */ + public function toggleFavorite():Bool + { + isFav = !isFav; + if (isFav) + { + Save.instance.favoriteSong(data.songName); + } + else + { + Save.instance.unfavoriteSong(data.songName); + } + return isFav; + } + + function updateValues(variations:Array):Void + { + // this.isNew = song.isSongNew(suffixedDifficulty); + } + + function get_isNew():Bool + { + // We use a slightly different manner to get the new status of a song than the other getters here + // `isSongNew()` only takes a single variation, and it's data that isn't accessible via the Song data/metadata + // it's stored in the song .hxc script in a function that overrides `isSongNew()` + // and is only accessible with the correct valid variation inputs + + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + var variation:String = data.getFirstValidVariation(FreeplayState.rememberedDifficulty, null, variations); + return data.isSongNew(FreeplayState.rememberedDifficulty, variation); + } + + function get_songCharacter():String + { + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + return data.getDifficulty(FreeplayState.rememberedDifficulty, null, variations)?.characters.opponent ?? ''; + } + + function get_fullSongName():String + { + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + + return data.getDifficulty(FreeplayState.rememberedDifficulty, null, variations)?.songName ?? data.songName; + } + + function get_songStartingBpm():Float + { + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + + return data.getDifficulty(FreeplayState.rememberedDifficulty, null, variations)?.getStartingBPM() ?? 0; + } + + function get_difficultyRating():Int + { + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + return data.getDifficulty(FreeplayState.rememberedDifficulty, null, variations)?.difficultyRating ?? 0; + } + + function get_scoringRank():Null + { + var variations:Array = data.getVariationsByCharacterId(FreeplayState.rememberedCharacterId); + var variation:String = data.getFirstValidVariation(FreeplayState.rememberedDifficulty, null, variations); + + return Save.instance.getSongRank(data.id, FreeplayState.rememberedDifficulty, variation); + } +} + +/** + * Parameters used to initialize the FreeplayState. + */ +typedef FreeplayStateParams = +{ + ?character:String, + + ?fromCharSelect:Bool, + + ?fromResults:FromResultsParams, +}; + +/** + * A set of parameters for transitioning to the FreeplayState from the ResultsState. + */ +typedef FromResultsParams = +{ + /** + * The previous rank the song hand, if any. Null if it had no score before. + */ + var ?oldRank:ScoringRank; + + /** + * Whether or not to play the rank animation on returning to freeplay. + */ + var playRankAnim:Bool; + + /** + * The new rank the song has. + */ + var newRank:ScoringRank; + + /** + * The song ID to play the animation on. + */ + var songId:String; + + /** + * The difficulty ID to play the animation on. + */ + var difficultyId:String; +}; + +/** + * The map storing information about the exit movers. + */ +typedef ExitMoverData = Map, MoveData>; + +/** + * The data for an exit mover. + */ +typedef MoveData = +{ + var ?x:Float; + var ?y:Float; + var ?speed:Float; + var ?wait:Float; +} + +/** + * The sprite for the difficulty + */ +class DifficultySprite extends FlxSprite +{ + /** + * The difficulty id which this sprite represents. + */ + public var difficultyId:String; + + public function new(diffId:String) + { + super(); + + difficultyId = diffId; + + var assetDiffId:String = diffId; + while (!Assets.exists(Paths.image('freeplay/freeplay${assetDiffId}'))) + { + // Remove the last suffix of the difficulty id until we find an asset or there are no more suffixes. + var assetDiffIdParts:Array = assetDiffId.split('-'); + assetDiffIdParts.pop(); + if (assetDiffIdParts.length == 0) + { + trace('Could not find difficulty asset: freeplay/freeplay${diffId} (from ${diffId})'); + return; + }; + assetDiffId = assetDiffIdParts.join('-'); + } + + // Check for an XML to use an animation instead of an image. + if (Assets.exists(Paths.file('images/freeplay/freeplay${assetDiffId}.xml'))) + { + this.frames = Paths.getSparrowAtlas('freeplay/freeplay${assetDiffId}'); + this.animation.addByPrefix('idle', 'idle0', 24, true); + if (Preferences.flashingLights) this.animation.play('idle'); + } + else + { + this.loadGraphic(Paths.image('freeplay/freeplay' + assetDiffId)); + trace('Loaded difficulty asset: freeplay/freeplay${assetDiffId} (from ${diffId})'); + } + } +} diff --git a/source/funkin/ui/freeplay/FreeplayStyle.hx b/source/funkin/ui/freeplay/FreeplayStyle.hx new file mode 100644 index 00000000..aabefa64 --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayStyle.hx @@ -0,0 +1,120 @@ +package funkin.ui.freeplay; + +import funkin.data.freeplay.style.FreeplayStyleData; +import funkin.data.freeplay.style.FreeplayStyleRegistry; +import funkin.data.IRegistryEntry; +import flixel.graphics.FlxGraphic; +import flixel.util.FlxColor; + +/** + * A class representing the data for a style of the Freeplay menu. + */ +class FreeplayStyle implements IRegistryEntry +{ + /** + * The internal ID for this freeplay style. + */ + public final id:String; + + /** + * The full data for a freeplay style. + */ + public final _data:FreeplayStyleData; + + public function new(id:String) + { + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse album data for id: $id'; + } + } + + /** + * Get the background art as a graphic, ready to apply to a sprite. + * @return The built graphic + */ + public function getBgAssetGraphic():FlxGraphic + { + return FlxG.bitmap.add(Paths.image(getBgAssetKey())); + } + + /** + * Get the asset key for the background. + * @return The asset key + */ + public function getBgAssetKey():String + { + return _data.bgAsset; + } + + /** + * Get the asset key for the background. + * @return The asset key + */ + public function getSelectorAssetKey():String + { + return _data.selectorAsset; + } + + /** + * Get the asset key for the number assets. + * @return The asset key + */ + public function getCapsuleAssetKey():String + { + return _data.capsuleAsset; + } + + /** + * Get the asset key for the capsule art. + * @return The asset key + */ + public function getNumbersAssetKey():String + { + return _data.numbersAsset; + } + + /** + * Return the deselected color of the text outline + * for freeplay capsules. + * @return The deselected color + */ + public function getCapsuleDeselCol():FlxColor + { + return FlxColor.fromString(_data.capsuleTextColors[0]); + } + + /** + * Return the song selection transition delay. + * @return The start delay + */ + public function getStartDelay():Float + { + return _data.startDelay; + } + + public function toString():String + { + return 'Style($id)'; + } + + /** + * Return the selected color of the text outline + * for freeplay capsules. + * @return The selected color + */ + public function getCapsuleSelCol():FlxColor + { + return FlxColor.fromString(_data.capsuleTextColors[1]); + } + + public function destroy():Void {} + + static function _fetchData(id:String):Null + { + return FreeplayStyleRegistry.instance.parseEntryDataWithMigration(id, FreeplayStyleRegistry.instance.fetchEntryVersion(id)); + } +} diff --git a/source/funkin/ui/freeplay/LetterSort.hx b/source/funkin/ui/freeplay/LetterSort.hx new file mode 100644 index 00000000..54d65cb9 --- /dev/null +++ b/source/funkin/ui/freeplay/LetterSort.hx @@ -0,0 +1,244 @@ +package funkin.ui.freeplay; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.util.FlxTimer; +import funkin.input.Controls; +import funkin.graphics.adobeanimate.FlxAtlasSprite; + +class LetterSort extends FlxTypedSpriteGroup +{ + public var letters:Array = []; + + // starts at 2, cuz that's the middle letter on start (accounting for fav and #, it should begin at ALL filter) + var curSelection:Int = 2; + + public var changeSelectionCallback:String->Void; + + var leftArrow:FlxSprite; + var rightArrow:FlxSprite; + var grpSeperators:Array = []; + + public var inputEnabled:Bool = true; + + public function new(x, y) + { + super(x, y); + + leftArrow = new FlxSprite(-20, 15).loadGraphic(Paths.image("freeplay/miniArrow")); + // leftArrow.animation.play("arrow"); + leftArrow.flipX = true; + add(leftArrow); + + for (i in 0...5) + { + var letter:FreeplayLetter = new FreeplayLetter(i * 80, 0, i); + letter.x += 50; + letter.y += 50; + // letter.visible = false; + add(letter); + + letters.push(letter); + + if (i != 2) letter.scale.x = letter.scale.y = 0.8; + + var darkness:Float = Math.abs(i - 2) / 6; + + letter.color = letter.color.getDarkened(darkness); + + // don't put the last seperator + if (i == 4) continue; + + var sep:FlxSprite = new FlxSprite((i * 80) + 60, 20).loadGraphic(Paths.image("freeplay/seperator")); + // sep.animation.play("seperator"); + sep.color = letter.color.getDarkened(darkness); + add(sep); + + grpSeperators.push(sep); + } + + rightArrow = new FlxSprite(380, 15).loadGraphic(Paths.image("freeplay/miniArrow")); + + // rightArrow.animation.play("arrow"); + add(rightArrow); + + changeSelection(0); + } + + var controls(get, never):Controls; + + inline function get_controls():Controls + return PlayerSettings.player1.controls; + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (inputEnabled) + { + if (controls.FREEPLAY_LEFT) changeSelection(-1); + if (controls.FREEPLAY_RIGHT) changeSelection(1); + } + } + + public function changeSelection(diff:Int = 0):Void + { + doLetterChangeAnims(diff); + + var multiPosOrNeg:Float = diff > 0 ? 1 : -1; + + // if we're moving left (diff < 0), we want control of the right arrow, and vice versa + var arrowToMove:FlxSprite = diff < 0 ? leftArrow : rightArrow; + arrowToMove.offset.x = 3 * multiPosOrNeg; + + new FlxTimer().start(2 / 24, function(_) { + arrowToMove.offset.x = 0; + }); + } + + /** + * Buncho timers and stuff to move the letters and seperators + * Seperated out so we can call it again on letters with songs within them + * @param diff + */ + function doLetterChangeAnims(diff:Int):Void + { + var ezTimer:Int->FlxSprite->Float->Void = function(frameNum:Int, spr:FlxSprite, offsetNum:Float) { + new FlxTimer().start(frameNum / 24, function(_) { + spr.offset.x = offsetNum; + }); + }; + + var positions:Array = [-10, -22, 2, 0]; + + // if we're moving left, we want to move the positions the same amount, but negative direciton + var multiPosOrNeg:Float = diff > 0 ? 1 : -1; + + for (sep in grpSeperators) + { + ezTimer(0, sep, positions[0] * multiPosOrNeg); + ezTimer(1, sep, positions[1] * multiPosOrNeg); + ezTimer(2, sep, positions[2] * multiPosOrNeg); + ezTimer(3, sep, positions[3] * multiPosOrNeg); + } + + for (index => letter in letters) + { + letter.offset.x = positions[0] * multiPosOrNeg; + + new FlxTimer().start(1 / 24, function(_) { + letter.offset.x = positions[1] * multiPosOrNeg; + if (index == 0) letter.visible = false; + }); + + new FlxTimer().start(2 / 24, function(_) { + letter.offset.x = positions[2] * multiPosOrNeg; + if (index == 0.) letter.visible = true; + }); + + if (index == 2) + { + ezTimer(3, letter, 0); + // letter.offset.x = 0; + continue; + } + + ezTimer(3, letter, positions[3] * multiPosOrNeg); + } + + curSelection += diff; + if (curSelection < 0) curSelection = letters[0].regexLetters.length - 1; + if (curSelection >= letters[0].regexLetters.length) curSelection = 0; + + for (letter in letters) + letter.changeLetter(diff, curSelection); + + if (changeSelectionCallback != null) changeSelectionCallback(letters[2].regexLetters[letters[2].curLetter]); // bullshit and long lol! + } +} + +/** + * The actual FlxAtlasSprite for the letters, with their animation code stuff and regex stuff + */ +class FreeplayLetter extends FlxAtlasSprite +{ + /** + * A preformatted array of letter strings, for use when doing regex + * ex: ['A-B', 'C-D', 'E-H', 'I-L' ...] + */ + public var regexLetters:Array = []; + + /** + * A preformatted array of the letters, for use when accessing symbol animation info + * ex: ['AB', 'CD', 'EH', 'IL' ...] + */ + public var animLetters:Array = []; + + /** + * The current letter in the regexLetters array this FreeplayLetter is on + */ + public var curLetter:Int = 0; + + public function new(x:Float, y:Float, ?letterInd:Int) + { + super(x, y, Paths.animateAtlas("freeplay/sortedLetters")); + + // this is used for the regex + // /^[OR].*/gi doesn't work for showing the song Pico, so now it's + // /^[O-R].*/gi ant it works for displaying Pico + // https://regex101.com/r/bWFPfS/1 + // we split by underscores, simply for nice lil convinience + var alphabet:String = 'A-B_C-D_E-H_I-L_M-N_O-R_S_T_U-Z'; + regexLetters = alphabet.split('_'); + regexLetters.insert(0, 'ALL'); + regexLetters.insert(0, 'fav'); + regexLetters.insert(0, '#'); + + // the symbols from flash don't have dashes, so we clean this up for use with animations + // (we don't need to re-export, rule of thumb is to accomodate files named in flash from dave + // until we get him programming classes (and since i cant find the .fla file....)) + animLetters = regexLetters.map(animLetter -> animLetter.replace('-', '')); + + if (letterInd != null) + { + this.anim.play(animLetters[letterInd] + " move"); + this.anim.pause(); + curLetter = letterInd; + this.anim.onComplete.add(function() { + this.anim.play(animLetters[curLetter] + " move"); + }); + } + } + + /** + * Changes the letter graphic/anim, used in the LetterSort class above + * @param diff -1 or 1, to go left or right in the animation array + * @param curSelection what the current letter selection is, to play the bouncing anim if it matches the current letter + */ + public function changeLetter(diff:Int = 0, ?curSelection:Int):Void + { + curLetter += diff; + + if (curLetter < 0) curLetter = regexLetters.length - 1; + if (curLetter >= regexLetters.length) curLetter = 0; + + var animName:String = animLetters[curLetter] + ' move'; + + switch (animLetters[curLetter]) + { + case "IL": + animName = "IL move"; + case "s": + animName = "S move"; + case "t": + animName = "T move"; + } + + this.anim.play(animName, true); + if (curSelection != curLetter) + { + this.anim.pause(); + } + // updateHitbox(); + } +} diff --git a/source/funkin/ui/freeplay/ScriptedAlbum.hx b/source/funkin/ui/freeplay/ScriptedAlbum.hx new file mode 100644 index 00000000..737f97ad --- /dev/null +++ b/source/funkin/ui/freeplay/ScriptedAlbum.hx @@ -0,0 +1,9 @@ +package funkin.ui.freeplay; + +/** + * A script that can be tied to an Album. + * Create a scripted class that extends Album to use this. + * This allows you to customize how a specific album appears. + */ +@:hscriptClass +class ScriptedAlbum extends funkin.ui.freeplay.Album implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx b/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx new file mode 100644 index 00000000..b7013a6b --- /dev/null +++ b/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx @@ -0,0 +1,9 @@ +package funkin.ui.freeplay; + +/** + * A script that can be tied to a Freeplay style. + * Create a scripted class that extends FreeplayStyle to use this. + * This allows you to customize how a specific style works. + */ +@:hscriptClass +class ScriptedFreeplayStyle extends funkin.ui.freeplay.FreeplayStyle implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx new file mode 100644 index 00000000..1a73996e --- /dev/null +++ b/source/funkin/ui/freeplay/SongMenuItem.hx @@ -0,0 +1,882 @@ +package funkin.ui.freeplay; + +import funkin.ui.freeplay.FreeplayState.FreeplaySongData; +import funkin.graphics.shaders.HSVShader; +import funkin.graphics.shaders.GaussianBlurShader; +import flixel.group.FlxGroup; +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxPoint; +import flixel.util.FlxTimer; +import funkin.util.MathUtil; +import funkin.graphics.shaders.Grayscale; +import openfl.display.BlendMode; +import funkin.graphics.FunkinSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.addons.effects.FlxTrail; +import funkin.play.scoring.Scoring.ScoringRank; +import flixel.util.FlxColor; +import funkin.ui.PixelatedIcon; + +class SongMenuItem extends FlxSpriteGroup +{ + public var capsule:FlxSprite; + + var pixelIcon:PixelatedIcon; + + /** + * Modify this by calling `init()` + * If `null`, assume this SongMenuItem is for the "Random Song" option. + */ + public var freeplayData(default, null):Null = null; + + public var selected(default, set):Bool; + + public var songText:CapsuleText; + public var favIconBlurred:FlxSprite; + public var favIcon:FlxSprite; + + public var ranking:FreeplayRank; + + public var fakeRanking:FreeplayRank; + + var ranks:Array = ["fail", "average", "great", "excellent", "perfect", "perfectsick"]; + + public var targetPos:FlxPoint = new FlxPoint(); + public var doLerp:Bool = false; + public var doJumpIn:Bool = false; + + public var doJumpOut:Bool = false; + + public var onConfirm:Void->Void; + public var grayscaleShader:Grayscale; + + public var hsvShader(default, set):HSVShader; + + // var diffRatingSprite:FlxSprite; + public var bpmText:FlxSprite; + public var difficultyText:FlxSprite; + public var weekType:FlxSprite; + + public var newText:FlxSprite; + + // public var weekType:FlxSprite; + public var bigNumbers:Array = []; + + public var smallNumbers:Array = []; + + public var weekNumbers:Array = []; + + var impactThing:FunkinSprite; + + public var sparkle:FlxSprite; + + var sparkleTimer:FlxTimer; + + var index:Int; + public var curSelected:Int; + + public function new(x:Float, y:Float) + { + super(x, y); + + capsule = new FlxSprite(); + capsule.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/capsule/freeplayCapsule'); + capsule.animation.addByPrefix('selected', 'mp3 capsule w backing0', 24); + capsule.animation.addByPrefix('unselected', 'mp3 capsule w backing NOT SELECTED', 24); + // capsule.animation + add(capsule); + + bpmText = new FlxSprite(144, 87).loadGraphic(Paths.image('freeplay/freeplayCapsule/bpmtext')); + bpmText.setGraphicSize(Std.int(bpmText.width * 0.9)); + add(bpmText); + + difficultyText = new FlxSprite(414, 87).loadGraphic(Paths.image('freeplay/freeplayCapsule/difficultytext')); + difficultyText.setGraphicSize(Std.int(difficultyText.width * 0.9)); + add(difficultyText); + + weekType = new FlxSprite(291, 87); + weekType.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/weektypes'); + + weekType.animation.addByPrefix('WEEK', 'WEEK text instance 1', 24, false); + weekType.animation.addByPrefix('WEEKEND', 'WEEKEND text instance 1', 24, false); + + weekType.setGraphicSize(Std.int(weekType.width * 0.9)); + add(weekType); + + newText = new FlxSprite(454, 9); + newText.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/new'); + newText.animation.addByPrefix('newAnim', 'NEW notif', 24, true); + newText.animation.play('newAnim', true); + newText.setGraphicSize(Std.int(newText.width * 0.9)); + + // newText.visible = false; + + add(newText); + + // var debugNumber2:CapsuleNumber = new CapsuleNumber(0, 0, true, 2); + // add(debugNumber2); + + for (i in 0...2) + { + var bigNumber:CapsuleNumber = new CapsuleNumber(466 + (i * 30), 32, true, 0); + add(bigNumber); + + bigNumbers.push(bigNumber); + } + + for (i in 0...3) + { + var smallNumber:CapsuleNumber = new CapsuleNumber(185 + (i * 11), 88.5, false, 0); + add(smallNumber); + + smallNumbers.push(smallNumber); + } + + // doesn't get added, simply is here to help with visibility of things for the pop in! + grpHide = new FlxGroup(); + + fakeRanking = new FreeplayRank(400, 15); + add(fakeRanking); + + fakeRanking.visible = false; + + ranking = new FreeplayRank(400, 15); + add(ranking); + + sparkle = new FlxSprite(ranking.x, ranking.y); + sparkle.frames = Paths.getSparrowAtlas('freeplay/sparkle'); + sparkle.animation.addByPrefix('sparkle', 'sparkle Export0', 24, false); + sparkle.animation.play('sparkle', true); + sparkle.scale.set(0.8, 0.8); + sparkle.blend = BlendMode.ADD; + + sparkle.visible = false; + sparkle.alpha = 0.7; + + add(sparkle); + + // ranking.loadGraphic(Paths.image('freeplay/ranks/' + rank)); + // ranking.scale.x = ranking.scale.y = realScaled; + // ranking.alpha = 0.75; + // ranking.visible = false; + // ranking.origin.set(capsule.origin.x - ranking.x, capsule.origin.y - ranking.y); + // add(ranking); + // grpHide.add(ranking); + + // switch (rank) + // { + // case 'perfect': + // ranking.x -= 10; + // } + + grayscaleShader = new Grayscale(1); + + // diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image('freeplay/diffRatings/diff00')); + // diffRatingSprite.shader = grayscaleShader; + // diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y); + // TODO: Readd once ratings are fully implemented + // add(diffRatingSprite); + // grpHide.add(diffRatingSprite); + + songText = new CapsuleText(capsule.width * 0.26, 45, 'Random', Std.int(40 * realScaled)); + add(songText); + grpHide.add(songText); + + // TODO: Use value from metadata instead of random. + updateDifficultyRating(FlxG.random.int(0, 20)); + + pixelIcon = new PixelatedIcon(160, 35); + add(pixelIcon); + grpHide.add(pixelIcon); + + favIconBlurred = new FlxSprite(380, 40); + favIconBlurred.frames = Paths.getSparrowAtlas('freeplay/favHeart'); + favIconBlurred.animation.addByPrefix('fav', 'favorite heart', 24, false); + favIconBlurred.animation.play('fav'); + + favIconBlurred.setGraphicSize(50, 50); + favIconBlurred.blend = BlendMode.ADD; + favIconBlurred.shader = new GaussianBlurShader(1.2); + favIconBlurred.visible = false; + add(favIconBlurred); + + favIcon = new FlxSprite(favIconBlurred.x, favIconBlurred.y); + favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart'); + favIcon.animation.addByPrefix('fav', 'favorite heart', 24, false); + favIcon.animation.play('fav'); + favIcon.setGraphicSize(50, 50); + favIcon.visible = false; + favIcon.blend = BlendMode.ADD; + add(favIcon); + + var weekNumber:CapsuleNumber = new CapsuleNumber(355, 88.5, false, 0); + add(weekNumber); + + weekNumbers.push(weekNumber); + + setVisibleGrp(false); + } + + function sparkleEffect(timer:FlxTimer):Void + { + sparkle.setPosition(FlxG.random.float(ranking.x - 20, ranking.x + 3), FlxG.random.float(ranking.y - 29, ranking.y + 4)); + sparkle.animation.play('sparkle', true); + sparkleTimer = new FlxTimer().start(FlxG.random.float(1.2, 4.5), sparkleEffect); + } + + // no way to grab weeks rn, so this needs to be done :/ + // negative values mean weekends + function checkWeek(name:String):Void + { + // trace(name); + var weekNum:Int = 0; + switch (name) + { + case 'bopeebo' | 'fresh' | 'dadbattle': + weekNum = 1; + case 'spookeez' | 'south' | 'monster': + weekNum = 2; + case 'pico' | 'philly-nice' | 'blammed': + weekNum = 3; + case "satin-panties" | 'high' | 'milf': + weekNum = 4; + case "cocoa" | 'eggnog' | 'winter-horrorland': + weekNum = 5; + case 'senpai' | 'roses' | 'thorns': + weekNum = 6; + case 'ugh' | 'guns' | 'stress': + weekNum = 7; + case 'darnell' | 'lit-up' | '2hot' | 'blazin': + weekNum = -1; + default: + weekNum = 0; + } + + weekNumbers[0].digit = Std.int(Math.abs(weekNum)); + + if (weekNum == 0) + { + weekType.visible = false; + weekNumbers[0].visible = false; + } + else + { + weekType.visible = true; + weekNumbers[0].visible = true; + } + if (weekNum > 0) + { + weekType.animation.play('WEEK', true); + } + else + { + weekType.animation.play('WEEKEND', true); + weekNumbers[0].offset.x -= 35; + } + } + + /** + * Checks whether the song is favorited, and/or has a rank, and adjusts the clipping + * for the scenario when the text could be too long + */ + public function checkClip():Void + { + var clipSize:Int = 290; + var clipType:Int = 0; + + if (ranking.visible) + { + favIconBlurred.x = this.x + 370; + favIcon.x = favIconBlurred.x; + clipType += 1; + } + else + { + favIconBlurred.x = favIcon.x = this.x + 405; + } + + if (favIcon.visible) clipType += 1; + + switch (clipType) + { + case 2: + clipSize = 210; + case 1: + clipSize = 245; + } + songText.clipWidth = clipSize; + } + + function updateBPM(newBPM:Int):Void + { + var shiftX:Float = 191; + var tempShift:Float = 0; + + if (Math.floor(newBPM / 100) == 1) + { + shiftX = 186; + } + + for (i in 0...smallNumbers.length) + { + smallNumbers[i].x = this.x + (shiftX + (i * 11)); + switch (i) + { + case 0: + if (newBPM < 100) + { + smallNumbers[i].digit = 0; + } + else + { + smallNumbers[i].digit = Math.floor(newBPM / 100) % 10; + } + + case 1: + if (newBPM < 10) + { + smallNumbers[i].digit = 0; + } + else + { + smallNumbers[i].digit = Math.floor(newBPM / 10) % 10; + + if (Math.floor(newBPM / 10) % 10 == 1) tempShift = -4; + } + case 2: + smallNumbers[i].digit = newBPM % 10; + default: + trace('why the fuck is this being called'); + } + smallNumbers[i].x += tempShift; + } + // diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}')); + // diffRatingSprite.visible = false; + } + + var evilTrail:FlxTrail; + + public function fadeAnim():Void + { + impactThing = new FunkinSprite(0, 0); + impactThing.frames = capsule.frames; + impactThing.frame = capsule.frame; + impactThing.updateHitbox(); + // impactThing.x = capsule.x; + // impactThing.y = capsule.y; + // picoFade.stamp(this, 0, 0); + impactThing.alpha = 0; + impactThing.zIndex = capsule.zIndex - 3; + add(impactThing); + FlxTween.tween(impactThing.scale, {x: 2.5, y: 2.5}, 0.5); + // FlxTween.tween(impactThing, {alpha: 0}, 0.5); + + evilTrail = new FlxTrail(impactThing, null, 15, 2, 0.01, 0.069); + evilTrail.blend = BlendMode.ADD; + evilTrail.zIndex = capsule.zIndex - 5; + FlxTween.tween(evilTrail, {alpha: 0}, 0.6, + { + ease: FlxEase.quadOut, + onComplete: function(_) { + remove(evilTrail); + } + }); + add(evilTrail); + + evilTrail.color = ranking.rank.getRankingFreeplayColor(); + } + + public function getTrailColor():FlxColor + { + return evilTrail.color; + } + + public function refreshDisplay(updateRank:Bool = true):Void + { + if (freeplayData == null) + { + songText.text = 'Random'; + pixelIcon.visible = false; + ranking.visible = false; + favIcon.visible = false; + favIconBlurred.visible = false; + newText.visible = false; + } + else + { + songText.text = freeplayData.fullSongName; + if (freeplayData.songCharacter != null) pixelIcon.setCharacter(freeplayData.songCharacter); + pixelIcon.visible = true; + updateBPM(Std.int(freeplayData.songStartingBpm) ?? 0); + updateDifficultyRating(freeplayData.difficultyRating ?? 0); + if (updateRank) updateScoringRank(freeplayData.scoringRank); + newText.visible = freeplayData.isNew; + favIcon.visible = freeplayData.isFav; + favIconBlurred.visible = freeplayData.isFav; + checkClip(); + } + updateSelected(); + } + + function updateDifficultyRating(newRating:Int):Void + { + var ratingPadded:String = newRating < 10 ? '0$newRating' : '$newRating'; + + for (i in 0...bigNumbers.length) + { + switch (i) + { + case 0: + if (newRating < 10) + { + bigNumbers[i].digit = 0; + } + else + { + bigNumbers[i].digit = Math.floor(newRating / 10); + } + case 1: + bigNumbers[i].digit = newRating % 10; + default: + trace('why the fuck is this being called'); + } + } + // diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}')); + // diffRatingSprite.visible = false; + } + + function updateScoringRank(newRank:Null):Void + { + if (sparkleTimer != null) sparkleTimer.cancel(); + sparkle.visible = false; + + this.ranking.rank = newRank; + + if (newRank == PERFECT_GOLD) + { + sparkleTimer = new FlxTimer().start(1, sparkleEffect); + sparkle.visible = true; + } + } + + function set_hsvShader(value:HSVShader):HSVShader + { + this.hsvShader = value; + capsule.shader = hsvShader; + songText.shader = hsvShader; + + return value; + } + + function textAppear():Void + { + songText.scale.x = 1.7; + songText.scale.y = 0.2; + + new FlxTimer().start(1 / 24, function(_) { + songText.scale.x = 0.4; + songText.scale.y = 1.4; + }); + + new FlxTimer().start(2 / 24, function(_) { + songText.scale.x = songText.scale.y = 1; + }); + } + + function setVisibleGrp(value:Bool):Void + { + for (spr in grpHide.members) + { + spr.visible = value; + } + + textAppear(); + + updateSelected(); + } + + public function initPosition(x:Float, y:Float):Void + { + this.x = x; + this.y = y; + } + + public function initData(freeplayData:Null, ?styleData:FreeplayStyle = null, index:Int = null):Void + { + this.freeplayData = freeplayData; + + if (index != null) this.index = index; + + // im so mad i have to do this but im pretty sure with the capsules recycling i cant call the new function properly :/ + // if thats possible someone Please change the new function to be something like + // capsule.frames = Paths.getSparrowAtlas(styleData == null ? 'freeplay/freeplayCapsule/capsule/freeplayCapsule' : styleData.getCapsuleAssetKey()); thank u luv u + if (styleData != null) + { + capsule.frames = Paths.getSparrowAtlas(styleData.getCapsuleAssetKey()); + capsule.animation.addByPrefix('selected', 'mp3 capsule w backing0', 24); + capsule.animation.addByPrefix('unselected', 'mp3 capsule w backing NOT SELECTED', 24); + songText.applyStyle(styleData); + } + + updateScoringRank(freeplayData?.scoringRank); + favIcon.animation.curAnim.curFrame = favIcon.animation.curAnim.numFrames - 1; + favIconBlurred.animation.curAnim.curFrame = favIconBlurred.animation.curAnim.numFrames - 1; + + refreshDisplay(); + + checkWeek(freeplayData?.data.id); + } + + var frameInTicker:Float = 0; + var frameInTypeBeat:Int = 0; + + var frameOutTicker:Float = 0; + var frameOutTypeBeat:Int = 0; + + var xFrames:Array = [1.7, 1.8, 0.85, 0.85, 0.97, 0.97, 1]; + var xPosLerpLol:Array = [0, 0, 0.16, 0.16, 0.22, 0.22, 0.245]; // NUMBERS ARE JANK CUZ THE SCALING OR WHATEVER + var xPosOutLerpLol:Array = [0.245, 0.75, 0.98, 0.98, 1.2]; // NUMBERS ARE JANK CUZ THE SCALING OR WHATEVER + + public var realScaled:Float = 0.8; + + public function initJumpIn(maxTimer:Float, ?force:Bool):Void + { + frameInTypeBeat = 0; + + new FlxTimer().start((1 / 24) * maxTimer, function(doShit) { + doJumpIn = true; + doLerp = true; + }); + + if (force) + { + visible = true; + capsule.alpha = 1; + setVisibleGrp(true); + } + else + { + new FlxTimer().start((xFrames.length / 24) * 2.5, function(_) { + visible = true; + capsule.alpha = 1; + setVisibleGrp(true); + }); + } + } + + var grpHide:FlxGroup; + + public function forcePosition():Void + { + visible = true; + capsule.alpha = 1; + updateSelected(); + doLerp = true; + doJumpIn = false; + doJumpOut = false; + + frameInTypeBeat = xFrames.length; + frameOutTypeBeat = 0; + + capsule.scale.x = xFrames[frameInTypeBeat - 1]; + capsule.scale.y = 1 / xFrames[frameInTypeBeat - 1]; + // x = FlxG.width * xPosLerpLol[Std.int(Math.min(frameInTypeBeat - 1, xPosLerpLol.length - 1))]; + + x = targetPos.x; + y = targetPos.y; + + capsule.scale.x *= realScaled; + capsule.scale.y *= realScaled; + + setVisibleGrp(true); + } + + override function update(elapsed:Float):Void + { + if (impactThing != null) impactThing.angle = capsule.angle; + + if (doJumpIn) + { + frameInTicker += elapsed; + + if (frameInTicker >= 1 / 24 && frameInTypeBeat < xFrames.length) + { + frameInTicker = 0; + + capsule.scale.x = xFrames[frameInTypeBeat]; + capsule.scale.y = 1 / xFrames[frameInTypeBeat]; + targetPos.x = FlxG.width * xPosLerpLol[Std.int(Math.min(frameInTypeBeat, xPosLerpLol.length - 1))]; + + capsule.scale.x *= realScaled; + capsule.scale.y *= realScaled; + + frameInTypeBeat += 1; + // Move the targetPos set to the if statement below if you want them to shift to their target positions after jumping in instead + // I have no idea why this if instead of frameInTypeBeat == xFrames.length works even though they're the same thing + if (targetPos.x <= 320) targetPos.x = intendedX(index - curSelected); + } + else if (frameInTypeBeat == xFrames.length) + { + doJumpIn = false; + } + } + + if (doJumpOut) + { + frameOutTicker += elapsed; + + if (frameOutTicker >= 1 / 24 && frameOutTypeBeat < xFrames.length) + { + frameOutTicker = 0; + + capsule.scale.x = xFrames[frameOutTypeBeat]; + capsule.scale.y = 1 / xFrames[frameOutTypeBeat]; + this.x = FlxG.width * xPosOutLerpLol[Std.int(Math.min(frameOutTypeBeat, xPosOutLerpLol.length - 1))]; + + capsule.scale.x *= realScaled; + capsule.scale.y *= realScaled; + + frameOutTypeBeat += 1; + } + else if (frameOutTypeBeat == xFrames.length) + { + doJumpOut = false; + } + } + + if (doLerp) + { + x = MathUtil.coolLerp(x, targetPos.x, 0.3); + y = MathUtil.coolLerp(y, targetPos.y, 0.4); + } + + super.update(elapsed); + } + + /** + * Play any animations associated with selecting this song. + */ + public function confirm():Void + { + if (songText != null) songText.flickerText(); + if (pixelIcon != null && pixelIcon.visible) + { + pixelIcon.animation.play('confirm'); + } + } + + public function intendedX(index:Int):Float + { + return 270 + (60 * (Math.sin(index))); + } + + public function intendedY(index:Int):Float + { + return (index * ((height * realScaled) + 10)) + 120; + } + + function set_selected(value:Bool):Bool + { + // cute one liners, lol! + selected = value; + updateSelected(); + return selected; + } + + function updateSelected():Void + { + grayscaleShader.setAmount(this.selected ? 0 : 0.8); + songText.alpha = this.selected ? 1 : 0.6; + songText.blurredText.visible = this.selected ? true : false; + capsule.offset.x = this.selected ? 0 : -5; + capsule.animation.play(this.selected ? "selected" : "unselected"); + ranking.alpha = this.selected ? 1 : 0.7; + favIcon.alpha = this.selected ? 1 : 0.6; + favIconBlurred.alpha = this.selected ? 1 : 0; + ranking.color = this.selected ? 0xFFFFFFFF : 0xFFAAAAAA; + + if (songText.tooLong) songText.resetText(); + + if (selected && songText.tooLong) songText.initMove(); + } + + public override function kill():Void + { + super.kill(); + + visible = true; + capsule.alpha = 1; + doLerp = false; + doJumpIn = false; + doJumpOut = false; + } +} + +/** + * Holds blurred and unblurred versions of the rank icon + */ +class FreeplayRank extends FlxSpriteGroup +{ + public var rank(default, set):Null = null; + + var spr:FlxSprite; + var blur:FlxSprite; + + function set_rank(val:Null):Null + { + rank = val; + + if (rank == null || val == null) + { + this.visible = false; + } + else + { + this.visible = true; + + spr.animation.play(val.getFreeplayRankIconAsset(), true, false); + blur.animation.play(val.getFreeplayRankIconAsset(), true, false); + + centerOffsets(false); + + switch (val) + { + case SHIT: + // offset.x -= 1; + case GOOD: + // offset.x -= 1; + offset.y -= 8; + case GREAT: + // offset.x -= 1; + offset.y -= 8; + case EXCELLENT: + // offset.y += 5; + case PERFECT: + // offset.y += 5; + case PERFECT_GOLD: + // offset.y += 5; + default: + centerOffsets(false); + this.visible = false; + } + updateHitbox(); + } + + return rank = val; + } + + public var baseX:Float = 0; + public var baseY:Float = 0; + + public function new(x:Float, y:Float) + { + super(x, y); + + blur = new FlxSprite(); + blur.frames = Paths.getSparrowAtlas('freeplay/rankbadges'); + blur.shader = new GaussianBlurShader(1); + add(blur); + + spr = new FlxSprite(); + spr.frames = Paths.getSparrowAtlas('freeplay/rankbadges'); + add(spr); + + for (i in members) + { + i.animation.addByPrefix('PERFECT', 'PERFECT rank0', 24, false); + i.animation.addByPrefix('EXCELLENT', 'EXCELLENT rank0', 24, false); + i.animation.addByPrefix('GOOD', 'GOOD rank0', 24, false); + i.animation.addByPrefix('PERFECTSICK', 'PERFECT rank GOLD', 24, false); + i.animation.addByPrefix('GREAT', 'GREAT rank0', 24, false); + i.animation.addByPrefix('LOSS', 'LOSS rank0', 24, false); + } + + blend = BlendMode.ADD; + + this.rank = null; + + // setGraphicSize(Std.int(width * 0.9)); + scale.set(0.9, 0.9); + updateHitbox(); + } + + /** + * Plays an animation for each member of the group + * Just passes the arguments to `animation.play`, since that's not available in FlxGroups + * @param animName The name of the animation to play + * @param force false + * @param reversed false + * @param frame 0 + */ + public function playAnimationEach(animName:String, force = false, reversed = false, frame = 0):Void + { + for (i in members) + { + i.animation.play(animName, force, reversed, frame); + } + } +} + +class CapsuleNumber extends FlxSprite +{ + public var digit(default, set):Int = 0; + + function set_digit(val):Int + { + animation.play(numToString[val], true, false, 0); + + centerOffsets(false); + + switch (val) + { + case 1: + offset.x -= 4; + case 3: + offset.x -= 1; + + case 6: + + case 4: + // offset.y += 5; + case 9: + // offset.y += 5; + default: + centerOffsets(false); + } + return val; + } + + public var baseY:Float = 0; + public var baseX:Float = 0; + + var numToString:Array = ["ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE"]; + + public function new(x:Float, y:Float, big:Bool = false, ?initDigit:Int = 0) + { + super(x, y); + + if (big) + { + frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/bignumbers'); + } + else + { + frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/smallnumbers'); + } + + for (i in 0...10) + { + var stringNum:String = numToString[i]; + animation.addByPrefix(stringNum, '$stringNum', 24, false); + } + + this.digit = initDigit; + + animation.play(numToString[initDigit], true); + + setGraphicSize(Std.int(width * 0.9)); + updateHitbox(); + } +} diff --git a/source/funkin/ui/freeplay/backcards/BackingCard.hx b/source/funkin/ui/freeplay/backcards/BackingCard.hx new file mode 100644 index 00000000..a071a56f --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/BackingCard.hx @@ -0,0 +1,239 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.FlxSprite; +import flixel.math.FlxPoint; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxSpriteUtil; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import openfl.display.BlendMode; +import flixel.group.FlxSpriteGroup; + +/** + * A class for the backing cards so they dont have to be part of freeplayState...... + */ +class BackingCard extends FlxSpriteGroup +{ + public var backingTextYeah:FlxAtlasSprite; + public var orangeBackShit:FunkinSprite; + public var alsoOrangeLOL:FunkinSprite; + public var pinkBack:FunkinSprite; + public var confirmGlow:FlxSprite; + public var confirmGlow2:FlxSprite; + public var confirmTextGlow:FlxSprite; + public var cardGlow:FlxSprite; + + var _exitMovers:Null; + var _exitMoversCharSel:Null; + + public var instance:FreeplayState; + + public function new(currentCharacter:PlayableCharacter, ?_instance:FreeplayState) + { + super(); + + if (_instance != null) instance = _instance; + + cardGlow = new FlxSprite(-30, -30).loadGraphic(Paths.image('freeplay/cardGlow')); + confirmGlow = new FlxSprite(-30, 240).loadGraphic(Paths.image('freeplay/confirmGlow')); + confirmTextGlow = new FlxSprite(-8, 115).loadGraphic(Paths.image('freeplay/glowingText')); + pinkBack = FunkinSprite.create('freeplay/pinkBack'); + orangeBackShit = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00); + alsoOrangeLOL = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400); + confirmGlow2 = new FlxSprite(confirmGlow.x, confirmGlow.y).loadGraphic(Paths.image('freeplay/confirmGlow2')); + backingTextYeah = new FlxAtlasSprite(640, 370, Paths.animateAtlas("freeplay/backing-text-yeah"), + { + FrameRate: 24.0, + Reversed: false, + // ?OnComplete:Void -> Void, + ShowPivot: false, + Antialiasing: true, + ScrollFactor: new FlxPoint(1, 1), + }); + + pinkBack.color = 0xFFFFD4E9; // sets it to pink! + pinkBack.x -= pinkBack.width; + } + + /** + * Apply exit movers for the pieces of the backing card. + * @param exitMovers The exit movers to apply. + */ + public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + if (exitMovers == null) + { + exitMovers = _exitMovers; + } + else + { + _exitMovers = exitMovers; + } + + if (exitMovers == null) return; + + if (exitMoversCharSel == null) + { + exitMoversCharSel = _exitMoversCharSel; + } + else + { + _exitMoversCharSel = exitMoversCharSel; + } + + if (exitMoversCharSel == null) return; + + exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL], + { + x: -pinkBack.width, + y: pinkBack.y, + speed: 0.4, + wait: 0 + }); + + exitMoversCharSel.set([pinkBack], + { + y: -100, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([orangeBackShit, alsoOrangeLOL], + { + y: -40, + speed: 0.8, + wait: 0.1 + }); + } + + /** + * Helper function to snap the back of the card to its final position. + * Used when returning from character select, as we dont want to play the full animation of everything sliding in. + */ + public function skipIntroTween():Void + { + FlxTween.cancelTweensOf(pinkBack); + pinkBack.x = 0; + } + + /** + * Called in create. Adds sprites and tweens. + */ + public function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + add(orangeBackShit); + + add(alsoOrangeLOL); + + FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + add(confirmGlow2); + add(confirmGlow); + + add(confirmTextGlow); + + add(backingTextYeah); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + add(cardGlow); + } + + /** + * Called after the dj finishes their start animation. + */ + public function introDone():Void + { + pinkBack.color = 0xFFFFD863; + orangeBackShit.visible = true; + alsoOrangeLOL.visible = true; + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + /** + * Called when selecting a song. + */ + public function confirm():Void + { + FlxTween.color(pinkBack, 0.33, 0xFFFFD0D5, 0xFF171831, {ease: FlxEase.quadOut}); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmGlow.visible = true; + confirmGlow2.visible = true; + + backingTextYeah.anim.play(""); + confirmGlow2.alpha = 0; + confirmGlow.alpha = 0; + + FlxTween.color(instance.backingImage, 0.5, 0xFFA8A8A8, 0xFF646464, + { + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + FlxTween.tween(confirmGlow2, {alpha: 0.5}, 0.33, + { + ease: FlxEase.quadOut, + onComplete: function(_) { + confirmGlow2.alpha = 0.6; + confirmGlow.alpha = 1; + confirmTextGlow.visible = true; + confirmTextGlow.alpha = 1; + FlxTween.tween(confirmTextGlow, {alpha: 0.4}, 0.5); + FlxTween.tween(confirmGlow, {alpha: 0}, 0.5); + FlxTween.color(instance.backingImage, 2, 0xFFCDCDCD, 0xFF555555, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + } + }); + } + + /** + * Called when entering character select, does nothing by default. + */ + public function enterCharSel():Void {} + + /** + * Called on each beat in freeplay state. + */ + public function beatHit():Void {} + + /** + * Called when exiting the freeplay menu. + */ + public function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFFFFD863, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + } +} diff --git a/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx b/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx new file mode 100644 index 00000000..fab4b081 --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx @@ -0,0 +1,225 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.FlxSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxSpriteUtil; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import openfl.display.BlendMode; + +class BoyfriendCard extends BackingCard +{ + public var moreWays:BGScrollingText; + public var funnyScroll:BGScrollingText; + public var txtNuts:BGScrollingText; + public var funnyScroll2:BGScrollingText; + public var moreWays2:BGScrollingText; + public var funnyScroll3:BGScrollingText; + + var glow:FlxSprite; + var glowDark:FlxSprite; + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + exitMovers.set([moreWays], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([funnyScroll], + { + x: -funnyScroll.width * 2, + y: funnyScroll.y, + speed: 0.4, + wait: 0 + }); + exitMovers.set([txtNuts], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([funnyScroll2], + { + x: -funnyScroll2.width * 2, + speed: 0.5, + }); + exitMovers.set([moreWays2], + { + x: FlxG.width * 2, + speed: 0.4 + }); + exitMovers.set([funnyScroll3], + { + x: -funnyScroll3.width * 2, + speed: 0.3 + }); + + exitMoversCharSel.set([moreWays, funnyScroll, txtNuts, funnyScroll2, moreWays2, funnyScroll3], + { + y: -60, + speed: 0.8, + wait: 0.1 + }); + } + + public override function enterCharSel():Void + { + FlxTween.tween(funnyScroll, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(funnyScroll2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(moreWays, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(moreWays2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(txtNuts, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(funnyScroll3, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function new(currentCharacter:PlayableCharacter) + { + super(currentCharacter); + + funnyScroll = new BGScrollingText(0, 220, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, false, 60); + funnyScroll2 = new BGScrollingText(0, 335, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, false, 60); + moreWays = new BGScrollingText(0, 160, currentCharacter.getFreeplayDJText(2), FlxG.width, true, 43); + moreWays2 = new BGScrollingText(0, 397, currentCharacter.getFreeplayDJText(2), FlxG.width, true, 43); + txtNuts = new BGScrollingText(0, 285, currentCharacter.getFreeplayDJText(3), FlxG.width / 2, true, 43); + funnyScroll3 = new BGScrollingText(0, orangeBackShit.y + 10, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, 60); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + add(orangeBackShit); + + add(alsoOrangeLOL); + + FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + add(confirmGlow2); + add(confirmGlow); + + add(confirmTextGlow); + + add(backingTextYeah); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + + moreWays.funnyColor = 0xFFFFF383; + moreWays.speed = 6.8; + add(moreWays); + + funnyScroll.funnyColor = 0xFFFF9963; + funnyScroll.speed = -3.8; + add(funnyScroll); + + txtNuts.speed = 3.5; + add(txtNuts); + + funnyScroll2.funnyColor = 0xFFFF9963; + funnyScroll2.speed = -3.8; + add(funnyScroll2); + + moreWays2.funnyColor = 0xFFFFF383; + moreWays2.speed = 6.8; + add(moreWays2); + + funnyScroll3.funnyColor = 0xFFFEA400; + funnyScroll3.speed = -3.8; + add(funnyScroll3); + + glowDark = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/beatglow')); + glowDark.blend = BlendMode.MULTIPLY; + add(glowDark); + + glow = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/beatglow')); + glow.blend = BlendMode.ADD; + add(glow); + + glowDark.visible = false; + glow.visible = false; + + add(cardGlow); + } + + var beatFreq:Int = 1; + var beatFreqList:Array = [1, 2, 4, 8]; + + public override function beatHit():Void + { + // increases the amount of beats that need to go by to pulse the glow because itd flash like craazy at high bpms..... + beatFreq = beatFreqList[Math.floor(Conductor.instance.bpm / 140)]; + + if (Conductor.instance.currentBeat % beatFreq != 0) return; + FlxTween.cancelTweensOf(glow); + FlxTween.cancelTweensOf(glowDark); + + glow.alpha = 0.8; + FlxTween.tween(glow, {alpha: 0}, 16 / 24, {ease: FlxEase.quartOut}); + glowDark.alpha = 0; + FlxTween.tween(glowDark, {alpha: 0.6}, 18 / 24, {ease: FlxEase.quartOut}); + } + + public override function introDone():Void + { + super.introDone(); + moreWays.visible = true; + funnyScroll.visible = true; + txtNuts.visible = true; + funnyScroll2.visible = true; + moreWays2.visible = true; + funnyScroll3.visible = true; + // grpTxtScrolls.visible = true; + glowDark.visible = true; + glow.visible = true; + } + + public override function confirm():Void + { + super.confirm(); + // FlxTween.color(bgDad, 0.33, 0xFFFFFFFF, 0xFF555555, {ease: FlxEase.quadOut}); + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + glowDark.visible = false; + glow.visible = false; + } + + public override function disappear():Void + { + super.disappear(); + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + glowDark.visible = false; + glow.visible = false; + } +} diff --git a/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx b/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx new file mode 100644 index 00000000..68df8e6c --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx @@ -0,0 +1,260 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.math.FlxMath; +import flixel.FlxSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import openfl.display.BlendMode; + +class NewCharacterCard extends BackingCard +{ + var confirmAtlas:FlxAtlasSprite; + + var darkBg:FlxSprite; + var lightLayer:FlxSprite; + var multiply1:FlxSprite; + var multiply2:FlxSprite; + var lightLayer2:FlxSprite; + var lightLayer3:FlxSprite; + var yellow:FlxSprite; + var multiplyBar:FlxSprite; + + var bruh:FlxSprite; + + public var friendFoe:BGScrollingText; + public var newUnlock1:BGScrollingText; + public var waiting:BGScrollingText; + public var newUnlock2:BGScrollingText; + public var friendFoe2:BGScrollingText; + public var newUnlock3:BGScrollingText; + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + exitMovers.set([friendFoe], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([newUnlock1], + { + x: -newUnlock1.width * 2, + y: newUnlock1.y, + speed: 0.4, + wait: 0 + }); + exitMovers.set([waiting], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([newUnlock2], + { + x: -newUnlock2.width * 2, + speed: 0.5, + }); + exitMovers.set([friendFoe2], + { + x: FlxG.width * 2, + speed: 0.4 + }); + exitMovers.set([newUnlock3], + { + x: -newUnlock3.width * 2, + speed: 0.3 + }); + + exitMoversCharSel.set([friendFoe, newUnlock1, waiting, newUnlock2, friendFoe2, newUnlock3, multiplyBar], { + y: -60, + speed: 0.8, + wait: 0.1 + }); + } + + public override function introDone():Void + { + // pinkBack.color = 0xFFFFD863; + + darkBg.visible = true; + friendFoe.visible = true; + newUnlock1.visible = true; + waiting.visible = true; + newUnlock2.visible = true; + friendFoe2.visible = true; + newUnlock3.visible = true; + multiplyBar.visible = true; + lightLayer.visible = true; + multiply1.visible = true; + multiply2.visible = true; + lightLayer2.visible = true; + yellow.visible = true; + lightLayer3.visible = true; + + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + public override function enterCharSel():Void + { + FlxTween.tween(friendFoe, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock1, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(waiting, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(friendFoe2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock3, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + friendFoe = new BGScrollingText(0, 163, "COULD IT BE A NEW FRIEND? OR FOE??", FlxG.width, true, 43); + newUnlock1 = new BGScrollingText(-440, 215, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + waiting = new BGScrollingText(0, 286, "SOMEONE'S WAITING!", FlxG.width / 2, true, 43); + newUnlock2 = new BGScrollingText(-220, 331, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + friendFoe2 = new BGScrollingText(0, 402, 'COULD IT BE A NEW FRIEND? OR FOE??', FlxG.width, true, 43); + newUnlock3 = new BGScrollingText(0, 458, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + + darkBg = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/darkback')); + add(darkBg); + + friendFoe.funnyColor = 0xFF139376; + friendFoe.speed = -4; + add(friendFoe); + + newUnlock1.funnyColor = 0xFF99BDF2; + newUnlock1.speed = 2; + add(newUnlock1); + + waiting.funnyColor = 0xFF40EA84; + waiting.speed = -2; + add(waiting); + + newUnlock2.funnyColor = 0xFF99BDF2; + newUnlock2.speed = 2; + add(newUnlock2); + + friendFoe2.funnyColor = 0xFF139376; + friendFoe2.speed = -4; + add(friendFoe2); + + newUnlock3.funnyColor = 0xFF99BDF2; + newUnlock3.speed = 2; + add(newUnlock3); + + multiplyBar = new FlxSprite(-10, 440).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/multiplyBar')); + multiplyBar.blend = BlendMode.MULTIPLY; + add(multiplyBar); + + lightLayer = new FlxSprite(-360, 230).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/orange gradient')); + lightLayer.blend = BlendMode.ADD; + add(lightLayer); + + multiply1 = new FlxSprite(-15, -125).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red')); + multiply1.blend = BlendMode.MULTIPLY; + add(multiply1); + + multiply2 = new FlxSprite(-15, -125).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red')); + multiply2.blend = BlendMode.MULTIPLY; + add(multiply2); + + lightLayer2 = new FlxSprite(-360, 230).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/orange gradient')); + lightLayer2.blend = BlendMode.ADD; + add(lightLayer2); + + yellow = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/yellow bg piece')); + yellow.blend = BlendMode.MULTIPLY; + add(yellow); + + lightLayer3 = new FlxSprite(-360, 290).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red gradient')); + lightLayer3.blend = BlendMode.ADD; + add(lightLayer3); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + add(cardGlow); + + darkBg.visible = false; + friendFoe.visible = false; + newUnlock1.visible = false; + waiting.visible = false; + newUnlock2.visible = false; + friendFoe2.visible = false; + newUnlock3.visible = false; + multiplyBar.visible = false; + lightLayer.visible = false; + multiply1.visible = false; + multiply2.visible = false; + lightLayer2.visible = false; + yellow.visible = false; + lightLayer3.visible = false; + } + + var _timer:Float = 0; + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + _timer += elapsed * 2; + var sinTest:Float = (Math.sin(_timer) + 1) / 2; + lightLayer.alpha = FlxMath.lerp(0.4, 1, sinTest); + lightLayer2.alpha = FlxMath.lerp(0.2, 0.5, sinTest); + lightLayer3.alpha = FlxMath.lerp(0.1, 0.7, sinTest); + + multiply1.alpha = FlxMath.lerp(1, 0.21, sinTest); + multiply2.alpha = FlxMath.lerp(1, 0.21, sinTest); + + yellow.alpha = FlxMath.lerp(0.2, 0.72, sinTest); + + if (instance != null) + { + instance.angleMaskShader.extraColor = FlxColor.interpolate(0xFF2E2E46, 0xFF60607B, sinTest); + } + } + + public override function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFF05020E, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + darkBg.visible = false; + friendFoe.visible = false; + newUnlock1.visible = false; + waiting.visible = false; + newUnlock2.visible = false; + friendFoe2.visible = false; + newUnlock3.visible = false; + multiplyBar.visible = false; + lightLayer.visible = false; + multiply1.visible = false; + multiply2.visible = false; + lightLayer2.visible = false; + yellow.visible = false; + lightLayer3.visible = false; + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + } + + override public function confirm():Void + { + // confirmAtlas.visible = true; + // confirmAtlas.anim.play(""); + } +} diff --git a/source/funkin/ui/freeplay/backcards/PicoCard.hx b/source/funkin/ui/freeplay/backcards/PicoCard.hx new file mode 100644 index 00000000..429b7b19 --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/PicoCard.hx @@ -0,0 +1,298 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.math.FlxMath; +import flixel.FlxSprite; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import openfl.display.BlendMode; +import flixel.addons.display.FlxBackdrop; + +class PicoCard extends BackingCard +{ + var scrollBack:FlxBackdrop; + var scrollLower:FlxBackdrop; + var scrollTop:FlxBackdrop; + var scrollMiddle:FlxBackdrop; + + var glow:FlxSprite; + var glowDark:FlxSprite; + var blueBar:FlxSprite; + + var confirmAtlas:FlxAtlasSprite; + + public override function enterCharSel():Void + { + FlxTween.tween(scrollBack.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollLower.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollTop.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollMiddle.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + + exitMoversCharSel.set([scrollTop], + { + y: -90, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollMiddle], + { + y: -80, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([blueBar], + { + y: -70, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollLower], + { + y: -60, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollBack], + { + y: -50, + speed: 0.8, + wait: 0.1 + }); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + scrollBack = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/lowerLoop'), X, 20); + scrollBack.setPosition(0, 200); + scrollBack.flipX = true; + scrollBack.alpha = 0.39; + scrollBack.velocity.x = 110; + add(scrollBack); + + scrollLower = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/lowerLoop'), X, 20); + scrollLower.setPosition(0, 406); + scrollLower.velocity.x = -110; + add(scrollLower); + + blueBar = new FlxSprite(0, 239).loadGraphic(Paths.image('freeplay/backingCards/pico/blueBar')); + blueBar.blend = BlendMode.MULTIPLY; + blueBar.alpha = 0.4; + add(blueBar); + + scrollTop = new FlxBackdrop(null, X, 20); + scrollTop.setPosition(0, 80); + scrollTop.velocity.x = -220; + + scrollTop.frames = Paths.getSparrowAtlas('freeplay/backingCards/pico/topLoop'); + scrollTop.animation.addByPrefix('uzi', 'uzi info', 24, false); + scrollTop.animation.addByPrefix('sniper', 'sniper info', 24, false); + scrollTop.animation.addByPrefix('rocket launcher', 'rocket launcher info', 24, false); + scrollTop.animation.addByPrefix('rifle', 'rifle info', 24, false); + scrollTop.animation.addByPrefix('base', 'base', 24, false); + scrollTop.animation.play('base'); + + add(scrollTop); + + scrollMiddle = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/middleLoop'), X, 15); + scrollMiddle.setPosition(0, 346); + add(scrollMiddle); + scrollMiddle.velocity.x = 220; + + glowDark = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/backingCards/pico/glow')); + glowDark.blend = BlendMode.MULTIPLY; + add(glowDark); + + glow = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/backingCards/pico/glow')); + glow.blend = BlendMode.ADD; + add(glow); + + blueBar.visible = false; + scrollBack.visible = false; + scrollLower.visible = false; + scrollTop.visible = false; + scrollMiddle.visible = false; + glow.visible = false; + glowDark.visible = false; + + confirmAtlas = new FlxAtlasSprite(5, 55, Paths.animateAtlas("freeplay/backingCards/pico/pico-confirm")); + confirmAtlas.visible = false; + add(confirmAtlas); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + add(cardGlow); + } + + override public function confirm():Void + { + confirmAtlas.visible = true; + confirmAtlas.anim.play(""); + + FlxTween.color(instance.backingImage, 10 / 24, 0xFFFFFFFF, 0xFF8A8A8A, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + + new FlxTimer().start(10 / 24, function(_) { + // shoot + FlxTween.color(instance.backingImage, 3 / 24, 0xFF343036, 0xFF696366, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + }); + + new FlxTimer().start(14 / 24, function(_) { + // shoot + FlxTween.color(instance.backingImage, 3 / 24, 0xFF27292D, 0xFF686A6F, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + }); + + new FlxTimer().start(18 / 24, function(_) { + // shoot + FlxTween.color(instance.backingImage, 3 / 24, 0xFF2D282D, 0xFF676164, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + }); + + new FlxTimer().start(21 / 24, function(_) { + // shoot + FlxTween.color(instance.backingImage, 3 / 24, 0xFF29292F, 0xFF62626B, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + }); + + new FlxTimer().start(24 / 24, function(_) { + // shoot + FlxTween.color(instance.backingImage, 3 / 24, 0xFF29232C, 0xFF808080, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.backingImage.color; + } + }); + }); + } + + var beatFreq:Int = 1; + var beatFreqList:Array = [1, 2, 4, 8]; + + public override function beatHit():Void + { + // increases the amount of beats that need to go by to pulse the glow because itd flash like craazy at high bpms..... + beatFreq = beatFreqList[Math.floor(Conductor.instance.bpm / 140)]; + + if (Conductor.instance.currentBeat % beatFreq != 0) return; + FlxTween.cancelTweensOf(glow); + FlxTween.cancelTweensOf(glowDark); + + glow.alpha = 1; + FlxTween.tween(glow, {alpha: 0}, 16 / 24, {ease: FlxEase.quartOut}); + glowDark.alpha = 0; + FlxTween.tween(glowDark, {alpha: 1}, 18 / 24, {ease: FlxEase.quartOut}); + } + + public override function introDone():Void + { + pinkBack.color = 0xFF98A2F3; + + blueBar.visible = true; + scrollBack.visible = true; + scrollLower.visible = true; + scrollTop.visible = true; + scrollMiddle.visible = true; + glowDark.visible = true; + glow.visible = true; + + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + public override function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFF98A2F3, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + blueBar.visible = false; + scrollBack.visible = false; + scrollLower.visible = false; + scrollTop.visible = false; + scrollMiddle.visible = false; + glowDark.visible = false; + glow.visible = false; + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + var scrollProgress:Float = Math.abs(scrollTop.x % (scrollTop.frameWidth + 20)); + + if (scrollTop.animation.curAnim.finished == true) + { + if (FlxMath.inBounds(scrollProgress, 500, 700) && scrollTop.animation.curAnim.name != 'sniper') + { + scrollTop.animation.play('sniper', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 700, 1300) && scrollTop.animation.curAnim.name != 'rifle') + { + scrollTop.animation.play('rifle', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 1450, 2000) && scrollTop.animation.curAnim.name != 'rocket launcher') + { + scrollTop.animation.play('rocket launcher', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 0, 300) && scrollTop.animation.curAnim.name != 'uzi') + { + scrollTop.animation.play('uzi', true, false); + } + } + } +} diff --git a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx new file mode 100644 index 00000000..fe5b8b9e --- /dev/null +++ b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx @@ -0,0 +1,154 @@ +package funkin.ui.freeplay.charselect; + +import funkin.data.IRegistryEntry; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.play.scoring.Scoring.ScoringRank; + +/** + * An object used to retrieve data about a playable character (also known as "weeks"). + * Can be scripted to override each function, for custom behavior. + */ +@:nullSafety +class PlayableCharacter implements IRegistryEntry +{ + /** + * @param id The ID of the JSON file to parse. + */ + public function new(id:String) + { + this.id = id; + _data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse playable character data for id: $id'; + } + } + + /** + * Retrieve the readable name of the playable character. + */ + public function getName():String + { + // TODO: Maybe add localization support? + return _data?.name ?? "Unknown"; + } + + /** + * Retrieve the list of stage character IDs associated with this playable character. + * @return The list of associated character IDs + */ + public function getOwnedCharacterIds():Array + { + return _data?.ownedChars ?? []; + } + + /** + * Return `true` if, when this character is selected in Freeplay, + * songs unassociated with a specific character should appear. + */ + public function shouldShowUnownedChars():Bool + { + return _data?.showUnownedChars ?? false; + } + + public function shouldShowCharacter(id:String):Bool + { + if (getOwnedCharacterIds().contains(id)) + { + return true; + } + + if (shouldShowUnownedChars()) + { + var result = !PlayerRegistry.instance.isCharacterOwned(id); + return result; + } + + return false; + } + + public function getStickerPackID():String + { + return _data?.stickerPack ?? Constants.DEFAULT_STICKER_PACK; + } + + public function getFreeplayStyleID():String + { + return _data?.freeplayStyle ?? Constants.DEFAULT_FREEPLAY_STYLE; + } + + public function getFreeplayDJData():Null + { + return _data?.freeplayDJ; + } + + public function getFreeplayDJText(index:Int):String + { + // Silly little placeholder + return _data?.freeplayDJ?.getFreeplayDJText(index) ?? 'GET FREAKY ON A FRIDAY'; + } + + public function getCharSelectData():Null + { + return _data?.charSelect; + } + + /** + * @param rank Which rank to get info for + * @return An array of animations. For example, BF Great has two animations, one for BF and one for GF + */ + public function getResultsAnimationDatas(rank:ScoringRank):Array + { + if (_data == null || _data.results == null) + { + return []; + } + + switch (rank) + { + case PERFECT_GOLD: + return _data.results.perfectGold; + case PERFECT: + return _data.results.perfect; + case EXCELLENT: + return _data.results.excellent; + case GREAT: + return _data.results.great; + case GOOD: + return _data.results.good; + case SHIT: + return _data.results.loss; + } + } + + public function getResultsMusicPath(rank:ScoringRank):String + { + switch (rank) + { + case PERFECT_GOLD: + return _data?.results?.music?.PERFECT_GOLD ?? "resultsPERFECT"; + case PERFECT: + return _data?.results?.music?.PERFECT ?? "resultsPERFECT"; + case EXCELLENT: + return _data?.results?.music?.EXCELLENT ?? "resultsEXCELLENT"; + case GREAT: + return _data?.results?.music?.GREAT ?? "resultsNORMAL"; + case GOOD: + return _data?.results?.music?.GOOD ?? "resultsNORMAL"; + case SHIT: + return _data?.results?.music?.SHIT ?? "resultsSHIT"; + default: + return _data?.results?.music?.GOOD ?? "resultsNORMAL"; + } + } + + /** + * Returns whether this character is unlocked. + */ + public function isUnlocked():Bool + { + return _data?.unlocked ?? true; + } +} diff --git a/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx new file mode 100644 index 00000000..f75a5809 --- /dev/null +++ b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx @@ -0,0 +1,8 @@ +package funkin.ui.freeplay.charselect; + +/** + * A script that can be tied to a PlayableCharacter. + * Create a scripted class that extends PlayableCharacter to use this. + */ +@:hscriptClass +class ScriptedPlayableCharacter extends funkin.ui.freeplay.charselect.PlayableCharacter implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx b/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx new file mode 100644 index 00000000..731f2821 --- /dev/null +++ b/source/funkin/ui/haxeui/FlxGamepadActionInputSource.hx @@ -0,0 +1,52 @@ +package funkin.ui.haxeui; + +import flixel.FlxBasic; +import flixel.input.gamepad.FlxGamepad; + +/** + * Receives button presses from the Flixel gamepad and emits HaxeUI events. + */ +class FlxGamepadActionInputSource extends FlxBasic +{ + public static var instance(get, null):FlxGamepadActionInputSource; + + static function get_instance():FlxGamepadActionInputSource + { + if (instance == null) instance = new FlxGamepadActionInputSource(); + return instance; + } + + public function new() + { + super(); + } + + public function start():Void + { + FlxG.plugins.addPlugin(this); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.gamepads.firstActive != null) + { + updateGamepad(elapsed, FlxG.gamepads.firstActive); + } + } + + function updateGamepad(elapsed:Float, gamepad:FlxGamepad):Void + { + if (gamepad.justPressed.BACK) + { + // + } + } + + public override function destroy():Void + { + super.destroy(); + FlxG.plugins.remove(this); + } +} diff --git a/source/funkin/ui/haxeui/HaxeUIState.hx b/source/funkin/ui/haxeui/HaxeUIState.hx new file mode 100644 index 00000000..2e65cf82 --- /dev/null +++ b/source/funkin/ui/haxeui/HaxeUIState.hx @@ -0,0 +1,246 @@ +package funkin.ui.haxeui; + +import haxe.ui.components.CheckBox; +import haxe.ui.containers.menus.MenuCheckBox; +import haxe.ui.containers.menus.MenuItem; +import haxe.ui.core.Component; +import haxe.ui.core.Screen; +import funkin.ui.MusicBeatState; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; +import haxe.ui.RuntimeComponentBuilder; +import lime.app.Application; + +class HaxeUIState extends MusicBeatState +{ + public var component:Component; + + var _componentKey:String; + + public function new(key:String) + { + super(); + _componentKey = key; + } + + override function create():Void + { + super.create(); + + if (component == null) component = buildComponent(_componentKey); + if (component != null) add(component); + } + + public function buildComponent(assetPath:String):Component + { + try + { + return RuntimeComponentBuilder.fromAsset(assetPath); + } + catch (e) + { + Application.current.window.alert('Error building component "$assetPath": $e', 'HaxeUI Parsing Error'); + // trace('[ERROR] Failed to build component from asset: ' + assetPath); + // trace(e); + + return null; + } + } + + /** + * The currently active context menu. + */ + public var contextMenu:Component; + + /** + * This function is called when right clicking on a component, to display a context menu. + */ + function showContextMenu(assetPath:String, xPos:Float, yPos:Float):Component + { + if (contextMenu != null) contextMenu.destroy(); + + contextMenu = buildComponent(assetPath); + + if (contextMenu != null) + { + // Move the context menu to the mouse position. + contextMenu.left = xPos; + contextMenu.top = yPos; + Screen.instance.addComponent(contextMenu); + } + + return contextMenu; + } + + /** + * Register a context menu to display when right clicking. + * @param component Only display the menu when clicking this component. If null, display the menu when right clicking anywhere. + * @param assetPath The asset path to the context menu XML. + */ + public function registerContextMenu(target:Null, assetPath:String):Void + { + if (target == null) + { + Screen.instance.registerEvent(MouseEvent.RIGHT_CLICK, function(e:MouseEvent) { + showContextMenu(assetPath, e.screenX, e.screenY); + }); + } + else + { + target.registerEvent(MouseEvent.RIGHT_CLICK, function(e:MouseEvent) { + showContextMenu(assetPath, e.screenX, e.screenY); + }); + } + } + + /** + * Add an onClick listener to a HaxeUI menu bar item. + */ + function addUIClickListener(key:String, callback:MouseEvent->Void):Void + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onClick = callback; + } + } + + /** + * Add an onRightClick listener to a HaxeUI menu bar item. + */ + function addUIRightClickListener(key:String, callback:MouseEvent->Void):Void + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onRightClick = callback; + } + } + + function setComponentText(key:String, text:String):Void + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.text = text; + } + } + + function setComponentShortcutText(key:String, text:String):Void + { + var target:MenuItem = findComponent(key, MenuItem); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.shortcutText = text; + } + } + + function addTooltip(key:String, text:String):Void + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.tooltip = text; + } + } + + /** + * Add an onChange listener to a HaxeUI input component such as a slider or text field. + */ + function addUIChangeListener(key:String, callback:UIEvent->Void):Void + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onChange = callback; + } + } + + /** + * Set the value of a HaxeUI component. + * Usually modifies the text of a label or value of a text field. + */ + function setUIValue(key:String, value:T):T + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + return value; + } + else + { + return target.value = value; + } + } + + /** + * Set the value of a HaxeUI checkbox, + * since that's on 'selected' instead of 'value'. + */ + public function setUICheckboxSelected(key:String, value:Bool):Bool + { + var targetA:CheckBox = findComponent(key, CheckBox); + + if (targetA != null) + { + return targetA.selected = value; + } + + var targetB:MenuCheckBox = findComponent(key, MenuCheckBox); + if (targetB != null) + { + return targetB.selected = value; + } + + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate check box: $key'); + return value; + } + + public function findComponent(criteria:String = null, type:Class = null, recursive:Null = null, searchType:String = "id"):Null + { + if (component == null) return null; + + return component.findComponent(criteria, type, recursive, searchType); + } + + override function destroy():Void + { + if (component != null) remove(component); + component = null; + + super.destroy(); + } +} diff --git a/source/funkin/ui/haxeui/HaxeUISubState.hx b/source/funkin/ui/haxeui/HaxeUISubState.hx new file mode 100644 index 00000000..ed742648 --- /dev/null +++ b/source/funkin/ui/haxeui/HaxeUISubState.hx @@ -0,0 +1,181 @@ +package funkin.ui.haxeui; + +import haxe.ui.RuntimeComponentBuilder; +import haxe.ui.components.CheckBox; +import haxe.ui.containers.menus.MenuCheckBox; +import haxe.ui.core.Component; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatSubState; +import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; + +class HaxeUISubState extends MusicBeatSubState +{ + // The component representing the main UI. + public var component:Component; + + var _componentKey:String; + + public function new(key:String) + { + super(); + _componentKey = key; + } + + override function create() + { + super.create(); + + refreshComponent(); + } + + /** + * Builds a component from a given XML file. + * Call this in your code to load additional components at runtime. + */ + public function buildComponent(assetPath:String) + { + trace('Building component $assetPath'); + return RuntimeComponentBuilder.fromAsset(assetPath); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + // Force quit. + if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); + + // Refresh the component. + if (FlxG.keys.justPressed.F5) + { + refreshComponent(); + } + } + + function refreshComponent() + { + /* + if (component != null) + { + remove(component); + component = null; + } + + if (component != null) + { + trace('Success!'); + add(component); + } + else + { + trace('Failed to build component $_componentKey'); + } + */ + + if (component == null) + { + component = buildComponent(_componentKey); + add(component); + trace(component); + } + else + { + var component2 = buildComponent(_componentKey); + component2.x += 100; + add(component2); + trace(component2); + remove(component); + } + } + + /** + * Add an onClick listener to a HaxeUI menu bar item. + */ + function addUIClickListener(key:String, callback:MouseEvent->Void) + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onClick = callback; + } + } + + /** + * Add an onChange listener to a HaxeUI input component such as a slider or text field. + */ + function addUIChangeListener(key:String, callback:UIEvent->Void) + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + } + else + { + target.onChange = callback; + } + } + + /** + * Set the value of a HaxeUI component. + * Usually modifies the text of a label or value of a text field. + */ + function setUIValue(key:String, value:T):T + { + var target:Component = findComponent(key); + if (target == null) + { + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate menu item: $key'); + return value; + } + else + { + return target.value = value; + } + } + + /** + * Set the value of a HaxeUI checkbox, + * since that's on 'selected' instead of 'value'. + */ + public function setUICheckboxSelected(key:String, value:Bool):Bool + { + var targetA:CheckBox = findComponent(key, CheckBox); + + if (targetA != null) + { + return targetA.selected = value; + } + + var targetB:MenuCheckBox = findComponent(key, MenuCheckBox); + if (targetB != null) + { + return targetB.selected = value; + } + + // Gracefully handle the case where the item can't be located. + trace('WARN: Could not locate check box: $key'); + return value; + } + + public function findComponent(criteria:String = null, type:Class = null, recursive:Null = null, searchType:String = "id"):Null + { + if (component == null) return null; + + return component.findComponent(criteria, type, recursive, searchType); + } + + override function destroy() + { + if (component != null) remove(component); + component = null; + } +} diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx new file mode 100644 index 00000000..e2c7a095 --- /dev/null +++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx @@ -0,0 +1,293 @@ +package funkin.ui.haxeui.components; + +import funkin.modding.events.ScriptEvent.GhostMissNoteScriptEvent; +import funkin.modding.events.ScriptEvent.NoteScriptEvent; +import funkin.modding.events.ScriptEvent.HoldNoteScriptEvent; +import funkin.modding.events.ScriptEvent.HitNoteScriptEvent; +import funkin.modding.events.ScriptEvent.SongTimeScriptEvent; +import funkin.modding.events.ScriptEvent.UpdateScriptEvent; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData.CharacterDataParser; +import haxe.ui.containers.Box; +import haxe.ui.core.Component; +import haxe.ui.events.AnimationEvent; +import haxe.ui.geom.Size; +import haxe.ui.layouts.DefaultLayout; + +typedef AnimationInfo = +{ + var name:String; + var prefix:String; + var frameRate:Null; // default 30 + var looped:Null; // default true + var flipX:Null; // default false + var flipY:Null; // default false +} + +/** + * A variant of SparrowPlayer which loads a BaseCharacter instead. + * This allows it to play appropriate animations based on song events. + */ +@:composite(Layout) +class CharacterPlayer extends Box +{ + var character:Null; + + public function new(defaultToBf:Bool = true) + { + super(); + // _overrideSkipTransformChildren = false; + + if (defaultToBf) + { + loadCharacter('bf'); + } + } + + public var charId(get, set):String; + + function get_charId():String + { + return character?.characterId ?? ''; + } + + function set_charId(value:String):String + { + loadCharacter(value); + return value; + } + + public var charName(get, never):String; + + function get_charName():String + { + return character?.characterName ?? "Unknown"; + } + + // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... is it smart to "collect and redispatch"? Not sure + var _redispatchLoaded:Bool = false; + // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... is it smart to "collect and redispatch"? Not sure + var _redispatchStart:Bool = false; + var _characterLoaded:Bool = false; + + /** + * Loads a character by ID. + * @param id The ID of the character to load. + */ + public function loadCharacter(id:String):Void + { + if (id == null) return; + + if (character != null) + { + remove(character); + character.destroy(); + character = null; + } + + // Prevent script issues by fetching with debug=true. + var newCharacter:BaseCharacter = CharacterDataParser.fetchCharacter(id, true); + if (newCharacter == null) + { + character = null; + return; // Fail if character doesn't exist. + } + + // Assign character. + character = newCharacter; + + // Set character properties. + if (characterType != null) character.characterType = characterType; + if (flip) character.flipX = !character.flipX; + if (targetScale != 1.0) character.setScale(targetScale); + + character.animation.onFrameChange.add(function(name:String = '', frameNumber:Int = -1, frameIndex:Int = -1) { + dispatch(new AnimationEvent(AnimationEvent.FRAME)); + }); + character.animation.onFinish.add(function(name:String = '') { + dispatch(new AnimationEvent(AnimationEvent.END)); + }); + add(character); + + invalidateComponentLayout(); + + if (hasEvent(AnimationEvent.LOADED)) + { + dispatch(new AnimationEvent(AnimationEvent.LOADED)); + } + else + { + _redispatchLoaded = true; + } + } + + /** + * The character type (such as BF, Dad, GF, etc). + */ + public var characterType(default, set):CharacterType; + + function set_characterType(value:CharacterType):CharacterType + { + if (character != null) character.characterType = value; + return characterType = value; + } + + public var flip(default, set):Bool; + + function set_flip(value:Bool):Bool + { + if (value == flip) return value; + + if (character != null) + { + character.flipX = !character.flipX; + } + + return flip = value; + } + + public var targetScale(default, set):Float = 1.0; + + function set_targetScale(value:Float):Float + { + if (value == targetScale) return value; + + if (character != null) + { + character.setScale(value); + } + + return targetScale = value; + } + + function onFrame(name:String, frameNumber:Int, frameIndex:Int):Void + { + dispatch(new AnimationEvent(AnimationEvent.FRAME)); + } + + function onFinish(name:String):Void + { + dispatch(new AnimationEvent(AnimationEvent.END)); + } + + override function repositionChildren():Void + { + super.repositionChildren(); + character.x = this.cachedScreenX; + character.y = this.cachedScreenY; + + // Apply animation offsets, so the character is positioned correctly based on the animation. + @:privateAccess var animOffsets:Array = character.animOffsets; + + character.x -= animOffsets[0] * targetScale * (flip ? -1 : 1); + character.y -= animOffsets[1] * targetScale; + } + + /** + * Called when an update event is hit in the song. + * Used to play character animations. + * @param event The event. + */ + public function onUpdate(event:UpdateScriptEvent):Void + { + if (character != null) character.onUpdate(event); + } + + /** + * Called when an beat is hit in the song + * Used to play character animations. + * @param event The event. + */ + public function onBeatHit(event:SongTimeScriptEvent):Void + { + if (character != null) character.onBeatHit(event); + } + + /** + * Called when a step is hit in the song + * Used to play character animations. + * @param event The event. + */ + public function onStepHit(event:SongTimeScriptEvent):Void + { + if (character != null) character.onStepHit(event); + } + + public function onNoteIncoming(event:NoteScriptEvent) + { + if (character != null) character.onNoteIncoming(event); + } + + /** + * Called when a note is hit in the song + * Used to play character animations. + * @param event The event. + */ + public function onNoteHit(event:HitNoteScriptEvent):Void + { + if (character != null) character.onNoteHit(event); + } + + /** + * Called when a note is missed in the song + * Used to play character animations. + * @param event The event. + */ + public function onNoteMiss(event:NoteScriptEvent):Void + { + if (character != null) character.onNoteMiss(event); + } + + /** + * Called when a hold note is dropped in the song + * Used to play character animations. + * @param event The event. + */ + public function onNoteHoldDrop(event:HoldNoteScriptEvent):Void + { + if (character != null) character.onNoteHoldDrop(event); + } + + /** + * Called when a key is pressed but no note is hit in the song + * Used to play character animations. + * @param event The event. + */ + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void + { + if (character != null) character.onNoteGhostMiss(event); + } +} + +@:access(funkin.ui.haxeui.components.CharacterPlayer) +private class Layout extends DefaultLayout +{ + public override function resizeChildren():Void + { + super.resizeChildren(); + + var player:CharacterPlayer = cast(_component, CharacterPlayer); + var character:BaseCharacter = player.character; + if (character == null) + { + return super.resizeChildren(); + } + + character.cornerPosition.set(0, 0); + // character.setGraphicSize(Std.int(innerWidth), Std.int(innerHeight)); + } + + public override function calcAutoSize(exclusions:Array = null):Size + { + var player:CharacterPlayer = cast(_component, CharacterPlayer); + var character:BaseCharacter = player.character; + if (character == null) + { + return super.calcAutoSize(exclusions); + } + var size:Size = new Size(); + size.width = character.width + paddingLeft + paddingRight; + size.height = character.height + paddingTop + paddingBottom; + return size; + } +} diff --git a/source/funkin/ui/haxeui/components/README.md b/source/funkin/ui/haxeui/components/README.md new file mode 100644 index 00000000..90f8ab55 --- /dev/null +++ b/source/funkin/ui/haxeui/components/README.md @@ -0,0 +1,3 @@ +# funkin.ui.haxeui.components + +Since there is a line in `source/module.xml` pointing to this folder, all components in this folder will automatically be accessible in any HaxeUI layouts. diff --git a/source/funkin/ui/haxeui/components/WaveformPlayer.hx b/source/funkin/ui/haxeui/components/WaveformPlayer.hx new file mode 100644 index 00000000..da85fbfe --- /dev/null +++ b/source/funkin/ui/haxeui/components/WaveformPlayer.hx @@ -0,0 +1,17 @@ +package funkin.ui.haxeui.components; + +import funkin.audio.waveform.WaveformSprite; +import funkin.audio.waveform.WaveformData; +import haxe.ui.backend.flixel.components.SpriteWrapper; + +class WaveformPlayer extends SpriteWrapper +{ + public var waveform(default, null):WaveformSprite; + + public function new(?waveformData:WaveformData) + { + super(); + this.waveform = new WaveformSprite(waveformData); + this.sprite = waveform; + } +} diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx new file mode 100644 index 00000000..c6ac3d23 --- /dev/null +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -0,0 +1,427 @@ +package funkin.ui.mainmenu; + +import flixel.addons.transition.FlxTransitionableState; +import funkin.ui.debug.DebugMenuSubState; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.effects.FlxFlicker; +import flixel.util.typeLimit.NextState; +import flixel.input.touch.FlxTouch; +import flixel.tweens.FlxEase; +import funkin.graphics.FunkinCamera; +import funkin.audio.FunkinSound; +import flixel.tweens.FlxTween; +import funkin.ui.MusicBeatState; +import flixel.util.FlxTimer; +import funkin.ui.AtlasMenuList.AtlasMenuItem; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.MenuList.MenuTypedList; +import funkin.ui.MenuList.MenuListItem; +import funkin.ui.title.TitleState; +import funkin.ui.story.StoryMenuState; +import funkin.ui.Prompt; +import funkin.util.WindowUtil; +import funkin.api.newgrounds.Referral; +#if FEATURE_DISCORD_RPC +import funkin.api.discord.DiscordClient; +#end +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.NewgroundsClient; +#end + +class MainMenuState extends MusicBeatState +{ + var menuItems:MenuTypedList; + + var magenta:FlxSprite; + var camFollow:FlxObject; + + var overrideMusic:Bool = false; + + static var rememberedSelectedIndex:Int = 0; + + public function new(?_overrideMusic:Bool = false) + { + super(); + overrideMusic = _overrideMusic; + } + + override function create():Void + { + #if FEATURE_DISCORD_RPC + DiscordClient.instance.setPresence({state: "In the Menus", details: null}); + #end + + FlxG.cameras.reset(new FunkinCamera('mainMenu')); + + transIn = FlxTransitionableState.defaultTransIn; + transOut = FlxTransitionableState.defaultTransOut; + + if (!overrideMusic) playMenuMusic(); + + // We want the state to always be able to begin with being able to accept inputs and show the anims of the menu items. + persistentUpdate = true; + persistentDraw = true; + + var bg:FlxSprite = new FlxSprite(Paths.image('menuBG')); + bg.scrollFactor.x = 0; + bg.scrollFactor.y = 0.17; + bg.setGraphicSize(Std.int(bg.width * 1.2)); + bg.updateHitbox(); + bg.screenCenter(); + add(bg); + + camFollow = new FlxObject(0, 0, 1, 1); + add(camFollow); + + magenta = new FlxSprite(Paths.image('menuBGMagenta')); + magenta.scrollFactor.x = bg.scrollFactor.x; + magenta.scrollFactor.y = bg.scrollFactor.y; + magenta.setGraphicSize(Std.int(bg.width)); + magenta.updateHitbox(); + magenta.x = bg.x; + magenta.y = bg.y; + magenta.visible = false; + + // TODO: Why doesn't this line compile I'm going fucking feral + + if (Preferences.flashingLights) add(magenta); + + menuItems = new MenuTypedList(); + add(menuItems); + menuItems.onChange.add(onMenuItemChange); + menuItems.onAcceptPress.add(function(_) { + FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); + }); + + menuItems.enabled = true; // can move on intro + createMenuItem('storymode', 'mainmenu/storymode', function() startExitState(() -> new StoryMenuState())); + createMenuItem('freeplay', 'mainmenu/freeplay', function() { + persistentDraw = true; + persistentUpdate = false; + rememberedSelectedIndex = menuItems.selectedIndex; + // Freeplay has its own custom transition + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + #if FEATURE_DEBUG_FUNCTIONS + // Debug function: Hold SHIFT when selecting Freeplay to swap character without the char select menu + var targetCharacter:Null = (FlxG.keys.pressed.SHIFT) ? (FreeplayState.rememberedCharacterId == "pico" ? "bf" : "pico") : null; + #else + var targetCharacter:Null = null; + #end + + openSubState(new FreeplayState( + { + character: targetCharacter + })); + }); + + // MULTIPLAYER SEXY SEX SEX MOD YES + createMenuItem('multiplayer', 'mainmenu/multiplayer', function() { + startExitState(() -> new funkin.ui.multiplayer.MultiplayerMenuState()); + }); + + #if FEATURE_OPEN_URL + // In order to prevent popup blockers from triggering, + // we need to open the link as an immediate result of a keypress event, + // so we can't wait for the flicker animation to complete. + var hasPopupBlocker = #if web true #else false #end; + createMenuItem('merch', 'mainmenu/merch', selectMerch, hasPopupBlocker); + #end + + createMenuItem('options', 'mainmenu/options', function() { + startExitState(() -> new funkin.ui.options.OptionsState()); + }); + + createMenuItem('credits', 'mainmenu/credits', function() { + startExitState(() -> new funkin.ui.credits.CreditsState()); + }); + + // Reset position of menu items. + var spacing = 160; + var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2; + for (i in 0...menuItems.length) + { + var menuItem = menuItems.members[i]; + menuItem.x = FlxG.width / 2; + menuItem.y = top + spacing * i; + menuItem.scrollFactor.x = 0.0; + // This one affects how much the menu items move when you scroll between them. + menuItem.scrollFactor.y = 0.4; + } + + menuItems.selectItem(rememberedSelectedIndex); + + resetCamStuff(); + + // reset camera when debug menu is closed + subStateClosed.add(_ -> resetCamStuff(false)); + + subStateOpened.add(sub -> { + if (Type.getClass(sub) == FreeplayState) + { + new FlxTimer().start(0.5, _ -> { + magenta.visible = false; + }); + } + }); + + // FlxG.camera.setScrollBounds(bg.x, bg.x + bg.width, bg.y, bg.y + bg.height * 1.2); + + super.create(); + + // This has to come AFTER! + this.leftWatermarkText.text = Constants.VERSION; + + #if FEATURE_NEWGROUNDS + if (NewgroundsClient.instance.isLoggedIn()) + { + this.leftWatermarkText.text += ' | Newgrounds: Logged in as ${NewgroundsClient.instance.user?.name}'; + } + #end + } + + function playMenuMusic():Void + { + FunkinSound.playMusic('freakyMenu', + { + overrideExisting: true, + restartTrack: false, + // Continue playing this music between states, until a different music track gets played. + persist: true + }); + } + + function resetCamStuff(?snap:Bool = true):Void + { + FlxG.camera.follow(camFollow, null, 0.06); + + if (snap) FlxG.camera.snapToTarget(); + } + + function createMenuItem(name:String, atlas:String, callback:Void->Void, fireInstantly:Bool = false):Void + { + var item = new AtlasMenuItem(name, Paths.getSparrowAtlas(atlas), callback); + item.fireInstantly = fireInstantly; + item.ID = menuItems.length; + + item.scrollFactor.set(); + + // Set the offset of the item so the sprite is centered on the origin. + item.centered = true; + item.changeAnim('idle'); + + menuItems.addItem(name, item); + } + + override function closeSubState():Void + { + magenta.visible = false; + + super.closeSubState(); + } + + override function finishTransIn():Void + { + super.finishTransIn(); + } + + function onMenuItemChange(selected:MenuListItem) + { + camFollow.setPosition(selected.getGraphicMidpoint().x, selected.getGraphicMidpoint().y); + } + + #if FEATURE_OPEN_URL + function selectDonate() + { + WindowUtil.openURL(Constants.URL_ITCH); + } + + function selectMerch() + { + Referral.doMerchReferral(); + } + #end + + public function openPrompt(prompt:Prompt, onClose:Void->Void):Void + { + menuItems.enabled = false; + persistentUpdate = false; + + prompt.closeCallback = function() { + menuItems.enabled = true; + if (onClose != null) onClose(); + } + + openSubState(prompt); + } + + function startExitState(state:NextState):Void + { + menuItems.enabled = false; // disable for exit + rememberedSelectedIndex = menuItems.selectedIndex; + + var duration = 0.4; + menuItems.forEach(function(item) { + if (menuItems.selectedIndex != item.ID) + { + FlxTween.tween(item, {alpha: 0}, duration, {ease: FlxEase.quadOut}); + } + else + { + item.visible = false; + } + }); + + new FlxTimer().start(duration, function(_) FlxG.switchState(state)); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.onMobile) + { + var touch:FlxTouch = FlxG.touches.getFirst(); + + if (touch != null) + { + for (item in menuItems) + { + if (touch.overlaps(item)) + { + if (menuItems.selectedIndex == item.ID && touch.justPressed) menuItems.accept(); + else + menuItems.selectItem(item.ID); + } + } + } + } + + Conductor.instance.update(); + + // Open the debug menu, defaults to ` / ~ + // This includes stuff like the Chart Editor, so it should be present on all builds. + if (controls.DEBUG_MENU) + { + persistentUpdate = false; + + FlxG.state.openSubState(new DebugMenuSubState()); + } + + #if FEATURE_DEBUG_FUNCTIONS + // Ctrl+Alt+Shift+P = Character Unlock screen + // Ctrl+Alt+Shift+W = Meet requirements for Pico Unlock + // Ctrl+Alt+Shift+M = Revoke requirements for Pico Unlock + // Ctrl+Alt+Shift+R = Score/Rank conflict test + // Ctrl+Alt+Shift+N = Mark all characters as not seen + // Ctrl+Alt+Shift+E = Dump save data + // Ctrl+Alt+Shift+L = Force crash and create a log dump + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.P) + { + FlxG.switchState(() -> new funkin.ui.charSelect.CharacterUnlockState('pico')); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.W) + { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + // Give the user a score of 1 point on Weekend 1 story mode (Easy difficulty). + // This makes the level count as cleared and displays the songs in Freeplay. + funkin.save.Save.instance.setLevelScore('weekend1', 'easy', + { + score: 1, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.M) + { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + // Give the user a score of 0 points on Weekend 1 story mode (all difficulties). + // This makes the level count as uncleared and no longer displays the songs in Freeplay. + for (diff in ['easy', 'normal', 'hard']) + { + funkin.save.Save.instance.setLevelScore('weekend1', diff, + { + score: 0, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }); + } + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R) + { + // Give the user a hypothetical overridden score, + // and see if we can maintain that golden P rank. + funkin.save.Save.instance.setSongScore('tutorial', 'easy', + { + score: 1234567, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 1, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 1, + totalNotes: 10, + } + }); + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.N) + { + @:privateAccess + { + funkin.save.Save.instance.data.unlocks.charactersSeen = ["bf"]; + funkin.save.Save.instance.data.unlocks.oldChar = false; + } + } + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.E) + { + funkin.save.Save.instance.debug_dumpSave(); + } + #end + + if (FlxG.sound.music != null && FlxG.sound.music.volume < 0.8) + { + FlxG.sound.music.volume += 0.5 * elapsed; + } + + if (_exiting) menuItems.enabled = false; + + if (controls.BACK && menuItems.enabled && !menuItems.busy) + { + rememberedSelectedIndex = menuItems.selectedIndex; + FlxG.switchState(() -> new TitleState()); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } + } +} diff --git a/source/funkin/ui/options/ButtonRemapSubstate.hx b/source/funkin/ui/options/ButtonRemapSubstate.hx new file mode 100644 index 00000000..b692dbe8 --- /dev/null +++ b/source/funkin/ui/options/ButtonRemapSubstate.hx @@ -0,0 +1,11 @@ +package funkin.ui.options; + +import flixel.FlxSubState; + +class ButtonRemapSubState extends FlxSubState +{ + public function new() + { + super(); + } +} diff --git a/source/funkin/ui/options/ColorsMenu.hx b/source/funkin/ui/options/ColorsMenu.hx new file mode 100644 index 00000000..014f3ac5 --- /dev/null +++ b/source/funkin/ui/options/ColorsMenu.hx @@ -0,0 +1,68 @@ +package funkin.ui.options; + +import funkin.data.notestyle.NoteStyleRegistry; +import flixel.addons.effects.chainable.FlxEffectSprite; +import flixel.addons.effects.chainable.FlxOutlineEffect; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.util.FlxColor; +import funkin.ui.Page; +import funkin.play.notes.NoteSprite; + +class ColorsMenu extends Page +{ + var curSelected:Int = 0; + + var grpNotes:FlxTypedGroup; + + public function new() + { + super(); + + grpNotes = new FlxTypedGroup(); + add(grpNotes); + + for (i in 0...4) + { + var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), i); + + note.x = (100 * i) + i; + note.screenCenter(Y); + + var _effectSpr:FlxEffectSprite = new FlxEffectSprite(note, [new FlxOutlineEffect(FlxOutlineMode.FAST, FlxColor.WHITE, 4, 1)]); + add(_effectSpr); + _effectSpr.y = 0; + _effectSpr.x = i * 130; + _effectSpr.scale.x = _effectSpr.scale.y = 0.7; + // _effectSpr.setGraphicSize(); + _effectSpr.height = note.height; + _effectSpr.width = note.width; + + // _effectSpr.updateHitbox(); + + grpNotes.add(note); + } + } + + override function update(elapsed:Float) + { + if (controls.UI_RIGHT_P) curSelected += 1; + if (controls.UI_LEFT_P) curSelected -= 1; + + if (curSelected < 0) curSelected = grpNotes.members.length - 1; + if (curSelected >= grpNotes.members.length) curSelected = 0; + + if (controls.UI_UP) + { + // grpNotes.members[curSelected].colorSwap.update(elapsed * 0.3); + // Note.arrowColors[curSelected] += elapsed * 0.3; + } + + if (controls.UI_DOWN) + { + // grpNotes.members[curSelected].colorSwap.update(-elapsed * 0.3); + // Note.arrowColors[curSelected] += -elapsed * 0.3; + } + + super.update(elapsed); + } +} diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx new file mode 100644 index 00000000..b1cce766 --- /dev/null +++ b/source/funkin/ui/options/ControlsMenu.hx @@ -0,0 +1,558 @@ +package funkin.ui.options; + +import funkin.util.InputUtil; +import flixel.FlxCamera; +import flixel.FlxObject; +import funkin.graphics.FunkinCamera; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.input.gamepad.FlxGamepadInputID; +import flixel.input.keyboard.FlxKey; +import funkin.graphics.FunkinSprite; +import funkin.input.Controls.Device; +import funkin.input.Controls.Control; +import funkin.ui.AtlasText; +import funkin.ui.MenuList.MenuTypedList; +import funkin.ui.TextMenuList; +import funkin.ui.Page; + +class ControlsMenu extends Page +{ + public static inline final COLUMNS = 2; + static var controlList = Control.createAll(); + /* + * Defines groups of controls that cannot share inputs, like left and right. Say, if ACCEPT is Z, Back is X, + * if the player sets Back to Z it also set ACCEPT to X. This prevents the player from setting the controls in + * a way the prevents them from changing more controls or exiting the menu. + */ + static var controlGroups:Array> = [ + [NOTE_UP, NOTE_DOWN, NOTE_LEFT, NOTE_RIGHT], + [UI_UP, UI_DOWN, UI_LEFT, UI_RIGHT, ACCEPT, BACK], + [CUTSCENE_ADVANCE], + [FREEPLAY_FAVORITE, FREEPLAY_LEFT, FREEPLAY_RIGHT, FREEPLAY_CHAR_SELECT], + [WINDOW_FULLSCREEN, #if FEATURE_SCREENSHOTS WINDOW_SCREENSHOT, #end], + [VOLUME_UP, VOLUME_DOWN, VOLUME_MUTE], + [ + DEBUG_MENU, + #if FEATURE_CHART_EDITOR DEBUG_CHART, #end + #if FEATURE_STAGE_EDITOR DEBUG_STAGE, #end + ] + ]; + + var itemGroups:Array> = [for (i in 0...controlGroups.length) []]; + + var controlGrid:MenuTypedList; + var deviceList:TextMenuList; + var menuCamera:FlxCamera; + var prompt:Prompt; + var popup:Prompt; + var camFollow:FlxObject; + var labels:FlxTypedGroup; + + var currentDevice:Device = Keys; + var deviceListSelected:Bool = false; + + var actionPrevented:Bool = false; + + static final CONTROL_BASE_X = 50; + static final CONTROL_MARGIN_X = 700; + static final CONTROL_SPACING_X = 300; + + public function new() + { + super(); + + menuCamera = new FunkinCamera('controlsMenu'); + FlxG.cameras.add(menuCamera, false); + menuCamera.bgColor = 0x0; + camera = menuCamera; + + labels = new FlxTypedGroup(); + var headers:FlxTypedGroup = new FlxTypedGroup(); + controlGrid = new MenuTypedList(Columns(COLUMNS), Vertical); + + add(labels); + add(headers); + add(controlGrid); + + if (FlxG.gamepads.numActiveGamepads > 0) + { + var devicesBg:FunkinSprite = new FunkinSprite(); + devicesBg.makeSolidColor(FlxG.width, 100, 0xFFFAFD6D); + add(devicesBg); + deviceList = new TextMenuList(Horizontal, None); + add(deviceList); + deviceListSelected = true; + + var item:TextMenuItem; + + item = deviceList.createItem('Keyboard', AtlasFont.BOLD, selectDevice.bind(Keys)); + item.x = FlxG.width / 2 - item.width - 30; + item.y = (devicesBg.height - item.height) / 2; + + item = deviceList.createItem('Gamepad', AtlasFont.BOLD, selectDevice.bind(Gamepad(FlxG.gamepads.firstActive.id))); + item.x = FlxG.width / 2 + 30; + item.y = (devicesBg.height - item.height) / 2; + } + + // FlxG.debugger.drawDebug = true; + var y = deviceList == null ? 30 : 120; + var spacer = 70; + var currentHeader:String = null; + // list order is determined by enum order + for (i in 0...controlList.length) + { + var control = controlList[i]; + var name = control.getName(); + if (currentHeader != "UI_" && name.indexOf("UI_") == 0) + { + currentHeader = "UI_"; + headers.add(new AtlasText(0, y, "UI", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "NOTE_" && name.indexOf("NOTE_") == 0) + { + currentHeader = "NOTE_"; + headers.add(new AtlasText(0, y, "NOTES", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "CUTSCENE_" && name.indexOf("CUTSCENE_") == 0) + { + currentHeader = "CUTSCENE_"; + headers.add(new AtlasText(0, y, "CUTSCENE", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "FREEPLAY_" && name.indexOf("FREEPLAY_") == 0) + { + currentHeader = "FREEPLAY_"; + headers.add(new AtlasText(0, y, "FREEPLAY", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "WINDOW_" && name.indexOf("WINDOW_") == 0) + { + currentHeader = "WINDOW_"; + headers.add(new AtlasText(0, y, "WINDOW", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "VOLUME_" && name.indexOf("VOLUME_") == 0) + { + currentHeader = "VOLUME_"; + headers.add(new AtlasText(0, y, "VOLUME", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + else if (currentHeader != "DEBUG_" && name.indexOf("DEBUG_") == 0) + { + currentHeader = "DEBUG_"; + headers.add(new AtlasText(0, y, "DEBUG", AtlasFont.BOLD)).screenCenter(X); + y += spacer; + } + + if (currentHeader != null && name.indexOf(currentHeader) == 0) name = name.substr(currentHeader.length); + + var formatName = name.replace('_', ' '); + var label = labels.add(new AtlasText(CONTROL_BASE_X, y, formatName, AtlasFont.BOLD)); + label.alpha = 0.6; + for (i in 0...COLUMNS) + createItem(label.x + CONTROL_MARGIN_X + i * CONTROL_SPACING_X, y, control, i); + + y += spacer; + } + + camFollow = new FlxObject(FlxG.width / 2, 0, 70, 70); + if (deviceList != null) + { + camFollow.y = deviceList.selectedItem.y; + controlGrid.selectedItem.idle(); + controlGrid.enabled = false; + } + else + camFollow.y = controlGrid.selectedItem.y; + + menuCamera.follow(camFollow, null, 0.06); + var margin = 100; + menuCamera.deadzone.set(0, margin, menuCamera.width, menuCamera.height - margin * 2); + menuCamera.minScrollY = 0; + controlGrid.onChange.add(function(selected) { + camFollow.y = selected.y; + + labels.forEach((label) -> label.alpha = 0.6); + labels.members[Std.int(controlGrid.selectedIndex / COLUMNS)].alpha = 1.0; + }); + + prompt = new Prompt("\nPress any key to rebind\n\n\nBackspace to unbind\n Escape to cancel", None); + prompt.create(); + prompt.createBgFromMargin(100, 0xFFfafd6d); + prompt.back.scrollFactor.set(0, 0); + prompt.exists = false; + add(prompt); + + popup = new Prompt("\nYou cannot unbind\nthat key!\n\n\nEscape to exit", None); + popup.create(); + popup.createBgFromMargin(100, 0xFFfafd6d); + popup.back.scrollFactor.set(0, 0); + popup.exists = false; + add(popup); + } + + function createItem(x = 0.0, y = 0.0, control:Control, index:Int) + { + var item = new InputItem(x, y, currentDevice, control, index, onSelect); + for (i in 0...controlGroups.length) + { + if (controlGroups[i].contains(control)) itemGroups[i].push(item); + } + + return controlGrid.addItem(item.name, item); + } + + function onSelect():Void + { + switch (currentDevice) + { + case Keys: + { + keyUsedToEnterPrompt = FlxG.keys.firstJustPressed(); + } + case Gamepad(id): + { + buttonUsedToEnterPrompt = FlxG.gamepads.getByID(id).firstJustPressedID(); + } + } + + controlGrid.enabled = false; + canExit = false; + prompt.exists = true; + } + + function createPopup():Void + { + canExit = false; + popup.exists = true; + } + + function goToDeviceList():Void + { + controlGrid.selectedItem.idle(); + labels.members[Std.int(controlGrid.selectedIndex / COLUMNS)].alpha = 0.6; + controlGrid.enabled = false; + deviceList.enabled = true; + canExit = true; + camFollow.y = deviceList.selectedItem.y; + deviceListSelected = true; + } + + function selectDevice(device:Device):Void + { + currentDevice = device; + + for (item in controlGrid.members) + item.updateDevice(currentDevice); + + var inputName = device == Keys ? "key" : "button"; + var cancel = device == Keys ? "Escape" : "Back"; + // todo: alignment + if (device == Keys) + { + prompt.setText('\nPress any key to rebind\n\n\n\n $cancel to cancel'); + popup.setText('\nYou cannot unbind\nthat key!\n\n\n$cancel to exit'); + } + else + { + prompt.setText('\nPress any button\n to rebind\n\n\n $cancel to cancel'); + popup.setText('\nYou cannot unbind\nthat button!\n\n\n$cancel to exit'); + } + + controlGrid.selectedItem.select(); + labels.members[Std.int(controlGrid.selectedIndex / COLUMNS)].alpha = 1.0; + controlGrid.enabled = true; + deviceList.enabled = false; + deviceListSelected = false; + canExit = false; + } + + var keyUsedToEnterPrompt:Null = null; + var buttonUsedToEnterPrompt:Null = null; + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + var controls = PlayerSettings.player1.controls; + if (controlGrid.enabled && deviceList != null && deviceListSelected == false && controls.BACK) goToDeviceList(); + + if (prompt.exists) + { + switch (currentDevice) + { + case Keys: + { + // Um? + // Checking pressed causes problems when you change the BACK key, + // but checking released causes problems when the prompt is instant. + + // keyUsedToEnterPrompt is my weird workaround. + + var key = FlxG.keys.firstJustReleased(); + if (key != NONE && key != keyUsedToEnterPrompt) + { + if (key == ESCAPE) + { + closePrompt(); + } + else if (key == BACKSPACE) + { + onInputSelect(NONE); + closePrompt(); + } + else + { + onInputSelect(key); + closePrompt(); + } + } + } + case Gamepad(id): + { + var button = FlxG.gamepads.getByID(id).firstJustReleasedID(); + if (button != NONE && button != buttonUsedToEnterPrompt) + { + if (button != BACK) onInputSelect(button); + closePrompt(); + } + + var key = FlxG.keys.firstJustReleased(); + if (key != NONE && key != keyUsedToEnterPrompt) + { + if (key == ESCAPE) + { + closePrompt(); + } + else if (key == BACKSPACE) + { + onInputSelect(NONE); + closePrompt(); + } + } + } + } + } + + if (actionPrevented && !popup.exists) createPopup(); + + if (popup.exists) + { + switch (currentDevice) + { + case Keys: + { + var key = FlxG.keys.firstJustReleased(); + if (key == ESCAPE) closePopup(); + } + case Gamepad(id): + { + var button = FlxG.gamepads.getByID(id).firstJustReleasedID(); + if (button == BACK) closePopup(); + + var key = FlxG.keys.firstJustReleased(); + if (key == ESCAPE) closePopup(); + } + } + } + + switch (currentDevice) + { + case Keys: + { + var keyJustReleased:Int = FlxG.keys.firstJustReleased(); + if (keyJustReleased != NONE && keyJustReleased == keyUsedToEnterPrompt) + { + keyUsedToEnterPrompt = null; + } + buttonUsedToEnterPrompt = null; + } + case Gamepad(id): + { + var buttonJustReleased:Int = FlxG.gamepads.getByID(id).firstJustReleasedID(); + if (buttonJustReleased != NONE && buttonJustReleased == buttonUsedToEnterPrompt) + { + buttonUsedToEnterPrompt = null; + } + keyUsedToEnterPrompt = null; + } + } + } + + function onInputSelect(input:Int):Void + { + var item = controlGrid.selectedItem; + var leftItem = controlGrid.members[controlGrid.selectedIndex - 1]; + var rightItem = controlGrid.members[controlGrid.selectedIndex + 1]; + + // check if all keybinds are being removed and this is a UI control, prevent removing last keybind for this + if (input == FlxKey.NONE && controlGrid.selectedIndex != 1 && rightItem.input == FlxKey.NONE) + { + for (group in itemGroups) + { + if (group.toString() == itemGroups[1].toString() && group.contains(item)) actionPrevented = true; + } + } + + if (actionPrevented) return; + + // check if that key is already set for this + if (input != FlxKey.NONE) + { + var column0 = Math.floor(controlGrid.selectedIndex / 2) * 2; + for (i in 0...COLUMNS) + { + if (controlGrid.members[column0 + i].input == input) return; + } + } + + // Check if items in the same group already have the new input + for (group in itemGroups) + { + if (input != FlxKey.NONE && group.contains(item)) + { + for (otherItem in group) + { + if (otherItem != item && otherItem.input == input) + { + // replace that input with this items old input. + PlayerSettings.player1.controls.replaceBinding(otherItem.control, currentDevice, item.input, otherItem.input); + // Don't use resetItem() since items share names/labels + otherItem.input = item.input; + otherItem.label.text = item.label.text; + } + } + } + } + + PlayerSettings.player1.controls.replaceBinding(item.control, currentDevice, input, item.input); + + // Don't use resetItem() since items share names/labels + item.input = input; + item.label.text = item.getLabel(input); + + // Shift left on the grid if the item on the right is bound and the item on the left is unbound. + if (controlGrid.selectedIndex % 2 == 1) + { + trace('Modified item on right side of grid'); + if (leftItem != null && input != FlxKey.NONE && leftItem.input == FlxKey.NONE) + { + trace('Left item is unbound and right item is not!'); + // Swap them. + var temp = leftItem.input; + leftItem.input = item.input; + item.input = temp; + + leftItem.label.text = leftItem.getLabel(leftItem.input); + item.label.text = item.getLabel(item.input); + } + } + else + { + trace('Modified item on left side of grid'); + if (rightItem != null && input == FlxKey.NONE && rightItem.input != FlxKey.NONE) + { + trace('Left item is unbound and right item is not!'); + // Swap them. + var temp = item.input; + item.input = rightItem.input; + rightItem.input = temp; + + item.label.text = item.getLabel(item.input); + rightItem.label.text = rightItem.getLabel(rightItem.input); + } + } + + PlayerSettings.player1.saveControls(); + } + + function closePrompt() + { + prompt.exists = false; + controlGrid.enabled = true; + if (deviceList == null) canExit = true; + } + + function closePopup() + { + popup.exists = false; + actionPrevented = false; + if (deviceList == null) canExit = true; + } + + override function destroy() + { + super.destroy(); + + itemGroups = null; + + if (FlxG.cameras.list.contains(menuCamera)) FlxG.cameras.remove(menuCamera); + } + + override function set_enabled(value:Bool) + { + if (value == false) + { + controlGrid.enabled = false; + if (deviceList != null) deviceList.enabled = false; + } + else + { + controlGrid.enabled = !deviceListSelected; + if (deviceList != null) deviceList.enabled = deviceListSelected; + } + return super.set_enabled(value); + } +} + +class InputItem extends TextMenuItem +{ + public var device(default, null):Device = Keys; + public var control:Control; + public var input:Int = -1; + public var index:Int = -1; + + public function new(x = 0.0, y = 0.0, device, control, index, ?callback) + { + this.device = device; + this.control = control; + this.index = index; + this.input = getInput(); + + super(x, y, getLabel(input), DEFAULT, callback); + + this.fireInstantly = true; + } + + public function updateDevice(device:Device) + { + if (this.device != device) + { + this.device = device; + input = getInput(); + label.text = getLabel(input); + } + } + + function getInput() + { + var list = PlayerSettings.player1.controls.getInputsFor(control, device); + list = list.distinct(); + if (list.length > index) + { + if (list[index] != FlxKey.ESCAPE || list[index] != FlxGamepadInputID.BACK) return list[index]; + + if (list.length > ControlsMenu.COLUMNS) // Escape isn't mappable, show a third option, instead. + return list[ControlsMenu.COLUMNS]; + } + + return -1; + } + + public function getLabel(input:Int) + { + return input == FlxKey.NONE ? "---" : InputUtil.format(input, device); + } +} diff --git a/source/funkin/ui/options/FunkinSoundTray.hx b/source/funkin/ui/options/FunkinSoundTray.hx new file mode 100644 index 00000000..047c86c9 --- /dev/null +++ b/source/funkin/ui/options/FunkinSoundTray.hx @@ -0,0 +1,164 @@ +package funkin.ui.options; + +import flixel.system.ui.FlxSoundTray; +import openfl.display.Bitmap; +import funkin.util.MathUtil; + +/** + * Extends the default flixel soundtray, but with some art + * and lil polish! + * + * Gets added to the game in Main.hx, right after FlxGame is new'd + * since it's a Sprite rather than Flixel related object + */ +class FunkinSoundTray extends FlxSoundTray +{ + var graphicScale:Float = 0.30; + var lerpYPos:Float = 0; + var alphaTarget:Float = 0; + + var volumeMaxSound:String; + + public function new() + { + // calls super, then removes all children to add our own + // graphics + super(); + removeChildren(); + + var bg:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/volumebox"))); + bg.scaleX = graphicScale; + bg.scaleY = graphicScale; + bg.smoothing = true; + addChild(bg); + + y = -height; + visible = false; + + // makes an alpha'd version of all the bars (bar_10.png) + var backingBar:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/bars_10"))); + backingBar.x = 9; + backingBar.y = 5; + backingBar.scaleX = graphicScale; + backingBar.scaleY = graphicScale; + backingBar.smoothing = true; + addChild(backingBar); + backingBar.alpha = 0.4; + + // clear the bars array entirely, it was initialized + // in the super class + _bars = []; + + // 1...11 due to how block named the assets, + // we are trying to get assets bars_1-10 + for (i in 1...11) + { + var bar:Bitmap = new Bitmap(Assets.getBitmapData(Paths.image("soundtray/bars_" + i))); + bar.x = 9; + bar.y = 5; + bar.scaleX = graphicScale; + bar.scaleY = graphicScale; + bar.smoothing = true; + addChild(bar); + _bars.push(bar); + } + + screenCenter(); + + volumeUpSound = Paths.sound("soundtray/Volup"); + volumeDownSound = Paths.sound("soundtray/Voldown"); + volumeMaxSound = Paths.sound("soundtray/VolMAX"); + } + + override public function update(ms:Float):Void + { + y = MathUtil.coolLerp(y, lerpYPos, 0.1); + alpha = MathUtil.coolLerp(alpha, alphaTarget, 0.25); + + // If it has volume, we want to auto-hide after 1 second (1000ms), we simply decrement a timer + var hasVolume:Bool = (!FlxG.sound.muted && FlxG.sound.volume > 0); + + if (hasVolume) + { + // Animate sound tray thing + if (_timer > 0) + { + _timer -= (ms / 1000); + } + else if (y >= -height) + { + lerpYPos = -height - 10; + alphaTarget = 0; + } + + if (y <= -height) + { + visible = false; + active = false; + } + } + else if (!visible) moveTrayMakeVisible(); + } + + /** + * Makes the little volume tray slide out. + * This is usually called by SoundFrontEnd, rather than being called by us explicitly + * (Which is why it's internals have been separated out a bit, for easier internal calling) + * + * @param up Whether the volume is increasing. + */ + override public function show(up:Bool = false):Void + { + moveTrayMakeVisible(up); + saveVolumePreferences(); + } + + function moveTrayMakeVisible(up:Bool = false):Void + { + _timer = 1; + lerpYPos = 10; + visible = true; + active = true; + alphaTarget = 1; + + for (i in 0..._bars.length) + _bars[i].visible = i < getGlobalVolume(up); + } + + /** + * Calculates the volume with proper linear scaling, and returns it as an int. + * @param up Whether the volume is increasing. + * @return Int The volume as an int from 0 to 10. + */ + function getGlobalVolume(up:Bool = false):Int + { + var globalVolume:Int = Math.round(FlxG.sound.logToLinear(FlxG.sound.volume) * 10); + + if (FlxG.sound.muted || FlxG.sound.volume == 0) globalVolume = 0; + + if (!silent) + { + // This is a String currently, but there is or was a Flixel PR to change this to a FlxSound or a Sound bject + var sound:String = up ? volumeUpSound : volumeDownSound; + + if (globalVolume == 10) sound = volumeMaxSound; + if (sound != null) FlxG.sound.load(sound).play().volume = 0.3; + } + + return globalVolume; + } + + function saveVolumePreferences():Void + { + // Actually save when the volume is changed / modified + #if FLX_SAVE + // Save sound preferences + if (FlxG.save.isBound) + { + FlxG.save.data.mute = FlxG.sound.muted; + FlxG.save.data.volume = FlxG.sound.volume; + FlxG.save.flush(); + } + #end + } +} diff --git a/source/funkin/ui/options/MenuItemEnums.hx b/source/funkin/ui/options/MenuItemEnums.hx new file mode 100644 index 00000000..4513a92a --- /dev/null +++ b/source/funkin/ui/options/MenuItemEnums.hx @@ -0,0 +1,10 @@ +package funkin.ui.options; + +// Add enums for use with `EnumPreferenceItem` here! +/* Example: + class MyOptionEnum + { + public static inline var YuhUh = "true"; // "true" is the value's ID + public static inline var NuhUh = "false"; + } + */ diff --git a/source/funkin/ui/options/ModMenu.hx b/source/funkin/ui/options/ModMenu.hx new file mode 100644 index 00000000..d0e1e48c --- /dev/null +++ b/source/funkin/ui/options/ModMenu.hx @@ -0,0 +1,128 @@ +package funkin.ui.options; + +import funkin.modding.PolymodHandler; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.text.FlxText; +import flixel.util.FlxColor; +import polymod.Polymod.ModMetadata; +import funkin.ui.Page; + +class ModMenu extends Page +{ + var grpMods:FlxTypedGroup; + var enabledMods:Array = []; + var detectedMods:Array = []; + + var curSelected:Int = 0; + + public function new():Void + { + super(); + + grpMods = new FlxTypedGroup(); + add(grpMods); + + refreshModList(); + } + + override function update(elapsed:Float) + { + if (FlxG.keys.justPressed.R) refreshModList(); + + selections(); + + if (controls.UI_UP_P) selections(-1); + if (controls.UI_DOWN_P) selections(1); + + if (FlxG.keys.justPressed.SPACE) grpMods.members[curSelected].modEnabled = !grpMods.members[curSelected].modEnabled; + + if (FlxG.keys.justPressed.I && curSelected != 0) + { + var oldOne = grpMods.members[curSelected - 1]; + grpMods.members[curSelected - 1] = grpMods.members[curSelected]; + grpMods.members[curSelected] = oldOne; + selections(-1); + } + + if (FlxG.keys.justPressed.K && curSelected < grpMods.members.length - 1) + { + var oldOne = grpMods.members[curSelected + 1]; + grpMods.members[curSelected + 1] = grpMods.members[curSelected]; + grpMods.members[curSelected] = oldOne; + selections(1); + } + + super.update(elapsed); + } + + function selections(change:Int = 0):Void + { + curSelected += change; + + if (curSelected >= detectedMods.length) curSelected = 0; + if (curSelected < 0) curSelected = detectedMods.length - 1; + + for (txt in 0...grpMods.length) + { + if (txt == curSelected) + { + grpMods.members[txt].color = FlxColor.YELLOW; + } + else + grpMods.members[txt].color = FlxColor.WHITE; + } + + organizeByY(); + } + + function refreshModList():Void + { + while (grpMods.members.length > 0) + { + grpMods.remove(grpMods.members[0], true); + } + + #if desktop + detectedMods = PolymodHandler.getAllMods(); + + trace('ModMenu: Detected ${detectedMods.length} mods'); + + for (index in 0...detectedMods.length) + { + var modMetadata:ModMetadata = detectedMods[index]; + var modName:String = modMetadata.title; + var txt:ModMenuItem = new ModMenuItem(0, 10 + (40 * index), 0, modName, 32); + txt.text = modName; + grpMods.add(txt); + } + #end + } + + function organizeByY():Void + { + for (i in 0...grpMods.length) + { + grpMods.members[i].y = 10 + (40 * i); + } + } +} + +class ModMenuItem extends FlxText +{ + public var modEnabled:Bool = false; + public var daMod:String; + + public function new(x:Float, y:Float, w:Float, str:String, size:Int) + { + super(x, y, w, str, size); + } + + override function update(elapsed:Float) + { + if (modEnabled) alpha = 1; + else + alpha = 0.5; + + super.update(elapsed); + } +} diff --git a/source/funkin/ui/options/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx new file mode 100644 index 00000000..4337495e --- /dev/null +++ b/source/funkin/ui/options/OptionsState.hx @@ -0,0 +1,206 @@ +package funkin.ui.options; + +import funkin.ui.Page.PageName; +import funkin.ui.transition.LoadingState; +import funkin.ui.debug.latency.LatencyState; +import flixel.FlxSprite; +import flixel.FlxSubState; +import flixel.group.FlxGroup; +import flixel.util.FlxSignal; +import funkin.audio.FunkinSound; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatState; +import funkin.graphics.shaders.HSVShader; +import funkin.input.Controls; +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.NewgroundsClient; +#end + +/** + * The main options menu + * It mainly is controlled via the "optionsCodex" object, + * which handles paging and going to the different submenus + */ +class OptionsState extends MusicBeatState +{ + var optionsCodex:Codex; + + override function create():Void + { + persistentUpdate = true; + + var menuBG = new FlxSprite().loadGraphic(Paths.image('menuBG')); + var hsv = new HSVShader(-0.6, 0.9, 3.6); + menuBG.shader = hsv; + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + add(menuBG); + + optionsCodex = new Codex(Options); + add(optionsCodex); + + var options:OptionsMenu = optionsCodex.addPage(Options, new OptionsMenu()); + var preferences:PreferencesMenu = optionsCodex.addPage(Preferences, new PreferencesMenu()); + var controls:ControlsMenu = optionsCodex.addPage(Controls, new ControlsMenu()); + + if (options.hasMultipleOptions()) + { + options.onExit.add(exitToMainMenu); + controls.onExit.add(exitControls); + preferences.onExit.add(optionsCodex.switchPage.bind(Options)); + } + else + { + // No need to show Options page + controls.onExit.add(exitToMainMenu); + optionsCodex.setPage(Controls); + } + + super.create(); + } + + function exitControls():Void + { + // Apply any changes to the controls. + PlayerSettings.reset(); + PlayerSettings.init(); + + optionsCodex.switchPage(Options); + } + + function exitToMainMenu() + { + optionsCodex.currentPage.enabled = false; + // TODO: Animate this transition? + FlxG.switchState(() -> new MainMenuState()); + } +} + +/** + * Our default Page when we enter the OptionsState, a bit of the root + */ +class OptionsMenu extends Page +{ + var items:TextMenuList; + + public function new() + { + super(); + + add(items = new TextMenuList()); + createItem("PREFERENCES", function() codex.switchPage(Preferences)); + createItem("CONTROLS", function() codex.switchPage(Controls)); + createItem("INPUT OFFSETS", function() { + #if web + LoadingState.transitionToState(() -> new LatencyState()); + #else + FlxG.state.openSubState(new LatencyState()); + #end + }); + + #if FEATURE_NEWGROUNDS + if (NewgroundsClient.instance.isLoggedIn()) + { + createItem("LOGOUT OF NG", function() { + NewgroundsClient.instance.logout(function() { + // Reset the options menu when logout succeeds. + // This means the login option will be displayed. + FlxG.resetState(); + }, function() { + FlxG.log.warn("Newgrounds logout failed!"); + }); + }); + } + else + { + createItem("LOGIN TO NG", function() { + NewgroundsClient.instance.login(function() { + // Reset the options menu when login succeeds. + // This means the logout option will be displayed. + + // NOTE: If the user presses login and opens the browser, + // then navigates the UI + FlxG.resetState(); + }, function() { + FlxG.log.warn("Newgrounds login failed!"); + }); + }); + } + #end + + createItem("CLEAR SAVE DATA", function() { + promptClearSaveData(); + }); + + createItem("EXIT", exit); + } + + function createItem(name:String, callback:Void->Void, fireInstantly = false) + { + var item = items.createItem(0, 100 + items.length * 100, name, BOLD, callback); + item.fireInstantly = fireInstantly; + item.screenCenter(X); + return item; + } + + override function update(elapsed:Float) + { + enabled = (prompt == null); + super.update(elapsed); + } + + override function set_enabled(value:Bool) + { + items.enabled = value; + return super.set_enabled(value); + } + + /** + * True if this page has multiple options, excluding the exit option. + * If false, there's no reason to ever show this page. + */ + public function hasMultipleOptions():Bool + { + return items.length > 2; + } + + var prompt:Prompt; + + function promptClearSaveData():Void + { + if (prompt != null) return; + + prompt = new Prompt("This will delete + \nALL your save data. + \nAre you sure? + ", Custom("Delete", "Cancel")); + prompt.create(); + prompt.createBgFromMargin(100, 0xFFFAFD6D); + prompt.back.scrollFactor.set(0, 0); + add(prompt); + + prompt.onYes = function() { + // Clear the save data. + funkin.save.Save.clearData(); + + FlxG.switchState(() -> new funkin.InitState()); + } + + prompt.onNo = function() { + prompt.close(); + prompt.destroy(); + prompt = null; + } + } +} + +enum abstract OptionsMenuPageName(String) to PageName +{ + var Options = "options"; + var Controls = "controls"; + var Colors = "colors"; + var Mods = "mods"; + var Preferences = "preferences"; +} diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx new file mode 100644 index 00000000..240eade8 --- /dev/null +++ b/source/funkin/ui/options/PreferencesMenu.hx @@ -0,0 +1,279 @@ +package funkin.ui.options; + +import flixel.FlxCamera; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.text.FlxText; +import flixel.util.FlxColor; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import funkin.ui.AtlasText.AtlasFont; +import funkin.ui.Page; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.ui.TextMenuList.TextMenuItem; +import funkin.ui.options.items.CheckboxPreferenceItem; +import funkin.ui.options.items.NumberPreferenceItem; +import funkin.ui.options.items.EnumPreferenceItem; +import lime.ui.WindowVSyncMode; + +class PreferencesMenu extends Page +{ + var items:TextMenuList; + var preferenceItems:FlxTypedSpriteGroup; + var preferenceDesc:Array = []; + var itemDesc:FlxText; + var itemDescBox:FunkinSprite; + + var menuCamera:FlxCamera; + var hudCamera:FlxCamera; + var camFollow:FlxObject; + + public function new() + { + super(); + + menuCamera = new FunkinCamera('prefMenu'); + FlxG.cameras.add(menuCamera, false); + menuCamera.bgColor = 0x0; + + hudCamera = new FlxCamera(); + FlxG.cameras.add(hudCamera, false); + hudCamera.bgColor = 0x0; + + camera = menuCamera; + + add(items = new TextMenuList()); + add(preferenceItems = new FlxTypedSpriteGroup()); + + add(itemDescBox = new FunkinSprite()); + itemDescBox.cameras = [hudCamera]; + + add(itemDesc = new FlxText(0, 0, 1180, null, 32)); + itemDesc.cameras = [hudCamera]; + + createPrefItems(); + createPrefDescription(); + + camFollow = new FlxObject(FlxG.width / 2, 0, 140, 70); + if (items != null) camFollow.y = items.selectedItem.y; + + menuCamera.follow(camFollow, null, 0.085); + var margin = 160; + menuCamera.deadzone.set(0, margin, menuCamera.width, menuCamera.height - margin * 2); + menuCamera.minScrollY = 0; + + items.onChange.add(function(selected) { + camFollow.y = selected.y; + itemDesc.text = preferenceDesc[items.selectedIndex]; + }); + } + + /** + * Create the description for preferences. + */ + function createPrefDescription():Void + { + itemDescBox.makeSolidColor(1, 1, FlxColor.BLACK); + itemDescBox.alpha = 0.6; + itemDesc.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.CENTER, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + itemDesc.borderSize = 3; + + // Update the text. + itemDesc.text = preferenceDesc[items.selectedIndex]; + itemDesc.screenCenter(); + itemDesc.y += 270; + + // Create the box around the text. + itemDescBox.setPosition(itemDesc.x - 10, itemDesc.y - 10); + itemDescBox.setGraphicSize(Std.int(itemDesc.width + 20), Std.int(itemDesc.height + 25)); + itemDescBox.updateHitbox(); + } + + /** + * Create the menu items for each of the preferences. + */ + function createPrefItems():Void + { + createPrefItemCheckbox('Naughtyness', 'If enabled, raunchy content (such as swearing, etc.) will be displayed.', function(value:Bool):Void { + Preferences.naughtyness = value; + }, Preferences.naughtyness); + createPrefItemCheckbox('Downscroll', 'If enabled, this will make the notes move downwards.', function(value:Bool):Void { + Preferences.downscroll = value; + }, Preferences.downscroll); + createPrefItemPercentage('Strumline Background', 'Give the strumline a semi-transparent background', function(value:Int):Void { + Preferences.strumlineBackgroundOpacity = value; + }, Preferences.strumlineBackgroundOpacity); + createPrefItemCheckbox('Flashing Lights', 'If disabled, it will dampen flashing effects. Useful for people with photosensitive epilepsy.', + function(value:Bool):Void { + Preferences.flashingLights = value; + }, Preferences.flashingLights); + createPrefItemCheckbox('Camera Zooms', 'If disabled, camera stops bouncing to the song.', function(value:Bool):Void { + Preferences.zoomCamera = value; + }, Preferences.zoomCamera); + createPrefItemCheckbox('Debug Display', 'If enabled, FPS and other debug stats will be displayed.', function(value:Bool):Void { + Preferences.debugDisplay = value; + }, Preferences.debugDisplay); + createPrefItemCheckbox('Pause on Unfocus', 'If enabled, game automatically pauses when it loses focus.', function(value:Bool):Void { + Preferences.autoPause = value; + }, Preferences.autoPause); + createPrefItemCheckbox('Launch in Fullscreen', 'Automatically launch the game in fullscreen on startup', function(value:Bool):Void { + Preferences.autoFullscreen = value; + }, Preferences.autoFullscreen); + + // disabled on macos due to "error: Late swap tearing currently unsupported" + #if !mac + createPrefItemEnum('VSync', 'If enabled, game will attempt to match framerate with your monitor.', [ + "Off" => WindowVSyncMode.OFF, + "On" => WindowVSyncMode.ON, + "Adaptive" => WindowVSyncMode.ADAPTIVE, + ], function(key:String, value:WindowVSyncMode):Void { + trace("Setting vsync mode to " + key); + Preferences.vsyncMode = value; + }, switch (Preferences.vsyncMode) + { + case WindowVSyncMode.OFF: "Off"; + case WindowVSyncMode.ON: "On"; + case WindowVSyncMode.ADAPTIVE: "Adaptive"; + }); + #end + #if web + createPrefItemCheckbox('Unlocked Framerate', 'If enabled, the framerate will be unlocked.', function(value:Bool):Void { + Preferences.unlockedFramerate = value; + }, Preferences.unlockedFramerate); + #else + createPrefItemNumber('FPS', 'The maximum framerate that the game targets.', function(value:Float) { + Preferences.framerate = Std.int(value); + }, null, Preferences.framerate, 30, 300, 5, 0); + #end + + createPrefItemCheckbox('Hide Mouse', 'If enabled, the mouse will be hidden when taking a screenshot.', function(value:Bool):Void { + Preferences.shouldHideMouse = value; + }, Preferences.shouldHideMouse); + createPrefItemCheckbox('Fancy Preview', 'If enabled, a preview will be shown after taking a screenshot.', function(value:Bool):Void { + Preferences.fancyPreview = value; + }, Preferences.fancyPreview); + createPrefItemCheckbox('Preview on save', 'If enabled, the preview will be shown only after a screenshot is saved.', function(value:Bool):Void { + Preferences.previewOnSave = value; + }, Preferences.previewOnSave); + // TODO: having oValue is weird, probably change this later? was done to accomodate VSync changes. + createPrefItemEnum('Save Format', 'Save screenshots to this format.', ['PNG' => 'PNG', 'JPEG' => 'JPEG'], function(value:String, oValue:String):Void { + Preferences.saveFormat = value; + }, Preferences.saveFormat); + createPrefItemNumber('JPEG Quality', 'The quality of JPEG screenshots.', function(value:Float) { + Preferences.jpegQuality = Std.int(value); + }, null, Preferences.jpegQuality, 0, 100, 5, 0); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Indent the selected item. + items.forEach(function(daItem:TextMenuItem) { + var thyOffset:Int = 0; + // Initializing thy text width (if thou text present) + var thyTextWidth:Int = 0; + switch (Type.typeof(daItem)) + { + case TClass(CheckboxPreferenceItem): + thyTextWidth = 0; + thyOffset = 0; + case TClass(EnumPreferenceItem): + thyTextWidth = cast(daItem, EnumPreferenceItem).lefthandText.getWidth(); + thyOffset = 0 + thyTextWidth - 75; + case TClass(NumberPreferenceItem): + thyTextWidth = cast(daItem, NumberPreferenceItem).lefthandText.getWidth(); + thyOffset = 0 + thyTextWidth - 75; + default: + // Huh? + } + + if (items.selectedItem == daItem) + { + thyOffset += 150; + } + else + { + thyOffset += 120; + } + + daItem.x = thyOffset; + }); + } + + // - Preference item creation methods - + // Should be moved into a separate PreferenceItems class but you can't access PreferencesMenu.items and PreferencesMenu.preferenceItems from outside. + + /** + * Creates a pref item that works with booleans + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + */ + function createPrefItemCheckbox(prefName:String, prefDesc:String, onChange:Bool->Void, defaultValue:Bool):Void + { + var checkbox:CheckboxPreferenceItem = new CheckboxPreferenceItem(0, 120 * (items.length - 1 + 1), defaultValue); + + items.createItem(0, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() { + var value = !checkbox.currentValue; + onChange(value); + checkbox.currentValue = value; + }); + + preferenceItems.add(checkbox); + preferenceDesc.push(prefDesc); + } + + /** + * Creates a pref item that works with general numbers + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed value looks + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + * @param min Minimum value (example: 0) + * @param max Maximum value (example: 10) + * @param step The value to increment/decrement by (default = 0.1) + * @param precision Rounds decimals up to a `precision` amount of digits (ex: 4 -> 0.1234, 2 -> 0.12) + */ + function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int, + step:Float = 0.1, precision:Int):Void + { + var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, step, precision, onChange, valueFormatter); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + preferenceDesc.push(prefDesc); + } + + /** + * Creates a pref item that works with number percentages + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + * @param min Minimum value (default = 0) + * @param max Maximum value (default = 100) + */ + function createPrefItemPercentage(prefName:String, prefDesc:String, onChange:Int->Void, defaultValue:Int, min:Int = 0, max:Int = 100):Void + { + var newCallback = function(value:Float) { + onChange(Std.int(value)); + }; + var formatter = function(value:Float) { + return '${value}%'; + }; + var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, 10, 0, newCallback, formatter); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + preferenceDesc.push(prefDesc); + } + + /** + * Creates a pref item that works with enums + * @param values Maps enum values to display strings _(ex: `NoteHitSoundType.PingPong => "Ping pong"`)_ + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + */ + function createPrefItemEnum(prefName:String, prefDesc:String, values:Map, onChange:String->T->Void, defaultKey:String):Void + { + var item = new EnumPreferenceItem(0, (120 * items.length) + 30, prefName, values, defaultKey, onChange); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + preferenceDesc.push(prefDesc); + } +} diff --git a/source/funkin/ui/options/items/CheckboxPreferenceItem.hx b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx new file mode 100644 index 00000000..c9bf4ffa --- /dev/null +++ b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx @@ -0,0 +1,49 @@ +package funkin.ui.options.items; + +import flixel.FlxSprite.FlxSprite; + +class CheckboxPreferenceItem extends FlxSprite +{ + public var currentValue(default, set):Bool; + + public function new(x:Float, y:Float, defaultValue:Bool = false) + { + super(x, y); + + frames = Paths.getSparrowAtlas('checkboxThingie'); + animation.addByPrefix('static', 'Check Box unselected', 24, false); + animation.addByPrefix('checked', 'Check Box selecting animation', 24, false); + + setGraphicSize(Std.int(width * 0.7)); + updateHitbox(); + + this.currentValue = defaultValue; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + switch (animation.curAnim.name) + { + case 'static': + offset.set(); + case 'checked': + offset.set(17, 70); + } + } + + function set_currentValue(value:Bool):Bool + { + if (value) + { + animation.play('checked', true); + } + else + { + animation.play('static'); + } + + return currentValue = value; + } +} diff --git a/source/funkin/ui/options/items/EnumPreferenceItem.hx b/source/funkin/ui/options/items/EnumPreferenceItem.hx new file mode 100644 index 00000000..4ec35c5e --- /dev/null +++ b/source/funkin/ui/options/items/EnumPreferenceItem.hx @@ -0,0 +1,88 @@ +package funkin.ui.options.items; + +import funkin.ui.TextMenuList.TextMenuItem; +import funkin.ui.AtlasText; +import funkin.input.Controls; + +/** + * Preference item that allows the player to pick a value from an enum (list of values) + */ +class EnumPreferenceItem extends TextMenuItem +{ + function controls():Controls + { + return PlayerSettings.player1.controls; + } + + public var lefthandText:AtlasText; + + public var currentKey:String; + public var onChangeCallback:NullT->Void>; + public var map:Map; + public var keys:Array = []; + + var index = 0; + + public function new(x:Float, y:Float, name:String, map:Map, defaultKey:String, ?callback:String->T->Void) + { + super(x, y, name, function() { + var value = map.get(this.currentKey); + callback(this.currentKey, value); + }); + + updateHitbox(); + + this.map = map; + this.currentKey = defaultKey; + this.onChangeCallback = callback; + + var i:Int = 0; + for (key in map.keys()) + { + var value:T = map[key]; + + this.keys.push(key); + if (this.currentKey == key) index = i; + i += 1; + } + + lefthandText = new AtlasText(15, y, formatted(defaultKey), AtlasFont.DEFAULT); + + this.fireInstantly = true; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // var fancyTextFancyColor:Color; + if (selected) + { + var shouldDecrease:Bool = controls().UI_LEFT_P; + var shouldIncrease:Bool = controls().UI_RIGHT_P; + + if (shouldDecrease) index -= 1; + if (shouldIncrease) index += 1; + + if (index > keys.length - 1) index = 0; + if (index < 0) index = keys.length - 1; + + currentKey = keys[index]; + if (onChangeCallback != null && (shouldIncrease || shouldDecrease)) + { + var value = map.get(currentKey); + onChangeCallback(currentKey, value); + } + } + + lefthandText.text = formatted(currentKey); + } + + function formatted(key:String):String + { + // FIXME: Can't add arrows around the text because the font doesn't support < > + // var leftArrow:String = selected ? '<' : ''; + // var rightArrow:String = selected ? '>' : ''; + return '${key}'; + } +} diff --git a/source/funkin/ui/options/items/NumberPreferenceItem.hx b/source/funkin/ui/options/items/NumberPreferenceItem.hx new file mode 100644 index 00000000..27d9ec50 --- /dev/null +++ b/source/funkin/ui/options/items/NumberPreferenceItem.hx @@ -0,0 +1,135 @@ +package funkin.ui.options.items; + +import funkin.ui.TextMenuList.TextMenuItem; +import funkin.ui.AtlasText; +import funkin.input.Controls; + +/** + * Preference item that allows the player to pick a value between min and max + */ +class NumberPreferenceItem extends TextMenuItem +{ + function controls():Controls + { + return PlayerSettings.player1.controls; + } + + // Widgets + public var lefthandText:AtlasText; + + // Constants + static final HOLD_DELAY:Float = 0.3; // seconds + static final CHANGE_RATE:Float = 0.08; // seconds + + // Constructor-initialized variables + public var currentValue:Float; + public var min:Float; + public var max:Float; + public var step:Float; + public var precision:Int; + public var onChangeCallback:NullVoid>; + public var valueFormatter:NullString>; + + // Variables + var holdDelayTimer:Float = HOLD_DELAY; // seconds + var changeRateTimer:Float = 0.0; // seconds + + /** + * @param min Minimum value (example: 0) + * @param max Maximum value (example: 100) + * @param step The value to increment/decrement by (example: 10) + * @param callback Will get called every time the user changes the setting; use this to apply/save the setting. + * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed string looks + */ + public function new(x:Float, y:Float, name:String, defaultValue:Float, min:Float, max:Float, step:Float, precision:Int, ?callback:Float->Void, + ?valueFormatter:Float->String):Void + { + super(x, y, name, function() { + callback(this.currentValue); + }); + lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT); + + updateHitbox(); + + this.currentValue = defaultValue; + this.min = min; + this.max = max; + this.step = step; + this.precision = precision; + this.onChangeCallback = callback; + this.valueFormatter = valueFormatter; + + this.fireInstantly = true; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + lefthandText.text = formatted(currentValue); + + if (!selected) return; + + holdDelayTimer -= elapsed; + if (holdDelayTimer <= 0.0) + { + changeRateTimer -= elapsed; + } + + var jpLeft:Bool = controls().UI_LEFT_P; + var jpRight:Bool = controls().UI_RIGHT_P; + + if (jpLeft || jpRight) + { + holdDelayTimer = HOLD_DELAY; + changeRateTimer = 0.0; + } + + var shouldDecrease:Bool = jpLeft; + var shouldIncrease:Bool = jpRight; + + if (controls().UI_LEFT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0) + { + shouldDecrease = true; + changeRateTimer = CHANGE_RATE; + } + else if (controls().UI_RIGHT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0) + { + shouldIncrease = true; + changeRateTimer = CHANGE_RATE; + } + + // Actually increasing/decreasing the value + if (shouldDecrease) + { + var isBelowMin:Bool = currentValue - step < min; + currentValue = (currentValue - step).clamp(min, max); + if (onChangeCallback != null && !isBelowMin) onChangeCallback(currentValue); + } + else if (shouldIncrease) + { + var isAboveMax:Bool = currentValue + step > max; + currentValue = (currentValue + step).clamp(min, max); + if (onChangeCallback != null && !isAboveMax) onChangeCallback(currentValue); + } + } + + /** Turns the float into a string */ + function formatted(value:Float):String + { + var float:Float = toFixed(value); + if (valueFormatter != null) + { + return valueFormatter(float); + } + else + { + return '${float}'; + } + } + + function toFixed(value:Float):Float + { + var multiplier:Float = Math.pow(10, precision); + return Math.floor(value * multiplier) / multiplier; + } +} diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx new file mode 100644 index 00000000..2d7a697f --- /dev/null +++ b/source/funkin/ui/story/Level.hx @@ -0,0 +1,242 @@ +package funkin.ui.story; + +import funkin.util.SortUtil; +import flixel.FlxSprite; +import flixel.util.FlxColor; +import funkin.play.song.Song; +import funkin.data.IRegistryEntry; +import funkin.data.song.SongRegistry; +import funkin.data.story.level.LevelRegistry; +import funkin.data.story.level.LevelData; + +/** + * An object used to retrieve data about a story mode level (also known as "weeks"). + * Can be scripted to override each function, for custom behavior. + */ +class Level implements IRegistryEntry +{ + /** + * @param id The ID of the JSON file to parse. + */ + public function new(id:String) + { + this.id = id; + _data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse level data for id: $id'; + } + } + + /** + * Get the list of songs in this level, as an array of IDs. + * @return Array + */ + public function getSongs():Array + { + // Copy the array so that it can't be modified on accident + return _data.songs.copy(); + } + + /** + * Retrieve the title of the level for display on the menu. + * @return Title of the level as a string + */ + public function getTitle():String + { + // TODO: Maybe add localization support? + return _data.name; + } + + /** + * Construct the title graphic for the level. + * @return The constructed graphic as a sprite. + */ + public function buildTitleGraphic():FlxSprite + { + var result:FlxSprite = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset)); + + return result; + } + + /** + * Get the list of songs in this level, as an array of names, for display on the menu. + * @param difficulty The difficulty of the level being displayed + * @return The display names of the songs in this level + */ + public function getSongDisplayNames(difficulty:String):Array + { + var songList:Array = getSongs() ?? []; + var songNameList:Array = songList.map(function(songId:String) { + return getSongDisplayName(songId, difficulty); + }); + return songNameList; + } + + static function getSongDisplayName(songId:String, difficulty:String):String + { + var song:Null = SongRegistry.instance.fetchEntry(songId); + if (song == null) return 'Unknown'; + + return song.songName; + } + + /** + * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon. + * Override this in a script. + * @default `true` + * @return Whether this level is unlocked + */ + public function isUnlocked():Bool + { + return true; + } + + /** + * Whether this level is visible. If not, it will not be shown on the menu at all. + * Override this in a script. + * @default `true` + * @return Whether this level is visible in the menu + */ + public function isVisible():Bool + { + return _data.visible; + } + + /** + * Build a sprite for the background of the level. + * Can be overriden by ScriptedLevel. Not used if `isBackgroundSimple` returns true. + * @return The constructed sprite + */ + public function buildBackground():FlxSprite + { + if (!_data.background.startsWith('#')) + { + // Image specified + return new FlxSprite().loadGraphic(Paths.image(_data.background)); + } + + // Color specified + var result:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 400, FlxColor.WHITE); + result.color = getBackgroundColor(); + return result; + } + + /** + * Returns true if the background is a solid color. + * If you have a ScriptedLevel with a fancy background, you may want to override this to false. + * @return Whether the background is a simple color + */ + public function isBackgroundSimple():Bool + { + return _data.background.startsWith('#'); + } + + /** + * Returns true if the background is a solid color. + * If you have a ScriptedLevel with a fancy background, you may want to override this to false. + * @return The background as a simple color. May not be valid if `isBackgroundSimple` returns false. + */ + public function getBackgroundColor():FlxColor + { + return FlxColor.fromString(_data.background); + } + + /** + * The list of difficulties the player can select from for this level. + * @return The difficulty IDs. + */ + public function getDifficulties():Array + { + var difficulties:Array = []; + + var songList:Array = getSongs(); + + var firstSongId:String = songList[0]; + var firstSong:Song = SongRegistry.instance.fetchEntry(firstSongId); + + if (firstSong != null) + { + // Don't display alternate characters in Story Mode. Only show `default` and `erect` variations. + for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, 'erect'], false, false)) + { + difficulties.push(difficulty); + } + } + + // Sort in a specific order! Fall back to alphabetical. + difficulties.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST)); + + // Filter to only include difficulties that are present in all songs + for (songIndex in 1...songList.length) + { + var songId:String = songList[songIndex]; + var song:Song = SongRegistry.instance.fetchEntry(songId); + + if (song == null) continue; + + for (difficulty in difficulties) + { + if (!song.hasDifficulty(difficulty, [Constants.DEFAULT_VARIATION, 'erect'])) + { + difficulties.remove(difficulty); + } + } + } + + if (difficulties.length == 0) difficulties = ['normal']; + + return difficulties; + } + + /** + * Build the props for display over the colored background. + * @param existingProps The existing prop sprites, if any. + * @return The constructed prop sprites + */ + public function buildProps(?existingProps:Array):Array + { + var props:Array = existingProps == null ? [] : [for (x in existingProps) x]; + + if (_data.props.length == 0) return props; + + var hiddenProps:Array = props.splice(_data.props.length - 1, props.length - 1); + for (hiddenProp in hiddenProps) + { + hiddenProp.visible = false; + } + + for (propIndex in 0..._data.props.length) + { + var propData:LevelPropData = _data.props[propIndex]; + + // Attempt to reuse the `LevelProp` object. + // This prevents animations from resetting. + var existingProp:Null = props[propIndex]; + if (existingProp != null) + { + existingProp.propData = propData; + if (existingProp.propData == null) + { + existingProp.visible = false; + } + else + { + existingProp.visible = true; + existingProp.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; + } + } + else + { + var propSprite:Null = LevelProp.build(propData); + if (propSprite == null) continue; + + propSprite.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex; + props.push(propSprite); + } + } + + return props; + } +} diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx new file mode 100644 index 00000000..167cda5a --- /dev/null +++ b/source/funkin/ui/story/LevelProp.hx @@ -0,0 +1,98 @@ +package funkin.ui.story; + +import funkin.play.stage.Bopper; +import funkin.util.assets.FlxAnimationUtil; +import funkin.data.story.level.LevelData.LevelPropData; + +class LevelProp extends Bopper +{ + public var propData(default, set):Null = null; + + function set_propData(value:LevelPropData):LevelPropData + { + // Only reset the prop if the asset path has changed. + if (propData == null || !(thx.Dynamics.equals(value, propData))) + { + this.propData = value; + + this.visible = this.propData != null; + danceEvery = this.propData?.danceEvery ?? 1.0; + + applyData(); + } + + return this.propData; + } + + public function new(propData:LevelPropData) + { + super(propData.danceEvery); + this.propData = propData; + } + + public function playConfirm():Void + { + if (hasAnimation('confirm')) playAnimation('confirm', true, true); + } + + function applyData():Void + { + if (propData == null) + { + this.visible = false; + return; + } + else + { + this.visible = true; + } + + // Reset animation state. + this.shouldAlternate = null; + + var isAnimated:Bool = propData.animations.length > 0; + if (isAnimated) + { + // Initalize sprite frames. + // Sparrow atlas only LEL. + this.frames = Paths.getSparrowAtlas(propData.assetPath); + } + else + { + // Initalize static sprite. + this.loadGraphic(Paths.image(propData.assetPath)); + + // Disables calls to update() for a performance boost. + this.active = false; + } + + if (this.frames == null || this.frames.numFrames == 0) + { + trace('ERROR: Could not build texture for level prop (${propData.assetPath}).'); + return; + } + + var scale:Float = propData.scale * (propData.isPixel ? 6 : 1); + this.scale.set(scale, scale); + this.antialiasing = !propData.isPixel; + this.alpha = propData.alpha; + this.x = propData.offsets[0]; + this.y = propData.offsets[1]; + + FlxAnimationUtil.addAtlasAnimations(this, propData.animations); + for (propAnim in propData.animations) + { + this.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + + this.dance(); + this.animation.paused = true; + } + + public static function build(propData:Null):Null + { + if (propData == null) return null; + + return new LevelProp(propData); + } +} diff --git a/source/funkin/ui/story/LevelTitle.hx b/source/funkin/ui/story/LevelTitle.hx new file mode 100644 index 00000000..6e5fb6c0 --- /dev/null +++ b/source/funkin/ui/story/LevelTitle.hx @@ -0,0 +1,90 @@ +package funkin.ui.story; + +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup; +import flixel.util.FlxColor; +import funkin.util.MathUtil; + +class LevelTitle extends FlxSpriteGroup +{ + static final LOCK_PAD:Int = 4; + + public final level:Level; + + public var targetY:Float; + + var title:FlxSprite; + var lock:FlxSprite; + + public function new(x:Int, y:Int, level:Level) + { + super(x, y); + + this.level = level; + + if (this.level == null) throw "Level cannot be null!"; + + buildLevelTitle(); + buildLevelLock(); + } + + override function get_width():Float + { + if (length == 0) return 0; + + if (lock.visible) + { + return title.width + lock.width + LOCK_PAD; + } + else + { + return title.width; + } + } + + public var isFlashing:Bool = false; + + var flashTick:Float = 0; + final flashFramerate:Float = 20; + + public override function update(elapsed:Float):Void + { + this.y = MathUtil.coolLerp(y, targetY, 0.17); + + if (isFlashing) + { + flashTick += elapsed; + if (flashTick >= 1 / flashFramerate) + { + flashTick %= 1 / flashFramerate; + title.color = (title.color == FlxColor.WHITE) ? 0xFF33ffff : FlxColor.WHITE; + } + } + } + + public function showLock():Void + { + lock.visible = true; + this.x -= (lock.width + LOCK_PAD) / 2; + } + + public function hideLock():Void + { + lock.visible = false; + this.x += (lock.width + LOCK_PAD) / 2; + } + + function buildLevelTitle():Void + { + title = level.buildTitleGraphic(); + add(title); + } + + function buildLevelLock():Void + { + lock = new FlxSprite(0, 0).loadGraphic(Paths.image('storymenu/ui/lock')); + lock.x = title.x + title.width + LOCK_PAD; + lock.visible = false; + add(lock); + } +} diff --git a/source/funkin/ui/story/ScriptedLevel.hx b/source/funkin/ui/story/ScriptedLevel.hx new file mode 100644 index 00000000..a9921741 --- /dev/null +++ b/source/funkin/ui/story/ScriptedLevel.hx @@ -0,0 +1,9 @@ +package funkin.ui.story; + +/** + * A script that can be tied to a Level, which persists across states. + * Create a scripted class that extends Level to use this. + * This allows you to customize how a specific level appears. + */ +@:hscriptClass +class ScriptedLevel extends funkin.ui.story.Level implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx new file mode 100644 index 00000000..c0fbeef7 --- /dev/null +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -0,0 +1,665 @@ +package funkin.ui.story; + +import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxSprite; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.story.level.LevelRegistry; +import funkin.data.song.SongRegistry; +import funkin.graphics.FunkinSprite; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.PlayStatePlaylist; +import funkin.play.song.Song; +import funkin.save.Save; +import funkin.save.Save.SaveScoreData; +import funkin.ui.mainmenu.MainMenuState; +import funkin.ui.MusicBeatState; +import funkin.ui.transition.LoadingState; +import funkin.ui.transition.stickers.StickerSubState; +import funkin.util.MathUtil; +import openfl.utils.Assets; +#if FEATURE_DISCORD_RPC +import funkin.api.discord.DiscordClient; +#end + +class StoryMenuState extends MusicBeatState +{ + static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString('#F9CF51'); + static final BACKGROUND_HEIGHT:Int = 400; + + var currentDifficultyId:String = 'normal'; + + var currentLevelId:String = 'tutorial'; + var currentLevel:Level; + var isLevelUnlocked:Bool; + var currentLevelTitle:LevelTitle; + + var highScore:Int = 42069420; + var highScoreLerp:Int = 12345678; + + var exitingMenu:Bool = false; + var selectedLevel:Bool = false; + + // + // RENDER OBJECTS + // + + /** + * The title of the level at the top. + */ + var levelTitleText:FlxText; + + /** + * The score text at the top. + */ + var scoreText:FlxText; + + /** + * The mode text at the top-middle. + */ + var modeText:FlxText; + + /** + * The list of songs on the left. + */ + var tracklistText:FlxText; + + /** + * The titles of the levels in the middle. + */ + var levelTitles:FlxTypedGroup; + + /** + * The props in the center. + */ + var levelProps:FlxTypedGroup; + + /** + * The background behind the props. + */ + var levelBackground:FlxSprite; + + /** + * The left arrow of the difficulty selector. + */ + var leftDifficultyArrow:FlxSprite; + + /** + * The right arrow of the difficulty selector. + */ + var rightDifficultyArrow:FlxSprite; + + /** + * The text of the difficulty selector. + */ + var difficultySprite:FlxSprite; + + /** + * List of available level IDs. + */ + var levelList:Array = []; + + var difficultySprites:Map; + + var stickerSubState:StickerSubState; + + static var rememberedLevelId:Null = null; + static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; + + public function new(?stickers:StickerSubState = null) + { + super(); + + if (stickers?.members != null) + { + stickerSubState = stickers; + } + } + + override function create():Void + { + super.create(); + + levelList = LevelRegistry.instance.listSortedLevelIds(); + levelList = levelList.filter(function(id) { + var levelData = LevelRegistry.instance.fetchEntry(id); + if (levelData == null) return false; + + return levelData.isVisible(); + }); + if (levelList.length == 0) levelList = ['tutorial']; // Make sure there's at least one level to display. + + difficultySprites = new Map(); + + transIn = FlxTransitionableState.defaultTransIn; + transOut = FlxTransitionableState.defaultTransOut; + + playMenuMusic(); + + if (stickerSubState != null) + { + this.persistentUpdate = true; + this.persistentDraw = true; + + openSubState(stickerSubState); + stickerSubState.degenStickers(); + } + + persistentUpdate = persistentDraw = true; + + rememberSelection(); + + updateData(); + + // Explicitly define the background color. + this.bgColor = FlxColor.BLACK; + + levelTitles = new FlxTypedGroup(); + levelTitles.zIndex = 15; + add(levelTitles); + + updateBackground(); + + var black:FunkinSprite = new FunkinSprite(levelBackground.x, 0).makeSolidColor(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK); + black.zIndex = levelBackground.zIndex - 1; + add(black); + + levelProps = new FlxTypedGroup(); + levelProps.zIndex = 1000; + add(levelProps); + + updateProps(); + + tracklistText = new FlxText(FlxG.width * 0.05, levelBackground.x + levelBackground.height + 100, 0, "Tracks", 32); + tracklistText.setFormat('VCR OSD Mono', 32); + tracklistText.alignment = CENTER; + tracklistText.color = 0xFFE55777; + add(tracklistText); + + scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420'); + scoreText.setFormat('VCR OSD Mono', 32); + scoreText.zIndex = 1000; + add(scoreText); + + levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1'); + levelTitleText.setFormat('VCR OSD Mono', 32, FlxColor.WHITE, RIGHT); + levelTitleText.alpha = 0.7; + levelTitleText.zIndex = 1000; + add(levelTitleText); + + buildLevelTitles(); + + leftDifficultyArrow = new FlxSprite(870, 480); + leftDifficultyArrow.frames = Paths.getSparrowAtlas('storymenu/ui/arrows'); + leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0'); + leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0'); + leftDifficultyArrow.animation.play('idle'); + add(leftDifficultyArrow); + + buildDifficultySprite(Constants.DEFAULT_DIFFICULTY); + buildDifficultySprite(); + + rightDifficultyArrow = new FlxSprite(1245, leftDifficultyArrow.y); + rightDifficultyArrow.frames = leftDifficultyArrow.frames; + rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0'); + rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0'); + rightDifficultyArrow.animation.play('idle'); + add(rightDifficultyArrow); + + add(difficultySprite); + + updateText(); + changeDifficulty(); + changeLevel(); + refresh(); + + #if FEATURE_DISCORD_RPC + // Updating Discord Rich Presence + DiscordClient.instance.setPresence({state: 'In the Menus', details: null}); + #end + } + + function rememberSelection():Void + { + if (rememberedLevelId != null) + { + currentLevelId = rememberedLevelId; + } + if (rememberedDifficulty != null) + { + currentDifficultyId = rememberedDifficulty; + } + } + + function playMenuMusic():Void + { + FunkinSound.playMusic('freakyMenu', + { + overrideExisting: true, + restartTrack: false, + // Continue playing this music between states, until a different music track gets played. + persist: true + }); + } + + function updateData():Void + { + currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId); + if (currentLevel == null) throw 'Could not fetch data for level: ${currentLevelId}'; + isLevelUnlocked = currentLevel == null ? false : currentLevel.isUnlocked(); + } + + function buildDifficultySprite(?diff:String):Void + { + if (diff == null) diff = currentDifficultyId; + remove(difficultySprite); + difficultySprite = difficultySprites.get(diff); + if (difficultySprite == null) + { + difficultySprite = new FlxSprite(leftDifficultyArrow.x + leftDifficultyArrow.width + 10, leftDifficultyArrow.y); + + if (Assets.exists(Paths.file('images/storymenu/difficulties/${diff}.xml'))) + { + difficultySprite.frames = Paths.getSparrowAtlas('storymenu/difficulties/${diff}'); + difficultySprite.animation.addByPrefix('idle', 'idle0', 24, true); + if (Preferences.flashingLights) difficultySprite.animation.play('idle'); + } + else + { + difficultySprite.loadGraphic(Paths.image('storymenu/difficulties/${diff}')); + } + + difficultySprites.set(diff, difficultySprite); + + difficultySprite.x += (difficultySprites.get(Constants.DEFAULT_DIFFICULTY).width - difficultySprite.width) / 2; + } + difficultySprite.alpha = 0; + + difficultySprite.y = leftDifficultyArrow.y - 15; + var targetY:Float = leftDifficultyArrow.y + 10; + targetY -= (difficultySprite.height - difficultySprites.get(Constants.DEFAULT_DIFFICULTY).height) / 2; + FlxTween.tween(difficultySprite, {y: targetY, alpha: 1}, 0.07); + + add(difficultySprite); + } + + function buildLevelTitles():Void + { + levelTitles.clear(); + + for (levelIndex in 0...levelList.length) + { + var levelId:String = levelList[levelIndex]; + var level:Level = LevelRegistry.instance.fetchEntry(levelId); + if (level == null || !level.isVisible()) continue; + + // TODO: Readd lock icon if unlocked is false. + + var levelTitleItem:LevelTitle = new LevelTitle(0, Std.int(levelBackground.y + levelBackground.height + 10), level); + levelTitleItem.targetY = ((levelTitleItem.height + 20) * levelIndex); + levelTitleItem.screenCenter(X); + levelTitles.add(levelTitleItem); + } + } + + override function update(elapsed:Float):Void + { + Conductor.instance.update(); + + highScoreLerp = Std.int(MathUtil.smoothLerp(highScoreLerp, highScore, elapsed, 0.25)); + + scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}'; + + levelTitleText.text = currentLevel.getTitle(); + levelTitleText.x = FlxG.width - (levelTitleText.width + 10); // Right align. + + handleKeyPresses(); + + super.update(elapsed); + } + + function handleKeyPresses():Void + { + if (!exitingMenu) + { + if (!selectedLevel) + { + if (controls.UI_UP_P) + { + changeLevel(-1); + changeDifficulty(0); + } + + if (controls.UI_DOWN_P) + { + changeLevel(1); + changeDifficulty(0); + } + + #if !html5 + if (FlxG.mouse.wheel != 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel)); + } + #else + if (FlxG.mouse.wheel < 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel / 8)); + } + else if (FlxG.mouse.wheel > 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel / 8)); + } + #end + + // TODO: Querying UI_RIGHT_P (justPressed) after UI_RIGHT always returns false. Fix it! + if (controls.UI_RIGHT_P) + { + changeDifficulty(1); + } + + if (controls.UI_LEFT_P) + { + changeDifficulty(-1); + } + + if (controls.UI_RIGHT) + { + rightDifficultyArrow.animation.play('press'); + } + else + { + rightDifficultyArrow.animation.play('idle'); + } + + if (controls.UI_LEFT) + { + leftDifficultyArrow.animation.play('press'); + } + else + { + leftDifficultyArrow.animation.play('idle'); + } + } + + if (controls.ACCEPT) + { + selectLevel(); + } + } + + if (controls.BACK && !exitingMenu && !selectedLevel) + { + exitingMenu = true; + FlxG.switchState(() -> new MainMenuState()); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } + } + + /** + * Changes the selected level. + * @param change +1 (down), -1 (up) + */ + function changeLevel(change:Int = 0):Void + { + var currentIndex:Int = levelList.indexOf(currentLevelId); + var prevIndex:Int = currentIndex; + + currentIndex += change; + + // Wrap around + if (currentIndex < 0) currentIndex = levelList.length - 1; + if (currentIndex >= levelList.length) currentIndex = 0; + + var previousLevelId:String = currentLevelId; + currentLevelId = levelList[currentIndex]; + rememberedLevelId = currentLevelId; + + updateData(); + + for (index in 0...levelTitles.members.length) + { + var item:LevelTitle = levelTitles.members[index]; + + item.targetY = (index - currentIndex) * 125 + 480; + + if (index == currentIndex) + { + currentLevelTitle = item; + item.alpha = 1.0; + } + else + { + item.alpha = 0.6; + } + } + + if (currentIndex != prevIndex) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + + updateText(); + updateBackground(previousLevelId); + updateProps(); + refresh(); + } + + /** + * Changes the selected difficulty. + * @param change +1 (right) to increase difficulty, -1 (left) to decrease difficulty + */ + function changeDifficulty(change:Int = 0):Void + { + // "For now, NO erect in story mode" -Dave + + var difficultyList:Array = Constants.DEFAULT_DIFFICULTY_LIST; + // Use this line to displays all difficulties + // var difficultyList:Array = currentLevel.getDifficulties(); + var currentIndex:Int = difficultyList.indexOf(currentDifficultyId); + + currentIndex += change; + + // Wrap around + if (currentIndex < 0) currentIndex = difficultyList.length - 1; + if (currentIndex >= difficultyList.length) currentIndex = 0; + + var hasChanged:Bool = currentDifficultyId != difficultyList[currentIndex]; + currentDifficultyId = difficultyList[currentIndex]; + rememberedDifficulty = currentDifficultyId; + + if (difficultyList.length <= 1) + { + leftDifficultyArrow.visible = false; + rightDifficultyArrow.visible = false; + } + else + { + leftDifficultyArrow.visible = true; + rightDifficultyArrow.visible = true; + } + + if (hasChanged) + { + buildDifficultySprite(); + FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + // Disable the funny music thing for now. + // funnyMusicThing(); + } + + updateText(); + refresh(); + } + + final FADE_OUT_TIME:Float = 1.5; + + function funnyMusicThing():Void + { + if (currentDifficultyId == "nightmare") + { + FlxG.sound.music.fadeOut(FADE_OUT_TIME, 0.0); + } + else + { + FlxG.sound.music.fadeOut(FADE_OUT_TIME, 1.0); + } + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + if (levelProps?.members != null && levelProps.members.length > 0) + { + // Dispatch event to props. + for (prop in levelProps.members) + { + ScriptEventDispatcher.callEvent(prop, event); + } + } + } + + function selectLevel():Void + { + if (!currentLevel.isUnlocked()) + { + FunkinSound.playOnce(Paths.sound('cancelMenu')); + return; + } + + if (selectedLevel) return; + + selectedLevel = true; + + FunkinSound.playOnce(Paths.sound('confirmMenu')); + + currentLevelTitle.isFlashing = true; + + for (prop in levelProps.members) + { + prop.playConfirm(); + } + + Paths.setCurrentLevel(currentLevel.id); + + PlayStatePlaylist.playlistSongIds = currentLevel.getSongs(); + PlayStatePlaylist.isStoryMode = true; + PlayStatePlaylist.campaignScore = 0; + + var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift(); + + var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId); + + PlayStatePlaylist.campaignId = currentLevel.id; + PlayStatePlaylist.campaignTitle = currentLevel.getTitle(); + PlayStatePlaylist.campaignDifficulty = currentDifficultyId; + + Highscore.talliesLevel = new funkin.Highscore.Tallies(); + + new FlxTimer().start(1, function(tmr:FlxTimer) { + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; + + var targetVariation:String = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty); + + LoadingState.loadPlayState( + { + targetSong: targetSong, + targetDifficulty: PlayStatePlaylist.campaignDifficulty, + targetVariation: targetVariation + }, true); + }); + } + + function updateBackground(?previousLevelId:String = ''):Void + { + if (levelBackground == null || previousLevelId == '') + { + // Build a new background and display it immediately. + levelBackground = currentLevel.buildBackground(); + levelBackground.x = 0; + levelBackground.y = 56; + levelBackground.zIndex = 100; + levelBackground.alpha = 1.0; // Not hidden. + add(levelBackground); + } + else + { + var previousLevel = LevelRegistry.instance.fetchEntry(previousLevelId); + + if (currentLevel.isBackgroundSimple() && previousLevel.isBackgroundSimple()) + { + var previousColor:FlxColor = previousLevel.getBackgroundColor(); + var currentColor:FlxColor = currentLevel.getBackgroundColor(); + if (previousColor != currentColor) + { + // Both the previous and current level were simple backgrounds. + // Fade between colors directly, rather than fading one background out and another in. + // cancels potential tween in progress, and tweens from there + FlxTween.cancelTweensOf(levelBackground); + FlxTween.color(levelBackground, 0.9, levelBackground.color, currentColor, {ease: FlxEase.quartOut}); + } + else + { + // Do no fade at all if the colors aren't different. + } + } + else + { + // Either the previous or current level has a complex background. + // We need to fade the old background out and the new one in. + + // Reference the old background and fade it out. + var oldBackground:FlxSprite = levelBackground; + FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6, + { + ease: FlxEase.linear, + onComplete: function(_) { + remove(oldBackground); + } + }); + + // Build a new background and fade it in. + levelBackground = currentLevel.buildBackground(); + levelBackground.x = 0; + levelBackground.y = 56; + levelBackground.alpha = 0.0; // Hidden to start. + levelBackground.zIndex = 100; + add(levelBackground); + + FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6, + { + ease: FlxEase.linear + }); + } + } + } + + function updateProps():Void + { + for (ind => prop in currentLevel.buildProps(levelProps.members)) + { + prop.zIndex = 1000; + if (levelProps.members[ind] != prop) levelProps.replace(levelProps.members[ind], prop) ?? levelProps.add(prop); + } + + refresh(); + } + + function updateText():Void + { + tracklistText.text = 'TRACKS\n\n'; + tracklistText.text += currentLevel.getSongDisplayNames(currentDifficultyId).join('\n'); + + tracklistText.screenCenter(X); + tracklistText.x -= FlxG.width * 0.35; + + var levelScore:Null = Save.instance.getLevelScore(currentLevelId, currentDifficultyId); + highScore = levelScore?.score ?? 0; + // levelScore.accuracy + } +} diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx new file mode 100644 index 00000000..443feace --- /dev/null +++ b/source/funkin/ui/title/AttractState.hx @@ -0,0 +1,132 @@ +package funkin.ui.title; + +#if html5 +import funkin.graphics.video.FlxVideo; +#end +#if hxvlc +import funkin.graphics.video.FunkinVideoSprite; +#end +import funkin.ui.MusicBeatState; + +/** + * After about 2 minutes of inactivity on the title screen, + * the game will enter the Attract state, as a reference to physical arcade machines. + * + * In the current version, this just plays the ~~Kickstarter trailer~~ Erect teaser, but this can be changed to + * gameplay footage, a generic game trailer, or something more elaborate. + */ +class AttractState extends MusicBeatState +{ + #if html5 + static final ATTRACT_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('toyCommercial')); + #end + + #if hxvlc + static final ATTRACT_VIDEO_PATH:String = Paths.videos('toyCommercial'); + #end + + public override function create():Void + { + // Pause existing music. + if (FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + #if html5 + trace('Playing web video ${ATTRACT_VIDEO_PATH}'); + playVideoHTML5(ATTRACT_VIDEO_PATH); + #end + + #if hxvlc + trace('Playing native video ${ATTRACT_VIDEO_PATH}'); + playVideoNative(ATTRACT_VIDEO_PATH); + #end + } + + #if html5 + var vid:FlxVideo; + + function playVideoHTML5(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FlxVideo(filePath); + if (vid != null) + { + vid.zIndex = 0; + + vid.finishCallback = onAttractEnd; + + add(vid); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + #if hxvlc + var vid:FunkinVideoSprite; + + function playVideoNative(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FunkinVideoSprite(0, 0); + + if (vid != null) + { + vid.zIndex = 0; + vid.bitmap.onEndReached.add(onAttractEnd); + + add(vid); + if (vid.load(filePath)) vid.play(); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // If the user presses any button, skip the video. + if (FlxG.keys.justPressed.ANY && !controls.VOLUME_MUTE && !controls.VOLUME_UP && !controls.VOLUME_DOWN) + { + onAttractEnd(); + } + } + + /** + * When the attraction state ends (after the video ends or the user presses any button), + * switch immediately to the title screen. + */ + function onAttractEnd():Void + { + #if html5 + if (vid != null) + { + remove(vid); + } + #end + + #if hxvlc + if (vid != null) + { + vid.stop(); + remove(vid); + } + #end + + #if (html5 || hxvlc) + vid.destroy(); + vid = null; + #end + + FlxG.switchState(() -> new TitleState()); + } +} diff --git a/source/funkin/ui/title/FlxSpriteOverlay.hx b/source/funkin/ui/title/FlxSpriteOverlay.hx new file mode 100644 index 00000000..1900e25f --- /dev/null +++ b/source/funkin/ui/title/FlxSpriteOverlay.hx @@ -0,0 +1,74 @@ +package funkin.ui.title; + +import flixel.FlxSprite; +import funkin.graphics.shaders.BlendModesShader; +import openfl.display.BitmapData; +import flixel.FlxCamera; +import flixel.FlxG; +import flixel.graphics.frames.FlxFrame.FlxFrameAngle; + +class FlxSpriteOverlay extends FlxSprite +{ + var blendShader:BlendModesShader; + var dipshitBitmap:BitmapData; + var temp:FlxSprite; + + public function new(x:Float, y:Float) + { + super(x, y); + temp = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, 0xFF000000); + blendShader = new BlendModesShader(); + dipshitBitmap = new BitmapData(2180, 1720, true, 0xFFCC00CC); + } + + override function drawComplex(camera:FlxCamera):Void + { + _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY()); + _matrix.translate(-origin.x, -origin.y); + _matrix.scale(scale.x, scale.y); + if (bakedRotationAngle <= 0) + { + updateTrig(); + if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle); + } + getScreenPosition(_point, camera).subtractPoint(offset); + _point.add(origin.x, origin.y); + _matrix.translate(_point.x, _point.y); + if (isPixelPerfectRender(camera)) + { + _matrix.tx = Math.floor(_matrix.tx); + _matrix.ty = Math.floor(_matrix.ty); + } + + var sprRect = getScreenBounds(); + + // dipshitBitmap.draw(camera.canvas, camera.canvas.transform.matrix); + // blendShader.setCamera(dipshitBitmap); + + // FlxG.bitmapLog.add(dipshitBitmap); + + camera.drawPixels(_frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader); + } + + function copyToFlash(rect):openfl.geom.Rectangle + { + var flashRect = new openfl.geom.Rectangle(); + flashRect.x = rect.x; + flashRect.y = rect.y; + flashRect.width = rect.width; + flashRect.height = rect.height; + return flashRect; + } + + override public function isSimpleRender(?camera:FlxCamera):Bool + { + if (FlxG.renderBlit) + { + return super.isSimpleRender(camera); + } + else + { + return false; + } + } +} diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx new file mode 100644 index 00000000..fef121ad --- /dev/null +++ b/source/funkin/ui/title/TitleState.hx @@ -0,0 +1,490 @@ +package funkin.ui.title; + +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.input.gamepad.FlxGamepad; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxDirectionFlags; +import flixel.util.FlxTimer; +import flixel.util.typeLimit.NextState; +import funkin.audio.visualize.SpectogramSprite; +import funkin.graphics.shaders.ColorSwap; +import funkin.graphics.shaders.LeftMaskShader; +import funkin.graphics.FunkinSprite; +import funkin.ui.MusicBeatState; +import funkin.graphics.shaders.TitleOutline; +import funkin.audio.FunkinSound; +import funkin.ui.AtlasText; +import openfl.Assets; +import openfl.display.Sprite; +import openfl.events.AsyncErrorEvent; +import funkin.ui.mainmenu.MainMenuState; +import openfl.events.MouseEvent; +import openfl.events.NetStatusEvent; +import openfl.media.Video; +import openfl.net.NetStream; +#if FEATURE_NEWGROUNDS +import funkin.api.newgrounds.Medals; +#end +import funkin.ui.freeplay.FreeplayState; +import openfl.display.BlendMode; +import funkin.save.Save; + +#if desktop +#end +class TitleState extends MusicBeatState +{ + /** + * Only play the credits once per session. + */ + public static var initialized:Bool = false; + + var blackScreen:FlxSprite; + var credGroup:FlxGroup; + var textGroup:FlxGroup; + var ngSpr:FlxSprite; + + var curWacky:Array = []; + var lastBeat:Int = 0; + var swagShader:ColorSwap; + + override public function create():Void + { + super.create(); + swagShader = new ColorSwap(); + + curWacky = FlxG.random.getObject(getIntroTextShit()); + FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu')); + FlxG.sound.cache(Paths.music('girlfriendsRingtone/girlfriendsRingtone')); + + // DEBUG BULLSHIT + + if (!initialized) new FlxTimer().start(1, function(tmr:FlxTimer) { + startIntro(); + }); + else + startIntro(); + } + + var logoBl:FlxSprite; + var outlineShaderShit:TitleOutline; + + var gfDance:FlxSpriteOverlay; + var danceLeft:Bool = false; + var titleText:FlxSprite; + var maskShader = new LeftMaskShader(); + + function startIntro():Void + { + if (!initialized || FlxG.sound.music == null) playMenuMusic(); + + persistentUpdate = true; + + var bg:FunkinSprite = new FunkinSprite(-1).makeSolidColor(FlxG.width + 2, FlxG.height, FlxColor.BLACK); + bg.screenCenter(); + add(bg); + + logoBl = new FlxSprite(-150, -100); + logoBl.frames = Paths.getSparrowAtlas('logoBumpin'); + logoBl.animation.addByPrefix('bump', 'logo bumpin', 24); + logoBl.animation.play('bump'); + logoBl.shader = swagShader.shader; + logoBl.updateHitbox(); + + outlineShaderShit = new TitleOutline(); + + gfDance = new FlxSpriteOverlay(FlxG.width * 0.4, FlxG.height * 0.07); + gfDance.frames = Paths.getSparrowAtlas('gfDanceTitle'); + gfDance.animation.addByIndices('danceLeft', 'gfDance', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); + gfDance.animation.addByIndices('danceRight', 'gfDance', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); + + // maskShader.swagSprX = gfDance.x; + // maskShader.swagMaskX = gfDance.x + 200; + // maskShader.frameUV = gfDance.frame.uv; + // gfDance.shader = maskShader; + + gfDance.shader = swagShader.shader; + + // gfDance.shader = new TitleOutline(); + + add(logoBl); + + add(gfDance); + + titleText = new FlxSprite(100, FlxG.height * 0.8); + titleText.frames = Paths.getSparrowAtlas('titleEnter'); + titleText.animation.addByPrefix('idle', "Press Enter to Begin", 24); + titleText.animation.addByPrefix('press', "ENTER PRESSED", 24); + titleText.animation.play('idle'); + titleText.updateHitbox(); + titleText.shader = swagShader.shader; + // titleText.screenCenter(X); + add(titleText); + + if (!initialized) // Fix an issue where returning to the credits would play a black screen. + { + credGroup = new FlxGroup(); + add(credGroup); + } + + textGroup = new FlxGroup(); + + blackScreen = bg.clone(); + if (credGroup != null) + { + credGroup.add(blackScreen); + credGroup.add(textGroup); + } + + ngSpr = new FlxSprite(0, FlxG.height * 0.52); + + if (FlxG.random.bool(1)) + { + ngSpr.loadGraphic(Paths.image('newgrounds_logo_classic')); + } + else if (FlxG.random.bool(30)) + { + ngSpr.loadGraphic(Paths.image('newgrounds_logo_animated'), true, 600); + ngSpr.animation.add('idle', [0, 1], 4); + ngSpr.animation.play('idle'); + ngSpr.setGraphicSize(Std.int(ngSpr.width * 0.55)); + ngSpr.y += 25; + } + else + { + ngSpr.loadGraphic(Paths.image('newgrounds_logo')); + ngSpr.setGraphicSize(Std.int(ngSpr.width * 0.8)); + } + + add(ngSpr); + ngSpr.visible = false; + + ngSpr.updateHitbox(); + ngSpr.screenCenter(X); + + FlxG.mouse.visible = false; + + if (initialized) skipIntro(); + else + initialized = true; + + if (FlxG.sound.music != null) FlxG.sound.music.onComplete = moveToAttract; + } + + /** + * After sitting on the title screen for a while, transition to the attract screen. + */ + function moveToAttract():Void + { + FlxG.switchState(() -> new AttractState()); + } + + function playMenuMusic():Void + { + var shouldFadeIn:Bool = (FlxG.sound.music == null); + // Load music. Includes logic to handle BPM changes. + FunkinSound.playMusic('freakyMenu', + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: false, + // Continue playing this music between states, until a different music track gets played. + persist: true + }); + // Fade from 0.0 to 1 over 4 seconds + if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0); + } + + function getIntroTextShit():Array> + { + var fullText:String = Assets.getText(Paths.txt('introText')); + + // Split into lines and remove empty lines + var firstArray:Array = fullText.split('\n').filter(function(s:String) return s != ''); + var swagGoodArray:Array> = []; + + for (i in firstArray) + { + swagGoodArray.push(i.split('--')); + } + + return swagGoodArray; + } + + var transitioning:Bool = false; + + override function update(elapsed:Float):Void + { + FlxG.bitmapLog.add(FlxG.camera.buffer); + + #if desktop + // Pressing BACK on the title screen should close the game. + // This lets you exit without leaving fullscreen mode. + // Only applicable on desktop. + if (controls.BACK) + { + openfl.Lib.application.window.close(); + } + #end + + Conductor.instance.update(); + + /* if (FlxG.onMobile) + { + if (gfDance != null) + { + gfDance.x = (FlxG.width / 2) + (FlxG.accelerometer.x * (FlxG.width / 2)); + // gfDance.y = (FlxG.height / 2) + (FlxG.accelerometer.y * (FlxG.height / 2)); + } + } + */ + if (FlxG.keys.justPressed.I) + { + FlxTween.tween(outlineShaderShit, {funnyX: 50, funnyY: 50}, 0.6, {ease: FlxEase.quartOut}); + } + if (FlxG.keys.pressed.D) outlineShaderShit.funnyX += 1; + // outlineShaderShit.xPos.value[0] += 1; + + if (FlxG.keys.justPressed.Y) + { + FlxTween.cancelTweensOf(FlxG.stage.window, ['x', 'y']); + FlxTween.tween(FlxG.stage.window, {x: FlxG.stage.window.x + 300}, 1.4, {ease: FlxEase.quadInOut, type: PINGPONG, startDelay: 0.35}); + FlxTween.tween(FlxG.stage.window, {y: FlxG.stage.window.y + 100}, 0.7, {ease: FlxEase.quadInOut, type: PINGPONG}); + } + + if (FlxG.sound.music != null) Conductor.instance.update(FlxG.sound.music.time); + + // do controls.PAUSE | controls.ACCEPT instead? + var pressedEnter:Bool = FlxG.keys.justPressed.ENTER; + + var gamepad:FlxGamepad = FlxG.gamepads.lastActive; + + if (gamepad != null) + { + if (gamepad.justPressed.START) pressedEnter = true; + } + + // If you spam Enter, we should skip the transition. + if (pressedEnter && transitioning && skippedIntro) + { + FlxG.switchState(() -> new MainMenuState()); + } + + if (pressedEnter && !transitioning && skippedIntro) + { + if (FlxG.sound.music != null) FlxG.sound.music.onComplete = null; + // netStream.play(Paths.file('music/kickstarterTrailer.mp4')); + titleText.animation.play('press'); + FlxG.camera.flash(FlxColor.WHITE, 1); + FunkinSound.playOnce(Paths.sound('confirmMenu'), 0.7); + transitioning = true; + + #if FEATURE_NEWGROUNDS + // Award the "Start Game" medal. + Medals.award(Medal.StartGame); + funkin.api.newgrounds.Events.logStartGame(); + #end + + var targetState:NextState = () -> new MainMenuState(); + + new FlxTimer().start(2, function(tmr:FlxTimer) { + // These assets are very unlikely to be used for the rest of gameplay, so it unloads them from cache/memory + // Saves about 50mb of RAM or so??? + // TODO: This BREAKS the title screen if you return back to it! Figure out how to fix that. + // Assets.cache.clear(Paths.image('gfDanceTitle')); + // Assets.cache.clear(Paths.image('logoBumpin')); + // Assets.cache.clear(Paths.image('titleEnter')); + // ngSpr?? + FlxG.switchState(targetState); + }); + // FunkinSound.playOnce(Paths.music('titleShoot'), 0.7); + } + if (pressedEnter && !skippedIntro && initialized) skipIntro(); + + if (controls.UI_LEFT) swagShader.update(-elapsed * 0.1); + if (controls.UI_RIGHT) swagShader.update(elapsed * 0.1); + if (!cheatActive && skippedIntro) cheatCodeShit(); + super.update(elapsed); + } + + override function draw() + { + super.draw(); + } + + var cheatArray:Array = [0x0001, 0x0010, 0x0001, 0x0010, 0x0100, 0x1000, 0x0100, 0x1000]; + var curCheatPos:Int = 0; + var cheatActive:Bool = false; + + function cheatCodeShit():Void + { + if (FlxG.keys.justPressed.ANY) + { + if (controls.NOTE_DOWN_P || controls.UI_DOWN_P) codePress(FlxDirectionFlags.DOWN); + if (controls.NOTE_UP_P || controls.UI_UP_P) codePress(FlxDirectionFlags.UP); + if (controls.NOTE_LEFT_P || controls.UI_LEFT_P) codePress(FlxDirectionFlags.LEFT); + if (controls.NOTE_RIGHT_P || controls.UI_RIGHT_P) codePress(FlxDirectionFlags.RIGHT); + } + } + + function codePress(input:Int) + { + if (input == cheatArray[curCheatPos]) + { + curCheatPos += 1; + if (curCheatPos >= cheatArray.length) startCheat(); + } + else + curCheatPos = 0; + + trace(input); + } + + function startCheat():Void + { + cheatActive = true; + + var spec:SpectogramSprite = new SpectogramSprite(FlxG.sound.music); + + FunkinSound.playMusic('girlfriendsRingtone', + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: true + }); + + FlxG.sound.music.fadeIn(4.0, 0.0, 1.0); + + FlxG.camera.flash(FlxColor.WHITE, 1); + FunkinSound.playOnce(Paths.sound('confirmMenu'), 0.7); + } + + function createCoolText(textArray:Array) + { + if (credGroup == null || textGroup == null) return; + + for (i in 0...textArray.length) + { + var money:AtlasText = new AtlasText(0, 0, textArray[i], AtlasFont.BOLD); + money.screenCenter(X); + money.y += (i * 60) + 200; + // credGroup.add(money); + textGroup.add(money); + } + } + + function addMoreText(text:String) + { + if (credGroup == null || textGroup == null) return; + + lime.ui.Haptic.vibrate(100, 100); + + var coolText:AtlasText = new AtlasText(0, 0, text.trim(), AtlasFont.BOLD); + coolText.screenCenter(X); + coolText.y += (textGroup.length * 60) + 200; + textGroup.add(coolText); + } + + function deleteCoolText() + { + if (credGroup == null || textGroup == null) return; + + while (textGroup.members.length > 0) + { + // credGroup.remove(textGroup.members[0], true); + textGroup.remove(textGroup.members[0], true); + } + } + + var isRainbow:Bool = false; + var skippedIntro:Bool = false; + + override function beatHit():Bool + { + // super.beatHit() returns false if a module cancelled the event. + if (!super.beatHit()) return false; + + if (!skippedIntro) + { + // FlxG.log.add(Conductor.instance.currentBeat); + // if the user is draggin the window some beats will + // be missed so this is just to compensate + if (Conductor.instance.currentBeat > lastBeat) + { + // TODO: Why does it perform ALL the previous steps each beat? + for (i in lastBeat...Conductor.instance.currentBeat) + { + switch (i + 1) + { + case 1: + createCoolText(['The', 'Funkin Crew Inc']); + case 3: + addMoreText('presents'); + case 4: + deleteCoolText(); + case 5: + createCoolText(['In association', 'with']); + case 7: + addMoreText('newgrounds'); + if (ngSpr != null) ngSpr.visible = true; + case 8: + deleteCoolText(); + if (ngSpr != null) ngSpr.visible = false; + case 9: + createCoolText([curWacky[0]]); + case 11: + addMoreText(curWacky[1]); + case 12: + deleteCoolText(); + case 13: + addMoreText('Friday'); + case 14: + // easter egg for when the game is trending with the wrong spelling + // the random intro text would be "trending--only on x" + + if (curWacky[0] == "trending") addMoreText('Nigth'); + else + addMoreText('Night'); + case 15: + addMoreText('Funkin'); + case 16: + skipIntro(); + } + } + } + lastBeat = Conductor.instance.currentBeat; + } + if (skippedIntro) + { + if (cheatActive && Conductor.instance.currentBeat % 2 == 0) swagShader.update(0.125); + + if (logoBl != null && logoBl.animation != null) logoBl.animation.play('bump', true); + + danceLeft = !danceLeft; + + if (gfDance != null && gfDance.animation != null) + { + if (danceLeft) gfDance.animation.play('danceRight'); + else + gfDance.animation.play('danceLeft'); + } + } + + return true; + } + + function skipIntro():Void + { + if (!skippedIntro) + { + remove(ngSpr); + + FlxG.camera.flash(FlxColor.WHITE, initialized ? 1 : 4); + + if (credGroup != null) remove(credGroup); + skippedIntro = true; + } + } +} diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx new file mode 100644 index 00000000..7957ab6c --- /dev/null +++ b/source/funkin/ui/transition/LoadingState.hx @@ -0,0 +1,541 @@ +package funkin.ui.transition; + +import flixel.FlxSprite; +import flixel.math.FlxMath; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import flixel.util.typeLimit.NextState; +import funkin.graphics.FunkinSprite; +import funkin.graphics.shaders.ScreenWipeShader; +import funkin.play.PlayState; +import funkin.play.PlayStatePlaylist; +import funkin.play.song.Song.SongDifficulty; +import haxe.io.Path; +import lime.app.Future; +import lime.app.Promise; +import lime.utils.AssetLibrary; +import lime.utils.AssetManifest; +import lime.utils.Assets as LimeAssets; +import openfl.filters.ShaderFilter; +import openfl.utils.Assets as OpenFLAssets; + +class LoadingState extends MusicBeatSubState +{ + inline static var MIN_TIME = 1.0; + + var asSubState:Bool = false; + + var target:NextState; + var playParams:Null; + var stopMusic:Bool = false; + var callbacks:MultiCallback; + var danceLeft:Bool = false; + + var loadBar:FlxSprite; + var funkay:FlxSprite; + + function new(target:NextState, stopMusic:Bool, playParams:Null = null) + { + super(); + this.target = target; + this.playParams = playParams; + this.stopMusic = stopMusic; + } + + override function create():Void + { + var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d); + add(bg); + + funkay = FunkinSprite.create('funkay'); + funkay.setGraphicSize(0, FlxG.height); + funkay.updateHitbox(); + add(funkay); + funkay.scrollFactor.set(); + funkay.screenCenter(); + + loadBar = new FunkinSprite(0, FlxG.height - 20).makeSolidColor(0, 10, 0xFFff16d2); + add(loadBar); + + initSongsManifest().onComplete(function(lib) { + callbacks = new MultiCallback(onLoad); + var introComplete = callbacks.add('introComplete'); + + if (playParams != null) + { + // Load and cache the song's charts. + if (playParams.targetSong != null) + { + playParams.targetSong.cacheCharts(true); + } + + // Preload the song for the play state. + var difficulty:String = playParams.targetDifficulty ?? Constants.DEFAULT_DIFFICULTY; + var variation:String = playParams.targetVariation ?? Constants.DEFAULT_VARIATION; + var targetChart:SongDifficulty = playParams.targetSong?.getDifficulty(difficulty, variation); + var instPath:String = targetChart.getInstPath(playParams.targetInstrumental); + var voicesPaths:Array = targetChart.buildVoiceList(); + + checkLoadSong(instPath); + for (voicePath in voicesPaths) + { + checkLoadSong(voicePath); + } + } + + checkLibrary('shared'); + checkLibrary(stageDirectory); + checkLibrary('tutorial'); + + var fadeTime:Float = 0.5; + FlxG.camera.fade(FlxG.camera.bgColor, fadeTime, true); + new FlxTimer().start(fadeTime + MIN_TIME, function(_) introComplete()); + }); + } + + function checkLoadSong(path:String):Void + { + if (!OpenFLAssets.cache.hasSound(path)) + { + var library = Assets.getLibrary('songs'); + var symbolPath = path.split(':').pop(); + // @:privateAccess + // library.types.set(symbolPath, SOUND); + // @:privateAccess + // library.pathGroups.set(symbolPath, [library.__cacheBreak(symbolPath)]); + var callback = callbacks.add('song:' + path); + Assets.loadSound(path).onComplete(function(_) { + callback(); + }); + } + } + + function checkLibrary(library:String):Void + { + trace(Assets.hasLibrary(library)); + if (Assets.getLibrary(library) == null) + { + @:privateAccess + if (!LimeAssets.libraryPaths.exists(library)) throw 'Missing library: ' + library; + + var callback = callbacks.add('library:' + library); + Assets.loadLibrary(library).onComplete(function(_) { + callback(); + }); + } + } + + override function beatHit():Bool + { + // super.beatHit() returns false if a module cancelled the event. + if (!super.beatHit()) return false; + + danceLeft = !danceLeft; + + return true; + } + + var targetShit:Float = 0; + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + funkay.setGraphicSize(Std.int(FlxMath.lerp(FlxG.width * 0.88, funkay.width, 0.9))); + funkay.updateHitbox(); + // funkay.updateHitbox(); + + if (controls.ACCEPT) + { + funkay.setGraphicSize(Std.int(funkay.width + 60)); + funkay.updateHitbox(); + // funkay.setGraphicSize(0, Std.int(funkay.height + 50)); + // funkay.updateHitbox(); + // funkay.screenCenter(); + } + + if (callbacks != null) + { + targetShit = FlxMath.remapToRange(callbacks.numRemaining / callbacks.length, 1, 0, 0, 1); + + var lerpWidth:Int = Std.int(FlxMath.lerp(loadBar.width, FlxG.width * targetShit, 0.2)); + // this if-check prevents the setGraphicSize function + // from setting the width of the loadBar to the height of the loadBar + // this is a behaviour that is implemented in the setGraphicSize function + // if the width parameter is equal to 0 + if (lerpWidth > 0) + { + loadBar.setGraphicSize(lerpWidth, loadBar.height); + loadBar.updateHitbox(); + } + FlxG.watch.addQuick('percentage?', callbacks.numRemaining / callbacks.length); + } + } + + function onLoad():Void + { + // Stop the instrumental. + if (stopMusic && FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + if (asSubState) + { + this.close(); + // We will assume the target is a valid substate. + FlxG.state.openSubState(cast target); + } + else + { + FlxG.switchState(target); + } + } + + static function getSongPath():String + { + return Paths.inst(PlayState.instance.currentSong.id); + } + + static var stageDirectory:String = "shared"; + + /** + * Starts the transition to a new `PlayState` to start a new song. + * First switches to the `LoadingState` if assets need to be loaded. + * @param params The parameters for the next `PlayState`. + * @param asSubState Whether to open as a substate rather than switching to the `PlayState`. + * @param shouldStopMusic Whether to stop the current music while loading. + */ + public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false, asSubState = false, ?onConstruct:PlayState->Void):Void + { + var daChart:Null = params.targetSong.getDifficulty(params.targetDifficulty ?? Constants.DEFAULT_DIFFICULTY, + params.targetVariation ?? Constants.DEFAULT_VARIATION); + + var daStage = funkin.data.stage.StageRegistry.instance.fetchEntry(daChart?.stage ?? Constants.DEFAULT_STAGE); + stageDirectory = daStage?._data?.directory ?? "shared"; + Paths.setCurrentLevel(stageDirectory); + + var playStateCtor:() -> PlayState = function() { + return new PlayState(params); + }; + + if (onConstruct != null) + { + playStateCtor = function() { + var result = new PlayState(params); + onConstruct(result); + return result; + }; + } + + #if NO_PRELOAD_ALL + // Switch to loading state while we load assets (default on HTML5 target). + var loadStateCtor = function() { + var result = new LoadingState(playStateCtor, shouldStopMusic, params); + @:privateAccess + result.asSubState = asSubState; + return result; + } + if (asSubState) + { + FlxG.state.openSubState(cast loadStateCtor()); + } + else + { + FlxG.switchState(loadStateCtor); + } + #else + // All assets preloaded, switch directly to play state (defualt on other targets). + if (shouldStopMusic && FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + // Load and cache the song's charts. + // Don't do this if we already provided the music and charts. + if (params?.targetSong != null && !params.overrideMusic) + { + params.targetSong.cacheCharts(true); + } + + var shouldPreloadLevelAssets:Bool = !(params?.minimalMode ?? false); + + if (shouldPreloadLevelAssets) preloadLevelAssets(); + + if (asSubState) + { + FlxG.state.openSubState(cast playStateCtor()); + } + else + { + FlxG.switchState(playStateCtor); + } + #end + } + + #if NO_PRELOAD_ALL + static function isSoundLoaded(path:String):Bool + { + return OpenFLAssets.cache.hasSound(path); + } + + static function isLibraryLoaded(library:String):Bool + { + return Assets.getLibrary(library) != null; + } + #else + static function preloadLevelAssets():Void + { + // TODO: This section is a hack! Redo this later when we have a proper asset caching system. + FunkinSprite.preparePurgeCache(); + FunkinSprite.cacheTexture(Paths.image('healthBar')); + FunkinSprite.cacheTexture(Paths.image('menuDesat')); + // Lord have mercy on me and this caching -anysad + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/combo')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num0')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num1')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num2')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num3')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num4')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num5')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num6')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num7')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num8')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num9')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/combo')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num0')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num1')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num2')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num3')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num4')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num5')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num6')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num7')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num8')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num9')); + FunkinSprite.cacheTexture(Paths.image('notes', 'shared')); + FunkinSprite.cacheTexture(Paths.image('noteSplashes', 'shared')); + FunkinSprite.cacheTexture(Paths.image('noteStrumline', 'shared')); + FunkinSprite.cacheTexture(Paths.image('NOTE_hold_assets')); + + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/ready', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/set', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/go', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/ready', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/set', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/go', 'shared')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/shit')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/shit')); + + // List all image assets in the level's library. + // This is crude and I want to remove it when we have a proper asset caching system. + // TODO: Get rid of this junk! + var library = PlayStatePlaylist.campaignId != null ? openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId) : null; + + if (library == null) return; // We don't need to do anymore precaching. + + var assets = library.list(lime.utils.AssetType.IMAGE); + trace('Got ${assets.length} assets: ${assets}'); + + // TODO: assets includes non-images! This is a bug with Polymod + for (asset in assets) + { + // Exclude items of the wrong type. + var path = '${PlayStatePlaylist.campaignId}:${asset}'; + // TODO DUMB HACK DUMB HACK why doesn't filtering by AssetType.IMAGE above work + // I will fix this properly later I swear -eric + if (!path.endsWith('.png')) continue; + + new Future(function() { + FunkinSprite.cacheTexture(path); + // Another dumb hack: FlxAnimate fetches from OpenFL's BitmapData cache directly and skips the FlxGraphic cache. + // Since FlxGraphic tells OpenFL to not cache it, we have to do it manually. + if (path.endsWith('spritemap1.png')) + { + trace('Preloading FlxAnimate asset: ${path}'); + openfl.Assets.getBitmapData(path, true); + } + return 'Done precaching ${path}'; + }, true); + + trace('Queued ${path} for precaching'); + // FunkinSprite.cacheTexture(path); + } + + // FunkinSprite.cacheAllNoteStyleTextures(noteStyle) // This will replace the stuff above! + // FunkinSprite.cacheAllCharacterTextures(player) + // FunkinSprite.cacheAllCharacterTextures(girlfriend) + // FunkinSprite.cacheAllCharacterTextures(opponent) + // FunkinSprite.cacheAllStageTextures(stage) + // FunkinSprite.cacheAllSongTextures(stage) + + FunkinSprite.purgeCache(); + } + #end + + override function destroy():Void + { + super.destroy(); + + callbacks = null; + } + + static function initSongsManifest():Future + { + var id = 'songs'; + var promise = new Promise(); + + var library = LimeAssets.getLibrary(id); + + if (library != null) + { + return Future.withValue(library); + } + + var path = id; + var rootPath = null; + + @:privateAccess + var libraryPaths = LimeAssets.libraryPaths; + if (libraryPaths.exists(id)) + { + path = libraryPaths[id]; + rootPath = Path.directory(path); + } + else + { + if (path.endsWith('.bundle')) + { + rootPath = path; + path += '/library.json'; + } + else + { + rootPath = Path.directory(path); + } + @:privateAccess + path = LimeAssets.__cacheBreak(path); + } + + AssetManifest.loadFromFile(path, rootPath).onComplete(function(manifest) { + if (manifest == null) + { + promise.error('Cannot parse asset manifest for library \'' + id + '\''); + return; + } + + var library = AssetLibrary.fromManifest(manifest); + + if (library == null) + { + promise.error('Cannot open library \'' + id + '\''); + } + else + { + @:privateAccess + LimeAssets.libraries.set(id, library); + library.onChange.add(LimeAssets.onChange.dispatch); + promise.completeWith(Future.withValue(library)); + } + }).onError(function(_) { + promise.error('There is no asset library with an ID of \'' + id + '\''); + }); + + return promise.future; + } + + public static function transitionToState(state:NextState, stopMusic:Bool = false):Void + { + FlxG.switchState(() -> new LoadingState(state, stopMusic)); + } +} + +class MultiCallback +{ + public var callback:Void->Void; + public var logId:String = null; + public var length(default, null) = 0; + public var numRemaining(default, null) = 0; + + var unfired = new MapVoid>(); + var fired = new Array(); + + public function new(callback:Void->Void, logId:String = null) + { + this.callback = callback; + this.logId = logId; + } + + public function add(id = 'untitled'):Void->Void + { + id = '$length:$id'; + length++; + numRemaining++; + var func:Void->Void = null; + func = function() { + if (unfired.exists(id)) + { + unfired.remove(id); + fired.push(id); + numRemaining--; + + if (logId != null) log('fired $id, $numRemaining remaining'); + + if (numRemaining == 0) + { + if (logId != null) log('all callbacks fired'); + callback(); + } + } + else + log('already fired $id'); + } + unfired[id] = func; + return func; + } + + inline function log(msg):Void + { + if (logId != null) trace('$logId: $msg'); + } + + public function getFired():Array + return fired.copy(); + + public function getUnfired():ArrayVoid> + return unfired.array(); + + /** + * Perform an FlxG.switchState with a nice transition + * @param state + * @param transitionTex + * @param time + */ + public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2) + { + var screenShit:FunkinSprite = FunkinSprite.create('shaderTransitionStuff/coolDots'); + var screenWipeShit:ScreenWipeShader = new ScreenWipeShader(); + + screenWipeShit.funnyShit.input = screenShit.pixels; + FlxTween.tween(screenWipeShit, {daAlphaShit: 1}, time, + { + ease: FlxEase.quadInOut, + onComplete: function(twn) { + screenShit.destroy(); + FlxG.switchState(state); + } + }); + FlxG.camera.filters = [new ShaderFilter(screenWipeShit)]; + } +} diff --git a/source/funkin/ui/transition/preload/FunkinPreloader.hx b/source/funkin/ui/transition/preload/FunkinPreloader.hx new file mode 100644 index 00000000..61ca4c71 --- /dev/null +++ b/source/funkin/ui/transition/preload/FunkinPreloader.hx @@ -0,0 +1,1176 @@ +package funkin.ui.transition.preload; + +import openfl.events.MouseEvent; +import flash.display.Bitmap; +import flash.display.BitmapData; +import flash.display.Sprite; +import flash.Lib; +import flixel.system.FlxBasePreloader; +import funkin.util.MathUtil; +import openfl.display.Sprite; +import openfl.text.TextField; +import openfl.text.TextFormat; +import openfl.text.TextFormatAlign; + +using StringTools; + +// Annotation embeds the asset in the executable for faster loading. +// Polymod can't override this, so we can't use this technique elsewhere. + +@:bitmap("art/preloaderArt.png") +class LogoImage extends BitmapData {} + +#if TOUCH_HERE_TO_PLAY +@:bitmap('art/touchHereToPlay.png') +class TouchHereToPlayImage extends BitmapData {} +#end + +/** + * This preloader displays a logo while the game downloads assets. + */ +class FunkinPreloader extends FlxBasePreloader +{ + /** + * The logo image width at the base resolution. + * Scaled up/down appropriately as needed. + */ + static final BASE_WIDTH:Float = 1280; + + /** + * Margin at the sides and bottom, around the loading bar. + */ + static final BAR_PADDING:Float = 20; + + static final BAR_HEIGHT:Int = 12; + + /** + * Logo takes this long (in seconds) to fade in. + */ + static final LOGO_FADE_TIME:Float = 2.5; + + // Ratio between window size and BASE_WIDTH + var ratio:Float = 0; + + var currentState:FunkinPreloaderState = FunkinPreloaderState.NotStarted; + + // private var downloadingAssetsStartTime:Float = -1; + private var downloadingAssetsPercent:Float = -1; + private var downloadingAssetsComplete:Bool = false; + + private var preloadingPlayAssetsPercent:Float = -1; + private var preloadingPlayAssetsStartTime:Float = -1; + private var preloadingPlayAssetsComplete:Bool = false; + + private var cachingGraphicsPercent:Float = -1; + private var cachingGraphicsStartTime:Float = -1; + private var cachingGraphicsComplete:Bool = false; + + private var cachingAudioPercent:Float = -1; + private var cachingAudioStartTime:Float = -1; + private var cachingAudioComplete:Bool = false; + + private var cachingDataPercent:Float = -1; + private var cachingDataStartTime:Float = -1; + private var cachingDataComplete:Bool = false; + + private var parsingSpritesheetsPercent:Float = -1; + private var parsingSpritesheetsStartTime:Float = -1; + private var parsingSpritesheetsComplete:Bool = false; + + private var parsingStagesPercent:Float = -1; + private var parsingStagesStartTime:Float = -1; + private var parsingStagesComplete:Bool = false; + + private var parsingCharactersPercent:Float = -1; + private var parsingCharactersStartTime:Float = -1; + private var parsingCharactersComplete:Bool = false; + + private var parsingSongsPercent:Float = -1; + private var parsingSongsStartTime:Float = -1; + private var parsingSongsComplete:Bool = false; + + private var initializingScriptsPercent:Float = -1; + + private var cachingCoreAssetsPercent:Float = -1; + + /** + * The timestamp when the other steps completed and the `Finishing up` step started. + */ + private var completeTime:Float = -1; + + // Graphics + var logo:Bitmap; + #if TOUCH_HERE_TO_PLAY + var touchHereToPlay:Bitmap; + var touchHereSprite:Sprite; + #end + var progressBarPieces:Array; + var progressBar:Bitmap; + var progressLeftText:TextField; + var progressRightText:TextField; + + var dspText:TextField; + var fnfText:TextField; + var enhancedText:TextField; + var stereoText:TextField; + + var vfdShader:VFDOverlay; + var vfdBitmap:Bitmap; + var box:Sprite; + var progressLines:Sprite; + + public function new() + { + super(Constants.PRELOADER_MIN_STAGE_TIME); + + // We can't even call trace() yet, until Flixel loads. + trace('Initializing custom preloader...'); + + this.siteLockTitleText = Constants.SITE_LOCK_TITLE; + this.siteLockBodyText = Constants.SITE_LOCK_DESC; + } + + override function create():Void + { + // Nothing happens in the base preloader. + super.create(); + + // Background color. + Lib.current.stage.color = Constants.COLOR_PRELOADER_BG; + + // Width and height of the preloader. + this._width = Lib.current.stage.stageWidth; + this._height = Lib.current.stage.stageHeight; + + // Scale assets to the screen size. + ratio = this._width / BASE_WIDTH / 2.0; + + // Create the logo. + logo = createBitmap(LogoImage, function(bmp:Bitmap) { + // Scale and center the logo. + // We have to do this inside the async call, after the image size is known. + bmp.scaleX = bmp.scaleY = ratio; + bmp.x = (this._width - bmp.width) / 2; + bmp.y = (this._height - bmp.height) / 2; + }); + // addChild(logo); + + var amountOfPieces:Int = 16; + progressBarPieces = []; + var maxBarWidth = this._width - BAR_PADDING * 2; + var pieceWidth = maxBarWidth / amountOfPieces; + var pieceGap:Int = 8; + + progressLines = new openfl.display.Sprite(); + progressLines.graphics.lineStyle(2, Constants.COLOR_PRELOADER_BAR); + progressLines.graphics.drawRect(-2, 480, this._width + 4, 30); + addChild(progressLines); + + var progressBarPiece = new Sprite(); + progressBarPiece.graphics.beginFill(Constants.COLOR_PRELOADER_BAR); + progressBarPiece.graphics.drawRoundRect(0, 0, pieceWidth - pieceGap, BAR_HEIGHT, 4, 4); + progressBarPiece.graphics.endFill(); + + for (i in 0...amountOfPieces) + { + var piece = new Sprite(); + piece.graphics.beginFill(Constants.COLOR_PRELOADER_BAR); + piece.graphics.drawRoundRect(0, 0, pieceWidth - pieceGap, BAR_HEIGHT, 4, 4); + piece.graphics.endFill(); + + piece.x = i * (piece.width + pieceGap); + piece.y = this._height - BAR_PADDING - BAR_HEIGHT - 200; + addChild(piece); + progressBarPieces.push(piece); + } + + // Create the progress bar. + // progressBar = new Bitmap(new BitmapData(1, BAR_HEIGHT, true, Constants.COLOR_PRELOADER_BAR)); + // progressBar.x = BAR_PADDING; + // progressBar.y = this._height - BAR_PADDING - BAR_HEIGHT; + // addChild(progressBar); + + // Create the progress message. + progressLeftText = new TextField(); + dspText = new TextField(); + fnfText = new TextField(); + enhancedText = new TextField(); + stereoText = new TextField(); + + var progressLeftTextFormat = new TextFormat("DS-Digital", 32, Constants.COLOR_PRELOADER_BAR, true); + progressLeftTextFormat.align = TextFormatAlign.LEFT; + progressLeftText.defaultTextFormat = progressLeftTextFormat; + + progressLeftText.selectable = false; + progressLeftText.width = this._width - BAR_PADDING * 2; + progressLeftText.text = 'Downloading assets...'; + progressLeftText.x = BAR_PADDING; + progressLeftText.y = this._height - BAR_PADDING - BAR_HEIGHT - 290; + // progressLeftText.shader = new VFDOverlay(); + addChild(progressLeftText); + + // Create the progress %. + progressRightText = new TextField(); + + var progressRightTextFormat = new TextFormat("DS-Digital", 16, Constants.COLOR_PRELOADER_BAR, true); + progressRightTextFormat.align = TextFormatAlign.RIGHT; + progressRightText.defaultTextFormat = progressRightTextFormat; + + progressRightText.selectable = false; + progressRightText.width = this._width - BAR_PADDING * 2; + progressRightText.text = '0%'; + progressRightText.x = BAR_PADDING; + progressRightText.y = this._height - BAR_PADDING - BAR_HEIGHT - 16 - 4; + addChild(progressRightText); + + box = new Sprite(); + box.graphics.beginFill(Constants.COLOR_PRELOADER_BAR, 1); + box.graphics.drawRoundRect(0, 0, 64, 20, 5, 5); + box.graphics.drawRoundRect(70, 0, 58, 20, 5, 5); + box.graphics.endFill(); + box.graphics.beginFill(Constants.COLOR_PRELOADER_BAR, 0.1); + box.graphics.drawRoundRect(0, 0, 128, 20, 5, 5); + box.graphics.endFill(); + box.x = 880; + box.y = 440; + addChild(box); + + dspText.selectable = false; + dspText.textColor = 0x000000; + dspText.width = this._width; + dspText.height = 30; + dspText.text = 'DSP'; + dspText.x = 10; + dspText.y = -7; + box.addChild(dspText); + + fnfText.selectable = false; + fnfText.textColor = 0x000000; + fnfText.width = this._width; + fnfText.height = 30; + fnfText.x = 78; + fnfText.y = -7; + fnfText.text = 'FNF'; + box.addChild(fnfText); + + enhancedText.selectable = false; + enhancedText.textColor = Constants.COLOR_PRELOADER_BAR; + enhancedText.width = this._width; + enhancedText.height = 100; + enhancedText.text = 'ENHANCED'; + enhancedText.x = -100; + enhancedText.y = 0; + box.addChild(enhancedText); + + stereoText.selectable = false; + stereoText.textColor = Constants.COLOR_PRELOADER_BAR; + stereoText.width = this._width; + stereoText.height = 100; + stereoText.text = 'STEREO'; + stereoText.x = 0; + stereoText.y = -40; + box.addChild(stereoText); + + // var dummyMatrix:openfl.geom.Matrix = new Matrix(); + // dummyMatrix.createGradientBox(this._width, this._height * 0.1, 90 * Math.PI / 180); + + // var gradient:Sprite = new Sprite(); + // gradient.graphics.beginGradientFill(GradientType.LINEAR, [0xFFFFFF, 0x000000], [1, 1], [0, 255], dummyMatrix, SpreadMethod.REFLECT); + // gradient.graphics.drawRect(0, 0, this._width, this._height); + // gradient.graphics.endFill(); + // addChild(gradient); + + vfdBitmap = new Bitmap(new BitmapData(this._width, this._height, true, 0xFFFFFFFF)); + addChild(vfdBitmap); + + vfdShader = new VFDOverlay(); + vfdBitmap.shader = vfdShader; + + #if TOUCH_HERE_TO_PLAY + touchHereToPlay = createBitmap(TouchHereToPlayImage, function(bmp:Bitmap) { + // Scale and center the touch to start image. + // We have to do this inside the async call, after the image size is known. + bmp.scaleX = bmp.scaleY = ratio; + bmp.x = (this._width - bmp.width) / 2; + bmp.y = (this._height - bmp.height) / 2; + }); + touchHereToPlay.alpha = 0.0; + + touchHereSprite = new Sprite(); + touchHereSprite.buttonMode = false; + touchHereSprite.addChild(touchHereToPlay); + addChild(touchHereSprite); + #end + } + + var lastElapsed:Float = 0.0; + + override function update(percent:Float):Void + { + var elapsed:Float = (Date.now().getTime() - this._startTime) / 1000.0; + + vfdShader.update(elapsed * 100); + // trace('Time since last frame: ' + (lastElapsed - elapsed)); + + downloadingAssetsPercent = percent; + var loadPercent:Float = updateState(percent, elapsed); + updateGraphics(loadPercent, elapsed); + + lastElapsed = elapsed; + } + + function updateState(percent:Float, elapsed:Float):Float + { + switch (currentState) + { + case FunkinPreloaderState.NotStarted: + if (downloadingAssetsPercent > 0.0) currentState = FunkinPreloaderState.DownloadingAssets; + + return percent; + + case FunkinPreloaderState.DownloadingAssets: + // Sometimes percent doesn't go to 100%, it's a floating point error. + if (downloadingAssetsPercent >= 1.0 + || (elapsed > Constants.PRELOADER_MIN_STAGE_TIME + && downloadingAssetsComplete)) currentState = FunkinPreloaderState.PreloadingPlayAssets; + + return percent; + + case FunkinPreloaderState.PreloadingPlayAssets: + if (preloadingPlayAssetsPercent < 0.0) + { + preloadingPlayAssetsStartTime = elapsed; + preloadingPlayAssetsPercent = 0.0; + + // This is quick enough to do synchronously. + // Assets.initialize(); + + /* + // Make a future to retrieve the manifest + var future:Future = Assets.preloadLibrary('gameplay'); + + future.onProgress((loaded:Int, total:Int) -> { + preloadingPlayAssetsPercent = loaded / total; + }); + future.onComplete((library:lime.utils.AssetLibrary) -> { + }); + */ + + // TODO: Reimplement this. + preloadingPlayAssetsPercent = 1.0; + preloadingPlayAssetsComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedPreloadingPlayAssets:Float = elapsed - preloadingPlayAssetsStartTime; + if (preloadingPlayAssetsComplete && elapsedPreloadingPlayAssets >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.InitializingScripts; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (preloadingPlayAssetsPercent < (elapsedPreloadingPlayAssets / Constants.PRELOADER_MIN_STAGE_TIME)) return preloadingPlayAssetsPercent; + else + return elapsedPreloadingPlayAssets / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + else + { + if (preloadingPlayAssetsComplete) currentState = FunkinPreloaderState.InitializingScripts; + } + + return preloadingPlayAssetsPercent; + + case FunkinPreloaderState.InitializingScripts: + if (initializingScriptsPercent < 0.0) + { + initializingScriptsPercent = 0.0; + + /* + var future:Future> = []; // PolymodHandler.loadNoModsAsync(); + + future.onProgress((loaded:Int, total:Int) -> { + trace('PolymodHandler.loadNoModsAsync() progress: ' + loaded + '/' + total); + initializingScriptsPercent = loaded / total; + }); + future.onComplete((result:Array) -> { + trace('Completed initializing scripts: ' + result); + }); + */ + + initializingScriptsPercent = 1.0; + currentState = FunkinPreloaderState.CachingGraphics; + return 0.0; + } + + return initializingScriptsPercent; + + case CachingGraphics: + if (cachingGraphicsPercent < 0) + { + cachingGraphicsPercent = 0.0; + cachingGraphicsStartTime = elapsed; + + /* + var assetsToCache:Array = []; // Assets.listGraphics('core'); + + var future:Future> = []; // Assets.cacheAssets(assetsToCache); + future.onProgress((loaded:Int, total:Int) -> { + cachingGraphicsPercent = loaded / total; + }); + future.onComplete((_result) -> { + trace('Completed caching graphics.'); + }); + */ + + // TODO: Reimplement this. + cachingGraphicsPercent = 1.0; + cachingGraphicsComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedCachingGraphics:Float = elapsed - cachingGraphicsStartTime; + if (cachingGraphicsComplete && elapsedCachingGraphics >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.CachingAudio; + return 0.0; + } + else + { + if (cachingGraphicsPercent < (elapsedCachingGraphics / Constants.PRELOADER_MIN_STAGE_TIME)) + { + // Return real progress if it's lower. + return cachingGraphicsPercent; + } + else + { + // Return simulated progress if it's higher. + return elapsedCachingGraphics / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + } + else + { + if (cachingGraphicsComplete) + { + currentState = FunkinPreloaderState.CachingAudio; + return 0.0; + } + else + { + return cachingGraphicsPercent; + } + } + + case CachingAudio: + if (cachingAudioPercent < 0) + { + cachingAudioPercent = 0.0; + cachingAudioStartTime = elapsed; + + var assetsToCache:Array = []; // Assets.listSound('core'); + + /* + var future:Future> = []; // Assets.cacheAssets(assetsToCache); + + future.onProgress((loaded:Int, total:Int) -> { + cachingAudioPercent = loaded / total; + }); + future.onComplete((_result) -> { + trace('Completed caching audio.'); + }); + */ + + // TODO: Reimplement this. + cachingAudioPercent = 1.0; + cachingAudioComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedCachingAudio:Float = elapsed - cachingAudioStartTime; + if (cachingAudioComplete && elapsedCachingAudio >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.CachingData; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (cachingAudioPercent < (elapsedCachingAudio / Constants.PRELOADER_MIN_STAGE_TIME)) + { + return cachingAudioPercent; + } + else + { + return elapsedCachingAudio / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + } + else + { + if (cachingAudioComplete) + { + currentState = FunkinPreloaderState.CachingData; + return 0.0; + } + else + { + return cachingAudioPercent; + } + } + + case CachingData: + if (cachingDataPercent < 0) + { + cachingDataPercent = 0.0; + cachingDataStartTime = elapsed; + + var assetsToCache:Array = []; + var sparrowFramesToCache:Array = []; + + // Core files + // assetsToCache = assetsToCache.concat(Assets.listText('core')); + // assetsToCache = assetsToCache.concat(Assets.listJSON('core')); + // Core spritesheets + // assetsToCache = assetsToCache.concat(Assets.listXML('core')); + + // Gameplay files + // assetsToCache = assetsToCache.concat(Assets.listText('gameplay')); + // assetsToCache = assetsToCache.concat(Assets.listJSON('gameplay')); + // We're not caching gameplay spritesheets here because they're fetched on demand. + + /* + var future:Future> = []; + // Assets.cacheAssets(assetsToCache, true); + future.onProgress((loaded:Int, total:Int) -> { + cachingDataPercent = loaded / total; + }); + future.onComplete((_result) -> { + trace('Completed caching data.'); + }); + */ + cachingDataPercent = 1.0; + cachingDataComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedCachingData:Float = elapsed - cachingDataStartTime; + if (cachingDataComplete && elapsedCachingData >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.ParsingSpritesheets; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (cachingDataPercent < (elapsedCachingData / Constants.PRELOADER_MIN_STAGE_TIME)) return cachingDataPercent; + else + return elapsedCachingData / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + else + { + if (cachingDataComplete) + { + currentState = FunkinPreloaderState.ParsingSpritesheets; + return 0.0; + } + } + + return cachingDataPercent; + + case ParsingSpritesheets: + if (parsingSpritesheetsPercent < 0) + { + parsingSpritesheetsPercent = 0.0; + parsingSpritesheetsStartTime = elapsed; + + // Core spritesheets + var sparrowFramesToCache = []; // Assets.listXML('core').map((xml:String) -> xml.replace('.xml', '').replace('core:assets/core/', '')); + // We're not caching gameplay spritesheets here because they're fetched on demand. + + /* + var future:Future> = []; // Assets.cacheSparrowFrames(sparrowFramesToCache, true); + future.onProgress((loaded:Int, total:Int) -> { + parsingSpritesheetsPercent = loaded / total; + }); + future.onComplete((_result) -> { + trace('Completed parsing spritesheets.'); + }); + */ + parsingSpritesheetsPercent = 1.0; + parsingSpritesheetsComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedParsingSpritesheets:Float = elapsed - parsingSpritesheetsStartTime; + if (parsingSpritesheetsComplete && elapsedParsingSpritesheets >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.ParsingStages; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (parsingSpritesheetsPercent < (elapsedParsingSpritesheets / Constants.PRELOADER_MIN_STAGE_TIME)) return parsingSpritesheetsPercent; + else + return elapsedParsingSpritesheets / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + else + { + if (parsingSpritesheetsComplete) + { + currentState = FunkinPreloaderState.ParsingStages; + return 0.0; + } + } + + return parsingSpritesheetsPercent; + + case ParsingStages: + if (parsingStagesPercent < 0) + { + parsingStagesPercent = 0.0; + parsingStagesStartTime = elapsed; + + /* + // TODO: Reimplement this. + var future:Future> = []; // StageDataParser.loadStageCacheAsync(); + + future.onProgress((loaded:Int, total:Int) -> { + parsingStagesPercent = loaded / total; + }); + + future.onComplete((_result) -> { + trace('Completed parsing stages.'); + }); + */ + + parsingStagesPercent = 1.0; + parsingStagesComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedParsingStages:Float = elapsed - parsingStagesStartTime; + if (parsingStagesComplete && elapsedParsingStages >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.ParsingCharacters; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (parsingStagesPercent < (elapsedParsingStages / Constants.PRELOADER_MIN_STAGE_TIME)) return parsingStagesPercent; + else + return elapsedParsingStages / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + else + { + if (parsingStagesComplete) + { + currentState = FunkinPreloaderState.ParsingCharacters; + return 0.0; + } + } + + return parsingStagesPercent; + + case ParsingCharacters: + if (parsingCharactersPercent < 0) + { + parsingCharactersPercent = 0.0; + parsingCharactersStartTime = elapsed; + + /* + // TODO: Reimplement this. + var future:Future> = []; // CharacterDataParser.loadCharacterCacheAsync(); + + future.onProgress((loaded:Int, total:Int) -> { + parsingCharactersPercent = loaded / total; + }); + + future.onComplete((_result) -> { + trace('Completed parsing characters.'); + }); + */ + + parsingCharactersPercent = 1.0; + parsingCharactersComplete = true; + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedParsingCharacters:Float = elapsed - parsingCharactersStartTime; + if (parsingCharactersComplete && elapsedParsingCharacters >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.ParsingSongs; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (parsingCharactersPercent < (elapsedParsingCharacters / Constants.PRELOADER_MIN_STAGE_TIME)) return parsingCharactersPercent; + else + return elapsedParsingCharacters / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + else + { + if (parsingStagesComplete) + { + currentState = FunkinPreloaderState.ParsingSongs; + return 0.0; + } + } + + return parsingCharactersPercent; + + case ParsingSongs: + if (parsingSongsPercent < 0) + { + parsingSongsPercent = 0.0; + parsingSongsStartTime = elapsed; + + /* + // TODO: Reimplement this. + var future:Future> = ; + // SongDataParser.loadSongCacheAsync(); + + future.onProgress((loaded:Int, total:Int) -> { + parsingSongsPercent = loaded / total; + }); + + future.onComplete((_result) -> { + trace('Completed parsing songs.'); + }); + */ + + parsingSongsPercent = 1.0; + parsingSongsComplete = true; + + return 0.0; + } + else if (Constants.PRELOADER_MIN_STAGE_TIME > 0) + { + var elapsedParsingSongs:Float = elapsed - parsingSongsStartTime; + if (parsingSongsComplete && elapsedParsingSongs >= Constants.PRELOADER_MIN_STAGE_TIME) + { + currentState = FunkinPreloaderState.Complete; + return 0.0; + } + else + { + // We need to return SIMULATED progress here. + if (parsingSongsPercent < (elapsedParsingSongs / Constants.PRELOADER_MIN_STAGE_TIME)) + { + return parsingSongsPercent; + } + else + { + return elapsedParsingSongs / Constants.PRELOADER_MIN_STAGE_TIME; + } + } + } + else + { + if (parsingSongsComplete) + { + currentState = FunkinPreloaderState.Complete; + return 0.0; + } + else + { + return parsingSongsPercent; + } + } + case FunkinPreloaderState.Complete: + if (completeTime < 0) + { + completeTime = elapsed; + } + + return 1.0; + #if TOUCH_HERE_TO_PLAY + case FunkinPreloaderState.TouchHereToPlay: + if (completeTime < 0) + { + completeTime = elapsed; + } + + if (touchHereToPlay.alpha < 1.0) + { + touchHereSprite.buttonMode = true; + touchHereToPlay.alpha = 1.0; + removeChild(vfdBitmap); + + addEventListener(MouseEvent.CLICK, onTouchHereToPlay); + touchHereSprite.addEventListener(MouseEvent.MOUSE_OVER, overTouchHereToPlay); + touchHereSprite.addEventListener(MouseEvent.MOUSE_DOWN, mouseDownTouchHereToPlay); + touchHereSprite.addEventListener(MouseEvent.MOUSE_OUT, outTouchHereToPlay); + } + + return 1.0; + #end + + default: + // Do nothing. + } + + return 0.0; + } + + #if TOUCH_HERE_TO_PLAY + function overTouchHereToPlay(e:MouseEvent):Void + { + touchHereToPlay.scaleX = touchHereToPlay.scaleY = ratio * 1.1; + touchHereToPlay.x = (this._width - touchHereToPlay.width) / 2; + touchHereToPlay.y = (this._height - touchHereToPlay.height) / 2; + } + + function outTouchHereToPlay(e:MouseEvent):Void + { + touchHereToPlay.scaleX = touchHereToPlay.scaleY = ratio * 1; + touchHereToPlay.x = (this._width - touchHereToPlay.width) / 2; + touchHereToPlay.y = (this._height - touchHereToPlay.height) / 2; + } + + function mouseDownTouchHereToPlay(e:MouseEvent):Void + { + touchHereToPlay.y += 10; + } + + function onTouchHereToPlay(e:MouseEvent):Void + { + touchHereToPlay.x = (this._width - touchHereToPlay.width) / 2; + touchHereToPlay.y = (this._height - touchHereToPlay.height) / 2; + + removeEventListener(MouseEvent.CLICK, onTouchHereToPlay); + touchHereSprite.removeEventListener(MouseEvent.MOUSE_OVER, overTouchHereToPlay); + touchHereSprite.removeEventListener(MouseEvent.MOUSE_OUT, outTouchHereToPlay); + touchHereSprite.removeEventListener(MouseEvent.MOUSE_DOWN, mouseDownTouchHereToPlay); + + // This is the actual thing that makes the game load. + immediatelyStartGame(); + } + #end + + static final TOTAL_STEPS:Int = 11; + static final ELLIPSIS_TIME:Float = 0.5; + + function updateGraphics(percent:Float, elapsed:Float):Void + { + // Render logo (including transitions) + if (completeTime > 0.0) + { + var elapsedFinished:Float = renderLogoFadeOut(elapsed); + // trace('Fading out logo... (' + elapsedFinished + 's)'); + if (elapsedFinished > LOGO_FADE_TIME) + { + #if TOUCH_HERE_TO_PLAY + // The logo has faded out, but we're not quite done yet. + // In order to prevent autoplay issues, we need the user to click after the loading finishes. + currentState = FunkinPreloaderState.TouchHereToPlay; + #else + immediatelyStartGame(); + #end + } + } + else + { + renderLogoFadeIn(elapsed); + + // Render progress bar + var maxWidth = this._width - BAR_PADDING * 2; + var barWidth = maxWidth * percent; + var piecesToRender:Int = Std.int(percent * progressBarPieces.length); + + for (i => piece in progressBarPieces) + { + piece.alpha = i <= piecesToRender ? 0.9 : 0.1; + } + } + + // progressBar.width = barWidth; + + // Cycle ellipsis count to show loading + var ellipsisCount:Int = Std.int(elapsed / ELLIPSIS_TIME) % 3 + 1; + var ellipsis:String = ''; + for (i in 0...ellipsisCount) + ellipsis += '.'; + + var percentage:Int = Math.floor(percent * 100); + // Render status text + switch (currentState) + { + // case FunkinPreloaderState.NotStarted: + default: + updateProgressLeftText('Loading \n0/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.DownloadingAssets: + updateProgressLeftText('Downloading assets \n1/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.PreloadingPlayAssets: + updateProgressLeftText('Preloading assets \n2/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.InitializingScripts: + updateProgressLeftText('Initializing scripts \n3/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.CachingGraphics: + updateProgressLeftText('Caching graphics \n4/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.CachingAudio: + updateProgressLeftText('Caching audio \n5/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.CachingData: + updateProgressLeftText('Caching data \n6/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.ParsingSpritesheets: + updateProgressLeftText('Parsing spritesheets \n7/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.ParsingStages: + updateProgressLeftText('Parsing stages \n8/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.ParsingCharacters: + updateProgressLeftText('Parsing characters \n9/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.ParsingSongs: + updateProgressLeftText('Parsing songs \n10/$TOTAL_STEPS $ellipsis'); + trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + case FunkinPreloaderState.Complete: + updateProgressLeftText('Finishing up \n$TOTAL_STEPS/$TOTAL_STEPS $ellipsis'); + // trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + #if TOUCH_HERE_TO_PLAY + case FunkinPreloaderState.TouchHereToPlay: + updateProgressLeftText(null); + // trace('Preloader state: ' + currentState + ' (' + percentage + '%, ' + elapsed + 's)'); + #end + } + + // Render percent text + progressRightText.text = '$percentage%'; + + super.update(percent); + } + + function updateProgressLeftText(text:Null):Void + { + if (progressLeftText != null) + { + if (text == null) + { + progressLeftText.alpha = 0.0; + } + else if (progressLeftText.text != text) + { + // We have to keep updating the text format, because the font can take a frame or two to load. + var progressLeftTextFormat = new TextFormat("DS-Digital", 32, Constants.COLOR_PRELOADER_BAR, true); + progressLeftTextFormat.align = TextFormatAlign.LEFT; + progressLeftText.defaultTextFormat = progressLeftTextFormat; + progressLeftText.text = text; + + dspText.defaultTextFormat = new TextFormat("Quantico", 20, 0x000000, false); + dspText.text = 'DSP'; // fukin dum.... + dspText.textColor = 0x000000; + + fnfText.defaultTextFormat = new TextFormat("Quantico", 20, 0x000000, false); + fnfText.text = 'FNF'; + fnfText.textColor = 0x000000; + + enhancedText.defaultTextFormat = new TextFormat("Inconsolata Black", 16, Constants.COLOR_PRELOADER_BAR, false); + enhancedText.text = 'ENHANCED'; + enhancedText.textColor = Constants.COLOR_PRELOADER_BAR; + + stereoText.defaultTextFormat = new TextFormat("Inconsolata Bold", 36, Constants.COLOR_PRELOADER_BAR, false); + stereoText.text = 'NATURAL STEREO'; + } + } + } + + function immediatelyStartGame():Void + { + _loaded = true; + } + + /** + * Fade out the logo. + * @param elapsed Elapsed time since the preloader started. + * @return Elapsed time since the logo started fading out. + */ + function renderLogoFadeOut(elapsed:Float):Float + { + // Fade-out takes LOGO_FADE_TIME seconds. + var elapsedFinished = elapsed - completeTime; + + logo.alpha = 1.0 - MathUtil.easeInOutCirc(elapsedFinished / LOGO_FADE_TIME); + logo.scaleX = (1.0 - MathUtil.easeInBack(elapsedFinished / LOGO_FADE_TIME)) * ratio; + logo.scaleY = (1.0 - MathUtil.easeInBack(elapsedFinished / LOGO_FADE_TIME)) * ratio; + logo.x = (this._width - logo.width) / 2; + logo.y = (this._height - logo.height) / 2; + + // Fade out progress bar too. + // progressBar.alpha = logo.alpha; + progressLeftText.alpha = logo.alpha; + progressRightText.alpha = logo.alpha; + box.alpha = logo.alpha; + dspText.alpha = logo.alpha; + fnfText.alpha = logo.alpha; + enhancedText.alpha = logo.alpha; + stereoText.alpha = logo.alpha; + progressLines.alpha = logo.alpha; + + for (piece in progressBarPieces) + piece.alpha = logo.alpha; + + return elapsedFinished; + } + + function renderLogoFadeIn(elapsed:Float):Void + { + // Fade-in takes LOGO_FADE_TIME seconds. + logo.alpha = MathUtil.easeInOutCirc(elapsed / LOGO_FADE_TIME); + logo.scaleX = MathUtil.easeOutBack(elapsed / LOGO_FADE_TIME) * ratio; + logo.scaleY = MathUtil.easeOutBack(elapsed / LOGO_FADE_TIME) * ratio; + logo.x = (this._width - logo.width) / 2; + logo.y = (this._height - logo.height) / 2; + } + + #if html5 + // These fields only exist on Web builds. + + /** + * Format the layout of the site lock screen. + */ + override function createSiteLockFailureScreen():Void + { + // addChild(createSiteLockFailureBackground(Constants.COLOR_PRELOADER_LOCK_BG, Constants.COLOR_PRELOADER_LOCK_BG)); + // addChild(createSiteLockFailureIcon(Constants.COLOR_PRELOADER_LOCK_FG, 0.9)); + // addChild(createSiteLockFailureText(30)); + } + + /** + * Format the text of the site lock screen. + */ + override function adjustSiteLockTextFields(titleText:TextField, bodyText:TextField, hyperlinkText:TextField):Void + { + var titleFormat = titleText.defaultTextFormat; + titleFormat.align = TextFormatAlign.CENTER; + titleFormat.color = Constants.COLOR_PRELOADER_LOCK_FONT; + titleText.setTextFormat(titleFormat); + + var bodyFormat = bodyText.defaultTextFormat; + bodyFormat.align = TextFormatAlign.CENTER; + bodyFormat.color = Constants.COLOR_PRELOADER_LOCK_FONT; + bodyText.setTextFormat(bodyFormat); + + var hyperlinkFormat = hyperlinkText.defaultTextFormat; + hyperlinkFormat.align = TextFormatAlign.CENTER; + hyperlinkFormat.color = Constants.COLOR_PRELOADER_LOCK_LINK; + hyperlinkText.setTextFormat(hyperlinkFormat); + } + #end + + override function destroy():Void + { + // Ensure the graphics are properly destroyed and GC'd. + removeChild(logo); + // removeChild(progressBar); + logo = null; + super.destroy(); + } + + override function onLoaded():Void + { + super.onLoaded(); + // We're not ACTUALLY finished. + // This function gets called when the DownloadingAssets step is done. + // We need to wait for the other steps, then the logo to fade out. + _loaded = false; + downloadingAssetsComplete = true; + } +} + +enum FunkinPreloaderState +{ + /** + * The state before downloading has begun. + * Moves to either `DownloadingAssets` or `CachingGraphics` based on platform. + */ + NotStarted; + + /** + * Downloading assets. + * On HTML5, Lime will do this for us, before calling `onLoaded`. + * On Desktop, this step will be completed immediately, and we'll go straight to `CachingGraphics`. + */ + DownloadingAssets; + + /** + * Preloading play assets. + * Loads the `manifest.json` for the `gameplay` library. + * If we make the base preloader do this, it will download all the assets as well, + * so we have to do it ourselves. + */ + PreloadingPlayAssets; + + /** + * Loading FireTongue, loading Polymod, parsing and instantiating module scripts. + */ + InitializingScripts; + + /** + * Loading all graphics from the `core` library to the cache. + */ + CachingGraphics; + + /** + * Loading all audio from the `core` library to the cache. + */ + CachingAudio; + + /** + * Loading all data files from the `core` library to the cache. + */ + CachingData; + + /** + * Parsing all XML files from the `core` library into FlxFramesCollections and caching them. + */ + ParsingSpritesheets; + + /** + * Parsing stage data and scripts. + */ + ParsingStages; + + /** + * Parsing character data and scripts. + */ + ParsingCharacters; + + /** + * Parsing song data and scripts. + */ + ParsingSongs; + + /** + * Finishing up. + */ + Complete; + + #if TOUCH_HERE_TO_PLAY + /** + * Touch Here to Play is displayed. + */ + TouchHereToPlay; + #end +} diff --git a/source/funkin/ui/transition/preload/README.md b/source/funkin/ui/transition/preload/README.md new file mode 100644 index 00000000..91efcf8e --- /dev/null +++ b/source/funkin/ui/transition/preload/README.md @@ -0,0 +1,17 @@ +# funkin.ui.loading.preload + +This package contains code powering the HTML5 preloader screen. + +The preloader performs the following tasks: +- **Downloading assets**: Downloads the `core` asset library and loads its manifest +- **Preloading play assets**: Downloads the `gameplay` asset library (manifest only) +- **Initializing scripts**: Downloads and registers stage scripts, character scripts, song scripts, and module scripts. +- **Caching graphics**: Downloads all graphics from the `core` asset library, uploads them to the GPU, then dumps them from RAM. This prepares them to be used very quickly in-game. +- **Caching audio**: Downloads all audio files from the `core` asset library, and caches them. This prepares them to be used very quickly in-game. +- **Caching data**: Downloads and caches all TXT files, all JSON files (it also parses them), and XML files (from the `core` library only). This prepares them to be used in the next steps. +- **Parsing stages**: Parses all stage data and instantiates associated stage scripts. This prepares them to be used in-game. +- **Parsing characters**: Parses all character data and instantiates associated character scripts. This prepares them to be used in-game. +- **Parsing songs**: Parses all song data and instantiates associated song scripts. This prepares them to be used in-game. +- **Finishing up**: Waits for the screen to fade out. Then, it loads the first state of the app. + +Due to the first few steps not being relevant on desktop, and due to this preloader being built in Lime rather than HaxeFlixel because of how Lime handles asset loading, this preloader is not used on desktop. The splash loader is used instead. diff --git a/source/funkin/ui/transition/preload/VFDOverlay.hx b/source/funkin/ui/transition/preload/VFDOverlay.hx new file mode 100644 index 00000000..19c94097 --- /dev/null +++ b/source/funkin/ui/transition/preload/VFDOverlay.hx @@ -0,0 +1,68 @@ +package funkin.ui.transition.preload; + +import openfl.display.GraphicsShader; + +class VFDOverlay extends GraphicsShader +{ + public var elapsedTime(default, set):Float = 0; + + function set_elapsedTime(value:Float):Float + { + u_time.value = [value]; + return value; + } + + @:glFragmentSource('#pragma header + const vec2 s = vec2(1, 1.7320508); + + uniform float u_time; + + float rand(float co) { return fract(sin(co*(91.3458)) * 47453.5453); } + float rand(vec2 co){ return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); } + + void main(void) { + vec4 col = texture2D (bitmap, openfl_TextureCoordv); + vec2 game_res = vec2(1280.0, 720.0); + const float tileAmount = 10.; + + vec2 uv = (2. * openfl_TextureCoordv.xy * -1.); + uv *= 50.; + + vec4 hexCenter = floor(vec4(uv, uv - vec2(0.5, 1.0)) / s.xyxy) + 0.5; + vec4 offset = vec4(uv - hexCenter.xy * s, uv - (hexCenter.zw + 0.5) * s) + 0.0; + vec4 hexInfo = dot(offset.xy, offset.xy) < dot(offset.zw, offset.zw) ? vec4(offset.xy, hexCenter.xy) : vec4(offset.zw, hexCenter.zw); + + // Distance to the nearest edge of a hexagon + vec2 p = abs(hexInfo.xy) ; + float edgeDist = max(dot(p, normalize(vec2(1.0, sqrt(3.0)))), p.x); + float edgeWidth = 0.05 * tileAmount; // Adjust edge width based on tile amount + float edgeSharpness = 0.011 * tileAmount; + + float outline = smoothstep(edgeWidth - edgeSharpness, edgeWidth, edgeDist); + float color_mix = mix(0.0, 0.3, outline); // Mix black outline with white fill + + float flicker = (sin(u_time) * 0.05) + 1.0; + float sinshit = smoothstep(-3.0, 1.0, sin(uv.y * 3.)); + + col = vec4(vec3(0.0), color_mix); + col = mix(col, vec4(0., 0., 0., sinshit), 0.5 * flicker); + + float specs = rand(uv.xy); + vec4 noise = vec4(0., 0., 0., specs); + col = mix(col, noise, 0.1); + + gl_FragColor = col; + } + ') + public function new() + { + super(); + + this.elapsedTime = 0; + } + + public function update(elapsed:Float):Void + { + this.elapsedTime += elapsed; + } +} diff --git a/source/funkin/ui/transition/stickers/ScriptedStickerPack.hx b/source/funkin/ui/transition/stickers/ScriptedStickerPack.hx new file mode 100644 index 00000000..34a0efd2 --- /dev/null +++ b/source/funkin/ui/transition/stickers/ScriptedStickerPack.hx @@ -0,0 +1,8 @@ +package funkin.ui.transition.stickers; + +/** + * A script that can be tied to a StickerPack. + * Create a scripted class that extends StickerPack to use this. + */ +@:hscriptClass +class ScriptedStickerPack extends funkin.ui.transition.stickers.StickerPack implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/transition/stickers/StickerPack.hx b/source/funkin/ui/transition/stickers/StickerPack.hx new file mode 100644 index 00000000..ecd79bf3 --- /dev/null +++ b/source/funkin/ui/transition/stickers/StickerPack.hx @@ -0,0 +1,82 @@ +package funkin.ui.transition.stickers; + +import funkin.data.stickers.StickerData; +import funkin.data.stickers.StickerRegistry; +import funkin.data.IRegistryEntry; +import funkin.graphics.FunkinSprite; + +/** + * A class representing the data for a sticker pack as displayed in the Sticker SubState. + */ +class StickerPack implements IRegistryEntry +{ + /** + * The internal ID for this sticker pack. + */ + public final id:String; + + /** + * The full data for this sticker pack. + */ + public final _data:StickerData; + + public function new(id:String) + { + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse sticker pack data for id: $id'; + } + } + + /** + * Return the name of the sticker pack. + * @return The name of the sticker pack + */ + public function getStickerPackName():String + { + return _data.name; + } + + /** + * Return the artist of the sticker pack. + * @return The list of artists + */ + public function getStickerPackArtist():String + { + return _data.artist; + } + + /** + * Gets a list of all the sticker assets available in the pack. + * @return The list of stickers as raw strings. + */ + public function getStickers():Array + { + return _data.stickers; + } + + /** + * Retrieve a random sticker from the pack. + * @param last Whether this will be the last sticker to be placed on the screen. + * @return An asset path to a sticker to display. + */ + public function getRandomStickerPath(last:Bool):String + { + return FlxG.random.getObject(getStickers()); + } + + public function toString():String + { + return 'StickerPack($id)'; + } + + public function destroy():Void {} + + static function _fetchData(id:String):Null + { + return StickerRegistry.instance.parseEntryDataWithMigration(id, StickerRegistry.instance.fetchEntryVersion(id)); + } +} diff --git a/source/funkin/ui/transition/stickers/StickerSprite.hx b/source/funkin/ui/transition/stickers/StickerSprite.hx new file mode 100644 index 00000000..b993587b --- /dev/null +++ b/source/funkin/ui/transition/stickers/StickerSprite.hx @@ -0,0 +1,16 @@ +package funkin.ui.transition.stickers; + +import funkin.graphics.FunkinSprite; + +class StickerSprite extends FunkinSprite +{ + public var timing:Float = 0; + + public function new(x:Float, y:Float, filePath:String):Void + { + super(x, y); + loadTexture(filePath); + updateHitbox(); + scrollFactor.set(); + } +} diff --git a/source/funkin/ui/transition/stickers/StickerSubState.hx b/source/funkin/ui/transition/stickers/StickerSubState.hx new file mode 100644 index 00000000..9061cb21 --- /dev/null +++ b/source/funkin/ui/transition/stickers/StickerSubState.hx @@ -0,0 +1,288 @@ +package funkin.ui.transition.stickers; + +import flixel.addons.transition.FlxTransitionableState; +import flixel.FlxG; +import flixel.FlxState; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.math.FlxMath; +import flixel.util.FlxSort; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.stickers.StickerRegistry; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.MusicBeatSubState; +import funkin.ui.transition.stickers.StickerPack; + +using Lambda; +using StringTools; + +typedef StickerSubStateParams = +{ + /* + * The state to transition into. + */ + ?targetState:StickerSubState->FlxState, + + /** + * The sticker pack to retrieve and use. + * @default `Constants.DEFAULT_STICKER_PACK` + */ + ?stickerPack:String, + + /** + * An existing set of stickers to transition out with. + */ + ?oldStickers:Array, +} + +@:nullSafety +class StickerSubState extends MusicBeatSubState +{ + public var grpStickers:FlxTypedGroup; + + /** + * The state to switch to after the stickers are done. + * This is a FUNCTION so we can pass it directly to `FlxG.switchState()`, + * and we can add constructor parameters in the caller. + */ + var targetState:StickerSubState->FlxState; + + var stickerPackId:String; + var stickerPack:StickerPack; + + // what "folders" to potentially load from (as of writing only "keys" exist) + var soundSelections:Array = []; + // what "folder" was randomly selected + var soundSelection:String = ""; + var sounds:Array = []; + + public function new(params:StickerSubStateParams):Void + { + super(); + + // Define the target state, with a default fallback. + this.targetState = params?.targetState ?? (sticker) -> FreeplayState.build(null, sticker); + + this.stickerPackId = params.stickerPack ?? Constants.DEFAULT_STICKER_PACK; + + var targetStickerPack = StickerRegistry.instance.fetchEntry(this.stickerPackId); + + this.stickerPack = targetStickerPack ?? StickerRegistry.instance.fetchDefault(); + + // TODO: Make this tied to the sticker pack more closely. + var assetsInList = Assets.list(); + var soundFilterFunc = function(a:String) { + return a.startsWith('assets/shared/sounds/stickersounds/'); + }; + soundSelections = assetsInList.filter(soundFilterFunc); + soundSelections = soundSelections.map(function(a:String) { + return a.replace('assets/shared/sounds/stickersounds/', '').split('/')[0]; + }); + + grpStickers = new FlxTypedGroup(); + add(grpStickers); + + // cracked cleanup... yuchh... + for (i in soundSelections) + { + while (soundSelections.contains(i)) + { + soundSelections.remove(i); + } + soundSelections.push(i); + } + + soundSelection = FlxG.random.getObject(soundSelections); + + var filterFunc = function(a:String) { + return a.startsWith('assets/shared/sounds/stickersounds/' + soundSelection + '/'); + }; + var assetsInList3 = Assets.list(); + sounds = assetsInList3.filter(filterFunc); + for (i in 0...sounds.length) + { + sounds[i] = sounds[i].replace('assets/shared/sounds/', ''); + sounds[i] = sounds[i].substring(0, sounds[i].lastIndexOf('.')); + } + + // makes the stickers on the most recent camera, which is more often than not... a UI camera!! + // grpStickers.cameras = [FlxG.cameras.list[FlxG.cameras.list.length - 1]]; + grpStickers.cameras = FlxG.cameras.list; + + if (params.oldStickers != null) + { + for (sticker in params.oldStickers) + { + grpStickers.add(sticker); + } + + degenStickers(); + } + else + { + regenStickers(); + } + } + + public function degenStickers():Void + { + grpStickers.cameras = FlxG.cameras.list; + + /* + if (dipshit != null) + { + FlxG.removeChild(dipshit); + dipshit = null; + } + */ + + if (grpStickers.members == null || grpStickers.members.length == 0) + { + switchingState = false; + close(); + return; + } + + for (ind => sticker in grpStickers.members) + { + new FlxTimer().start(sticker.timing, _ -> { + sticker.visible = false; + var daSound:String = FlxG.random.getObject(sounds); + FunkinSound.playOnce(Paths.sound(daSound)); + + if (grpStickers == null || ind == grpStickers.members.length - 1) + { + switchingState = false; + close(); + } + }); + } + } + + function regenStickers():Void + { + if (grpStickers.members.length > 0) + { + grpStickers.clear(); + } + + // Initialize stickers at each point on the screen, then shuffle up the order they will get placed. + // This ensures stickers consistently cover the screen. + var xPos:Float = -100; + var yPos:Float = -100; + while (xPos <= FlxG.width) + { + var stickerPath:String = stickerPack.getRandomStickerPath(false); + var sticky:StickerSprite = new StickerSprite(0, 0, stickerPath); + sticky.visible = false; + + sticky.x = xPos; + sticky.y = yPos; + xPos += sticky.frameWidth * 0.5; + + if (xPos >= FlxG.width) + { + if (yPos <= FlxG.height) + { + xPos = -100; + yPos += FlxG.random.float(70, 120); + } + } + + sticky.angle = FlxG.random.int(-60, 70); + grpStickers.add(sticky); + } + + FlxG.random.shuffle(grpStickers.members); + + // Creates a new sticker for the very center. + var lastStickerPath:String = stickerPack.getRandomStickerPath(true); + var lastSticker:StickerSprite = new StickerSprite(0, 0, lastStickerPath); + lastSticker.visible = false; + lastSticker.updateHitbox(); + lastSticker.angle = 0; + lastSticker.screenCenter(); + grpStickers.add(lastSticker); + + // another damn for loop... apologies!!! + for (ind => sticker in grpStickers.members) + { + sticker.timing = FlxMath.remapToRange(ind, 0, grpStickers.members.length, 0, 0.9); + + new FlxTimer().start(sticker.timing, _ -> { + if (grpStickers == null) return; + + sticker.visible = true; + var daSound:String = FlxG.random.getObject(sounds); + FunkinSound.playOnce(Paths.sound(daSound)); + + var frameTimer:Int = FlxG.random.int(0, 2); + + // always make the last one POP + if (ind == grpStickers.members.length - 1) frameTimer = 2; + + new FlxTimer().start((1 / 24) * frameTimer, _ -> { + if (sticker == null) return; + + sticker.scale.x = sticker.scale.y = FlxG.random.float(0.97, 1.02); + + if (ind == grpStickers.members.length - 1) + { + switchingState = true; + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + // I think this grabs the screen and puts it under the stickers? + // Leaving this commented out rather than stripping it out because it's cool... + /* + dipshit = new Sprite(); + var scrn:BitmapData = new BitmapData(FlxG.width, FlxG.height, true, 0x00000000); + var mat:Matrix = new Matrix(); + scrn.draw(grpStickers.cameras[0].canvas, mat); + + var bitmap:Bitmap = new Bitmap(scrn); + + dipshit.addChild(bitmap); + // FlxG.addChildBelowMouse(dipshit); + */ + + FlxG.switchState(() -> { + // TODO: Rework this asset caching stuff + // NOTE: This has to come AFTER the state switch, + // otherwise the game tries to render destroyed sprites! + FunkinSprite.preparePurgeCache(); + FunkinSprite.purgeCache(); + + return targetState(this); + }); + } + }); + }); + } + + grpStickers.sort((ord, a, b) -> { + return FlxSort.byValues(ord, a.timing, b.timing); + }); + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + } + + var switchingState:Bool = false; + + override public function close():Void + { + if (switchingState) return; + super.close(); + } + + override public function destroy():Void + { + if (switchingState) return; + super.destroy(); + } +} diff --git a/source/funkin/util/BezierUtil.hx b/source/funkin/util/BezierUtil.hx new file mode 100644 index 00000000..37d91223 --- /dev/null +++ b/source/funkin/util/BezierUtil.hx @@ -0,0 +1,85 @@ +package funkin.util; + +import flixel.math.FlxPoint; + +/** + * Utilities for performing math with bezier curves. + */ +class BezierUtil +{ + /** + * Linearly interpolate between two values. + * Depending on p, 0 = a, 1 = b, 0.5 = halfway between a and b. + */ + static inline function mix2(p:Float, a:Float, b:Float):Float + { + return a * (1 - p) + (b * p); + } + + /** + * Linearly interpolate between three values. + * Depending on p, 0 = a, 0.5 = b, 1 = c, 0.25 = halfway between a and b, etc. + */ + static inline function mix3(p:Float, a:Float, b:Float, c:Float):Float + { + return mix2(p, mix2(p, a, b), mix2(p, b, c)); + } + + static inline function mix4(p:Float, a:Float, b:Float, c:Float, d:Float):Float + { + return mix2(p, mix3(p, a, b, c), mix3(p, b, c, d)); + } + + static inline function mix5(p:Float, a:Float, b:Float, c:Float, d:Float, e:Float):Float + { + return mix2(p, mix4(p, a, b, c, d), mix4(p, b, c, d, e)); + } + + /** + * A bezier curve with two points. + * This is really just linear interpolation but whatever. + */ + public static function bezier2(p:Float, a:FlxPoint, b:FlxPoint):FlxPoint + { + return new FlxPoint(mix2(p, a.x, b.x), mix2(p, a.y, b.y)); + } + + /** + * A bezier curve with three points. + * @param p The percentage of the way through the curve. + * @param a The start point. + * @param b The control point. + * @param c The end point. + */ + public static function bezier3(p:Float, a:FlxPoint, b:FlxPoint, c:FlxPoint):FlxPoint + { + return new FlxPoint(mix3(p, a.x, b.x, c.x), mix3(p, a.y, b.y, c.y)); + } + + /** + * A bezier curve with four points. + * @param p The percentage of the way through the curve. + * @param a The start point. + * @param b The first control point. + * @param c The second control point. + * @param d The end point. + */ + public static function bezier4(p:Float, a:FlxPoint, b:FlxPoint, c:FlxPoint, d:FlxPoint):FlxPoint + { + return new FlxPoint(mix4(p, a.x, b.x, c.x, d.x), mix4(p, a.y, b.y, c.y, d.y)); + } + + /** + * A bezier curve with four points. + * @param p The percentage of the way through the curve. + * @param a The start point. + * @param b The first control point. + * @param c The second control point. + * @param c The third control point. + * @param d The end point. + */ + public static function bezier5(p:Float, a:FlxPoint, b:FlxPoint, c:FlxPoint, d:FlxPoint, e:FlxPoint):FlxPoint + { + return new FlxPoint(mix5(p, a.x, b.x, c.x, d.x, e.x), mix5(p, a.y, b.y, c.y, d.y, e.y)); + } +} diff --git a/source/funkin/util/CLIUtil.hx b/source/funkin/util/CLIUtil.hx new file mode 100644 index 00000000..ecabaff0 --- /dev/null +++ b/source/funkin/util/CLIUtil.hx @@ -0,0 +1,147 @@ +package funkin.util; + +import haxe.io.Path; + +/** + * Utilties for interpreting command line arguments. + */ +@:nullSafety +class CLIUtil +{ + /** + * If we don't do this, dragging and dropping a file onto the executable + * causes it to be unable to find the assets folder. + */ + public static function resetWorkingDir():Void + { + #if sys + var exeDir:String = Path.addTrailingSlash(Path.directory(Sys.programPath())); + #if mac + exeDir = Path.addTrailingSlash(Path.join([exeDir, '../Resources/'])); + #end + var cwd:String = Path.addTrailingSlash(Sys.getCwd()); + if (cwd == exeDir) + { + trace('Working directory is already correct.'); + } + else + { + trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}'); + Sys.setCwd(exeDir); + } + #end + } + + public static function processArgs():CLIParams + { + #if sys + return interpretArgs(cleanArgs(Sys.args())); + #else + return buildDefaultParams(); + #end + } + + static function interpretArgs(args:Array):CLIParams + { + var result = buildDefaultParams(); + + result.args = [for (arg in args) arg]; // Copy the array. + + while (args.length > 0) + { + var arg:Null = args.shift(); + if (arg == null) continue; + + if (arg.startsWith('-')) + { + switch (arg) + { + // Flags + case '-h' | '--help': + printUsage(); + case '-v' | '--version': + trace(Constants.GENERATED_BY); + case '--chart': + if (args.length == 0) + { + trace('No chart path provided.'); + printUsage(); + } + else + { + result.chart.shouldLoadChart = true; + result.chart.chartPath = args.shift(); + } + } + } + else + { + // Make an attempt to interpret the argument. + + if (arg.endsWith(Constants.EXT_CHART)) + { + result.chart.shouldLoadChart = true; + result.chart.chartPath = arg; + } + else + { + trace('Unrecognized argument: ${arg}'); + printUsage(); + } + } + } + + return result; + } + + static function printUsage():Void + { + trace('Usage: Funkin.exe [--chart ] [--help] [--version]'); + } + + static function buildDefaultParams():CLIParams + { + return { + args: [], + + chart: + { + shouldLoadChart: false, + chartPath: null + } + }; + } + + /** + * Clean up the arguments passed to the application before parsing them. + * @param args The arguments to clean up. + * @return The cleaned up arguments. + */ + static function cleanArgs(args:Array):Array + { + var result:Array = []; + + if (args == null || args.length == 0) return result; + + return args.map(function(arg:String):String { + if (arg == null) return ''; + + return arg.trim(); + }).filter(function(arg:String):Bool { + return arg != null && arg != ''; + }); + } +} + +typedef CLIParams = +{ + var args:Array; + + var chart:CLIChartParams; +} + +typedef CLIChartParams = +{ + var shouldLoadChart:Bool; + var chartPath:Null; +}; diff --git a/source/funkin/util/ClipboardUtil.hx b/source/funkin/util/ClipboardUtil.hx new file mode 100644 index 00000000..2796bcad --- /dev/null +++ b/source/funkin/util/ClipboardUtil.hx @@ -0,0 +1,51 @@ +package funkin.util; + +/** + * Utility functions for working with the system clipboard. + * On platforms that don't support interacting with the clipboard, + * an internal clipboard is used (neat!). + */ +class ClipboardUtil +{ + /** + * Add an event listener callback which executes next time the system clipboard is updated. + * + * @param callback The callback to execute when the clipboard is updated. + * @param once If true, the callback will only execute once and then be deleted. + * @param priority Set the priority at which the callback will be executed. Higher values execute first. + */ + public static function addListener(callback:Void->Void, once:Bool = false, ?priority:Int = 0):Void + { + lime.system.Clipboard.onUpdate.add(callback, once, priority); + } + + /** + * Remove an event listener callback from the system clipboard. + * + * @param callback The callback to remove. + */ + public static function removeListener(callback:Void->Void):Void + { + lime.system.Clipboard.onUpdate.remove(callback); + } + + /** + * Get the current contents of the system clipboard. + * + * @return The current contents of the system clipboard. + */ + public static function getClipboard():String + { + return lime.system.Clipboard.text; + } + + /** + * Set the contents of the system clipboard. + * + * @param text The text to set the system clipboard to. + */ + public static function setClipboard(text:String):String + { + return lime.system.Clipboard.text = text; + } +} diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx new file mode 100644 index 00000000..eab31fef --- /dev/null +++ b/source/funkin/util/Constants.hx @@ -0,0 +1,610 @@ +package funkin.util; + +import flixel.system.FlxBasePreloader; +import flixel.util.FlxColor; +import funkin.data.song.SongData.SongTimeFormat; +import lime.app.Application; + +/** + * A store of unchanging, globally relevant values. + */ +class Constants +{ + /** + * ENGINE AND VERSION DATA + */ + // ============================== + + /** + * The title of the game, for debug printing purposes. + * Change this if you're making an engine. + */ + public static final TITLE:String = "Friday Night Funkin'"; + + /** + * The current version number of the game. + * Modify this in the `project.xml` file. + */ + public static var VERSION(get, never):String; + + /** + * The generatedBy string embedded in the chart files made by this application. + */ + public static var GENERATED_BY(get, never):String; + + static function get_GENERATED_BY():String + { + return '${Constants.TITLE} - ${Constants.VERSION}'; + } + + /** + * A suffix to add to the game version. + * Add a suffix to prototype builds and remove it for releases. + */ + public static final VERSION_SUFFIX:String = #if FEATURE_DEBUG_FUNCTIONS ' PROTOTYPE' #else '' #end; + + #if FEATURE_DEBUG_FUNCTIONS + static function get_VERSION():String + { + return 'v${Application.current.meta.get('version')} (${GIT_BRANCH} : ${GIT_HASH}${GIT_HAS_LOCAL_CHANGES ? ' : MODIFIED' : ''})' + VERSION_SUFFIX; + } + #else + static function get_VERSION():String + { + return 'v${Application.current.meta.get('version')}' + VERSION_SUFFIX; + } + #end + + /** + * Whether or not the game is a debug build. + */ + public static final DEBUG_BUILD:Bool = #if FEATURE_DEBUG_FUNCTIONS true #else false #end; + + /** + * URL DATA + */ + // ============================== + + /** + * Link to buy merch for the game. + * This is usually fetched from the Newgrounds API but we use this as a fallback. + */ + public static final URL_MERCH_FALLBACK:String = 'https://needlejuicerecords.com/en-ca/pages/friday-night-funkin'; + + /** + * Preloader sitelock. + * Matching is done by `FlxStringUtil.getDomain`, so any URL on the domain will work. + * The first link in this list is the one users will be redirected to if they try to access the game from a different URL. + */ + public static final SITE_LOCK:Array = [ + "https://www.newgrounds.com/portal/view/770371", // Newgrounds, baybee! + FlxBasePreloader.LOCAL // localhost for dev stuff + ]; + + /** + * Link to download the game on Itch.io. + */ + public static final URL_ITCH:String = 'https://ninja-muffin24.itch.io/funkin'; + + /** + * Link to play the game on Newgrounds. + */ + public static final URL_NEWGROUNDS:String = 'https://www.newgrounds.com/portal/view/770371'; + + /** + * Link to the game's page on Kickstarter. + */ + public static final URL_KICKSTARTER:String = 'https://www.kickstarter.com/projects/funkin/friday-night-funkin-the-full-ass-game/'; + + /** + * REPOSITORY DATA + */ + // ============================== + + /** + * The current Git branch. + */ + public static final GIT_BRANCH:String = funkin.util.macro.GitCommit.getGitBranch(); + + /** + * The current Git commit hash. + */ + public static final GIT_HASH:String = funkin.util.macro.GitCommit.getGitCommitHash(); + + public static final GIT_HAS_LOCAL_CHANGES:Bool = funkin.util.macro.GitCommit.getGitHasLocalChanges(); + + /** + * The current library versions, as provided by hmm. + */ + public static final LIBRARY_VERSIONS:Array = funkin.util.macro.HaxelibVersions.getLibraryVersions(); + + /** + * COLORS + */ + // ============================== + + /** + * The color used by the enemy health bar. + */ + public static final COLOR_HEALTH_BAR_RED:FlxColor = 0xFFFF0000; + + /** + * The color used by the player health bar. + */ + public static final COLOR_HEALTH_BAR_GREEN:FlxColor = 0xFF66FF33; + + /** + * The base colors used by notes. + */ + public static var COLOR_NOTES:Array = [ + 0xFFFF22AA, // left (0) + 0xFF00EEFF, // down (1) + 0xFF00CC00, // up (2) + 0xFFCC1111 // right (3) + ]; + + /** + * Color for the preloader background + */ + public static final COLOR_PRELOADER_BG:FlxColor = 0xFF000000; + + /** + * Color for the preloader progress bar + */ + public static final COLOR_PRELOADER_BAR:FlxColor = 0xFFA4FF11; + + /** + * Color for the preloader site lock background + */ + public static final COLOR_PRELOADER_LOCK_BG:FlxColor = 0xFF1B1717; + + /** + * Color for the preloader site lock foreground + */ + public static final COLOR_PRELOADER_LOCK_FG:FlxColor = 0xB96F10; + + /** + * Color for the preloader site lock text + */ + public static final COLOR_PRELOADER_LOCK_FONT:FlxColor = 0xCCCCCC; + + /** + * Color for the preloader site lock link + */ + public static final COLOR_PRELOADER_LOCK_LINK:FlxColor = 0xEEB211; + + /** + * LANGUAGE + */ + // ============================== + public static final SITE_LOCK_TITLE:String = "You Loser!"; + + public static final SITE_LOCK_DESC:String = "This isn't Newgrounds!\nGo play Friday Night Funkin' on Newgrounds:"; + + /** + * GAME DEFAULTS + */ + // ============================== + + /** + * Default difficulty for charts. + */ + public static final DEFAULT_DIFFICULTY:String = 'normal'; + + /** + * Default list of difficulties for charts. + * Assumes no Erect mode, etc. + */ + public static final DEFAULT_DIFFICULTY_LIST:Array = ['easy', 'normal', 'hard']; + + /** + * Default list of difficulties for Erect mode. + */ + public static final DEFAULT_DIFFICULTY_LIST_ERECT:Array = ['erect', 'nightmare']; + + /** + * List of all difficulties used by the base game. + * Includes Erect and Nightmare. + */ + public static final DEFAULT_DIFFICULTY_LIST_FULL:Array = ['easy', 'normal', 'hard', 'erect', 'nightmare']; + + /** + * Default player character for charts. + */ + public static final DEFAULT_CHARACTER:String = 'bf'; + + /** + * Default player character for health icons. + */ + public static final DEFAULT_HEALTH_ICON:String = 'face'; + + /** + * Default stage for charts. + */ + public static final DEFAULT_STAGE:String = 'mainStage'; + + /** + * Default song for if the PlayState messes up. + */ + public static final DEFAULT_SONG:String = 'tutorial'; + + /** + * Default variation for charts. + */ + public static final DEFAULT_VARIATION:String = 'default'; + + /** + * Standardized variations for charts + */ + public static final DEFAULT_VARIATION_LIST:Array = ['default', 'erect', 'pico', 'bf']; + + /** + * Default sticker pack for transitions + */ + public static final DEFAULT_STICKER_PACK:String = 'default'; + + /** + * The default intensity multiplier for camera bops. + * Prolly needs to be tuned bc it's a multiplier now. + */ + public static final DEFAULT_BOP_INTENSITY:Float = 1.015; + + /** + * The default rate for camera zooms (in beats per zoom). + */ + public static final DEFAULT_ZOOM_RATE:Int = 4; + + /** + * The default BPM for charts, so things don't break if none is specified. + */ + public static final DEFAULT_BPM:Float = 100.0; + + /** + * The default name for songs. + */ + public static final DEFAULT_SONGNAME:String = 'Unknown'; + + /** + * The default artist for songs. + */ + public static final DEFAULT_ARTIST:String = 'Unknown'; + + /** + * The default charter for songs. + */ + public static final DEFAULT_CHARTER:String = 'Unknown'; + + /** + * The default note style for songs. + */ + public static final DEFAULT_NOTE_STYLE:String = 'funkin'; + + /** + * The default freeplay style for characters. + */ + public static final DEFAULT_FREEPLAY_STYLE:String = 'bf'; + + /** + * The default pixel note style for songs. + */ + public static final DEFAULT_PIXEL_NOTE_STYLE:String = 'pixel'; + + /** + * The default album for songs in Freeplay. + */ + public static final DEFAULT_ALBUM_ID:String = 'volume1'; + + /** + * The default timing format for songs. + */ + public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; + + /** + * The default scroll speed for songs. + */ + public static final DEFAULT_SCROLLSPEED:Float = 1.0; + + /** + * Default numerator for the time signature. + */ + public static final DEFAULT_TIME_SIGNATURE_NUM:Int = 4; + + /** + * Default denominator for the time signature. + */ + public static final DEFAULT_TIME_SIGNATURE_DEN:Int = 4; + + /** + * ANIMATIONS + */ + // ============================== + + /** + * A suffix used for animations played when an animation would loop. + */ + public static final ANIMATION_HOLD_SUFFIX:String = '-hold'; + + /** + * A suffix used for animations played when an animation would end before transitioning to another. + */ + public static final ANIMATION_END_SUFFIX:String = '-end'; + + /** + * TIMING + */ + // ============================== + + /** + * A magic number used when calculating scroll speed and note distances. + */ + public static final PIXELS_PER_MS:Float = 0.45; + + /** + * The maximum interval within which a note can be hit, in milliseconds. + */ + public static final HIT_WINDOW_MS:Float = 160.0; + + /** + * Constant for the number of seconds in a minute. + * + * sex per min + */ + public static final SECS_PER_MIN:Int = 60; + + /** + * Constant for the number of milliseconds in a second. + */ + public static final MS_PER_SEC:Int = 1000; + + /** + * The number of microseconds in a millisecond. + */ + public static final US_PER_MS:Int = 1000; + + /** + * The number of microseconds in a second. + */ + public static final US_PER_SEC:Int = US_PER_MS * MS_PER_SEC; + + /** + * The number of nanoseconds in a microsecond. + */ + public static final NS_PER_US:Int = 1000; + + /** + * The number of nanoseconds in a millisecond. + */ + public static final NS_PER_MS:Int = NS_PER_US * US_PER_MS; + + /** + * The number of nanoseconds in a second. + */ + public static final NS_PER_SEC:Int = NS_PER_US * US_PER_MS * MS_PER_SEC; + + /** + * Duration, in milliseconds, until toast notifications are automatically hidden. + */ + public static final NOTIFICATION_DISMISS_TIME:Int = 5 * MS_PER_SEC; + + /** + * Duration to wait before autosaving the chart. + */ + public static final AUTOSAVE_TIMER_DELAY_SEC:Float = 5.0 * SECS_PER_MIN; + + /** + * Number of steps in a beat. + * One step is one 16th note and one beat is one quarter note. + */ + public static final STEPS_PER_BEAT:Int = 4; + + /** + * All MP3 decoders introduce a playback delay of `528` samples, + * which at 44,100 Hz (samples per second) is ~12 ms. + */ + public static final MP3_DELAY_MS:Float = 528 / 44100 * Constants.MS_PER_SEC; + + /** + * Each step of the preloader has to be on screen at least this long. + * + * 0 = The preloader immediately moves to the next step when it's ready. + * 1 = The preloader waits for 1 second before moving to the next step. + * The progress bare is automatically rescaled to match. + */ + public static final PRELOADER_MIN_STAGE_TIME:Float = 0.1; + + /** + * HEALTH VALUES + */ + // ============================== + + /** + * The player's maximum health. + * If the player is at this value, they can't gain any more health. + */ + public static final HEALTH_MAX:Float = 2.0; + + /** + * The player's starting health. + */ + public static final HEALTH_STARTING = HEALTH_MAX / 2.0; + + /** + * The player's minimum health. + * If the player is at or below this value, they lose. + */ + public static final HEALTH_MIN:Float = 0.0; + + /** + * The amount of health the player gains when hitting a note with the KILLER rating. + */ + public static final HEALTH_KILLER_BONUS:Float = 2.0 / 100.0 * HEALTH_MAX; // +2.0% + + /** + * The amount of health the player gains when hitting a note with the SICK rating. + */ + public static final HEALTH_SICK_BONUS:Float = 1.5 / 100.0 * HEALTH_MAX; // +1.0% + + /** + * The amount of health the player gains when hitting a note with the GOOD rating. + */ + public static final HEALTH_GOOD_BONUS:Float = 0.75 / 100.0 * HEALTH_MAX; // +0.75% + + /** + * The amount of health the player gains when hitting a note with the BAD rating. + */ + public static final HEALTH_BAD_BONUS:Float = 0.0 / 100.0 * HEALTH_MAX; // +0.0% + + /** + * The amount of health the player gains when hitting a note with the SHIT rating. + * If negative, the player will actually lose health. + */ + public static final HEALTH_SHIT_BONUS:Float = -1.0 / 100.0 * HEALTH_MAX; // -1.0% + + /** + * The amount of health the player gains, while holding a hold note, per second. + */ + public static final HEALTH_HOLD_BONUS_PER_SECOND:Float = 6.0 / 100.0 * HEALTH_MAX; // +6.0% / second + + /** + * The amount of health the player loses upon missing a note. + */ + public static final HEALTH_MISS_PENALTY:Float = -4.0 / 100.0 * HEALTH_MAX; // 4.0% + + /** + * The amount of health the player loses upon pressing a key when no note is there. + */ + public static final HEALTH_GHOST_MISS_PENALTY:Float = -4.0 / 100.0 * HEALTH_MAX; // 2.0% + + /** + * The amount of health the player loses upon letting go of a hold note, per second remaining. + */ + public static final HEALTH_HOLD_DROP_PENALTY_PER_SECOND:Float = 0 / 100.0 * HEALTH_MAX; // -4.5% / second + + /** + * The maximum amount of health the player can lose upon letting go of a hold note. + */ + public static final HEALTH_HOLD_DROP_PENALTY_MAX:Float = 0 / 100.0 * HEALTH_MAX; // -10.0% + + /** + * The amount of health the player loses upon hitting a mine. + */ + public static final HEALTH_MINE_PENALTY:Float = -15.0 / 100.0 * HEALTH_MAX; // 15.0% + + /** + * SCORE VALUES + */ + // ============================== + + /** + * The amount of score the player gains for every second they hold a hold note. + * A fraction of this value is granted every frame. + */ + public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0; + + /** + * The amount of score the player loses upon letting go of a hold note, per second remaining. + */ + public static final SCORE_HOLD_DROP_PENALTY_PER_SECOND:Float = -125.0; + + /** + * The minimum amount of the hold note, in milliseconds, before the player gets penalized for letting go of it early. + */ + public static final HOLD_DROP_PENALTY_THRESHOLD_MS:Float = 160.0; + + public static final JUDGEMENT_KILLER_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_SICK_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_GOOD_COMBO_BREAK:Bool = false; + public static final JUDGEMENT_BAD_COMBO_BREAK:Bool = true; + public static final JUDGEMENT_SHIT_COMBO_BREAK:Bool = true; + + // % Hit + public static final RANK_PERFECT_THRESHOLD:Float = 1.00; + public static final RANK_EXCELLENT_THRESHOLD:Float = 0.90; + public static final RANK_GREAT_THRESHOLD:Float = 0.80; + public static final RANK_GOOD_THRESHOLD:Float = 0.60; + + // public static final RANK_SHIT_THRESHOLD:Float = 0.00; + /** + * FILE EXTENSIONS + */ + // ============================== + + /** + * The file extension used when exporting chart files. + * + * - "I made a new file format" + * - "Actually new or just a renamed ZIP?" + */ + public static final EXT_CHART = "fnfc"; + + /** + * The file extension used when loading audio files. + */ + public static final EXT_SOUND = #if web "mp3" #else "ogg" #end; + + /** + * The file extension used when loading video files. + */ + public static final EXT_VIDEO = "mp4"; + + /** + * The file extension used when loading image files. + */ + public static final EXT_IMAGE = "png"; + + /** + * The file extension used when loading data files. + */ + public static final EXT_DATA = "json"; + + /** + * OTHER + */ + // ============================== + #if FEATURE_GHOST_TAPPING + // Hey there, Eric here. + // This feature is currently still in development. You can test it out by creating a special debug build! + // lime build windows -DFEATURE_GHOST_TAPPING + + /** + * Duration, in seconds, after the player's section ends before the player can spam without penalty. + */ + public static final GHOST_TAP_DELAY:Float = 3 / 8; + #end + + /** + * The maximum number of previous file paths for the Chart Editor to remember. + */ + public static final MAX_PREVIOUS_WORKING_FILES:Int = 10; + + /** + * The separator between an asset library and the asset path. + */ + public static final LIBRARY_SEPARATOR:String = ':'; + + /** + * The scale factor to use when increasing the size of pixel art graphics. + */ + public static final PIXEL_ART_SCALE:Float = 6; + + /** + * The volume at which to play the countdown before the song starts. + */ + public static final COUNTDOWN_VOLUME:Float = 0.6; + + /** + * The horizontal offset of the strumline from the left edge of the screen. + */ + public static final STRUMLINE_X_OFFSET:Float = 48; + + /** + * The vertical offset of the strumline from the top edge of the screen. + */ + public static final STRUMLINE_Y_OFFSET:Float = 24; + + /** + * The rate at which the camera lerps to its target. + * 0.04 = 4% of distance per frame. + */ + public static final DEFAULT_CAMERA_FOLLOW_RATE:Float = 0.04; +} diff --git a/source/funkin/util/DateUtil.hx b/source/funkin/util/DateUtil.hx new file mode 100644 index 00000000..f899ffeb --- /dev/null +++ b/source/funkin/util/DateUtil.hx @@ -0,0 +1,22 @@ +package funkin.util; + +/** + * Utilities for performing operations on dates. + */ +class DateUtil +{ + public static function generateTimestamp(?date:Date = null):String + { + if (date == null) date = Date.now(); + + return + '${date.getFullYear()}-${Std.string(date.getMonth() + 1).lpad('0', 2)}-${Std.string(date.getDate()).lpad('0', 2)}-${Std.string(date.getHours()).lpad('0', 2)}-${Std.string(date.getMinutes()).lpad('0', 2)}-${Std.string(date.getSeconds()).lpad('0', 2)}'; + } + + public static function generateCleanTimestamp(?date:Date = null):String + { + if (date == null) date = Date.now(); + + return '${DateTools.format(date, '%B %d, %Y')} at ${DateTools.format(date, '%I:%M %p')}'; + } +} diff --git a/source/funkin/util/EaseUtil.hx b/source/funkin/util/EaseUtil.hx new file mode 100644 index 00000000..200e74d0 --- /dev/null +++ b/source/funkin/util/EaseUtil.hx @@ -0,0 +1,17 @@ +package funkin.util; + +class EaseUtil +{ + /** + * Returns an ease function that eases via steps. + * Useful for "retro" style fades (week 6!) + * @param steps how many steps to ease over + * @return Float->Float + */ + public static inline function stepped(steps:Int):Float->Float + { + return function(t:Float):Float { + return Math.floor(t * steps) / steps; + } + } +} diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx new file mode 100644 index 00000000..3f47b611 --- /dev/null +++ b/source/funkin/util/FileUtil.hx @@ -0,0 +1,1548 @@ +package funkin.util; + +import haxe.zip.Entry; +import lime.utils.Bytes; +import lime.ui.FileDialog; +import openfl.net.FileFilter; +import haxe.io.Path; +import openfl.net.FileReference; +import openfl.events.Event; +import openfl.events.IOErrorEvent; +import haxe.ui.containers.dialogs.Dialog.DialogButton; +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"); + public static final FILE_FILTER_JSON:FileFilter = new FileFilter("JSON Data File (.json)", "*.json"); + public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip"); + public static final FILE_FILTER_PNG:FileFilter = new FileFilter("PNG Image (.png)", "*.png"); + public static final FILE_FILTER_FNFS:FileFilter = new FileFilter("Friday Night Funkin' Stage (.fnfs)", "*.fnfs"); + + public static final FILE_EXTENSION_INFO_FNFC:FileDialogExtensionInfo = + { + extension: 'fnfc', + label: 'Friday Night Funkin\' Chart', + }; + public static final FILE_EXTENSION_INFO_ZIP:FileDialogExtensionInfo = + { + extension: 'zip', + label: 'ZIP Archive', + }; + public static final FILE_EXTENSION_INFO_PNG:FileDialogExtensionInfo = + { + extension: 'png', + label: 'PNG Image', + }; + + public static final FILE_EXTENSION_INFO_FNFS:FileDialogExtensionInfo = + { + extension: 'fnfs', + label: 'Friday Night Funkin\' Stage', + }; + + /** + * Paths which should not be deleted or modified by scripts. + */ + public static var PROTECTED_PATHS(get, never):Array; + + public static function get_PROTECTED_PATHS():Array + { + final protected:Array = [ + '', + '.', + 'assets', + 'assets/*', + 'backups', + 'backups/*', + 'manifest', + 'manifest/*', + 'plugins', + 'plugins/*', + 'Funkin.exe', + 'Funkin', + 'icon.ico', + 'libvlc.dll', + 'libvlccore.dll', + 'lime.ndll', + 'scores.json' + ]; + + #if sys + for (i in 0...protected.length) + { + protected[i] = sys.FileSystem.fullPath(Path.join([gameDirectory, protected[i]])); + } + #end + + return protected; + } + + /** + * Regex for invalid filesystem characters. + */ + public static final INVALID_CHARS:EReg = ~/[:*?"<>|\n\r\t]/g; + + #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 = sys.FileSystem.fullPath(Path.directory(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. + * File contents will be binary, not String. + * + * @param typeFilter + * @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) + { + var onComplete = function(button, selectedFiles) { + if (button == DialogButton.OK && selectedFiles.length > 0) + { + onSelect(selectedFiles[0]); + } + else if (onCancel != null) + { + onCancel(); + } + }; + + Dialogs.openFile(onComplete, + { + readContents: true, + readAsBinary: true, // Binary + multiple: false, + extensions: typeFilter ?? new Array(), + title: dialogTitle, + }); + } + + /** + * Browses for a single file, then calls `onSelect(fileInfo)` when a file is selected. + * Powered by HaxeUI, so it works on all platforms. + * File contents will be a String, not binary. + * + * @param typeFilter + * @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 + { + var onComplete = function(button, selectedFiles) { + if (button == DialogButton.OK && selectedFiles.length > 0) + { + onSelect(selectedFiles[0]); + } + else if (onCancel != null) + { + onCancel(); + } + }; + + Dialogs.openFile(onComplete, + { + readContents: true, + readAsBinary: false, // Text + multiple: false, + extensions: typeFilter ?? new Array(), + title: dialogTitle, + }); + } + + /** + * Browses for a directory, then calls `onSelect(path)` when a path chosen. + * Note that on HTML5 this will immediately fail. + * + * @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, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter:Null = convertTypeFilter(typeFilter); + var fileDialog:FileDialog = new FileDialog(); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + + fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle); + + return true; + #else + trace('WARNING: browseForDirectory not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + + return false; + #end + } + + /** + * Browses for multiple file, then calls `onSelect(paths)` when a path chosen. + * Note that on HTML5 this will immediately fail. + * + * @return Whether the file dialog was opened successfully. + */ + public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter:Null = convertTypeFilter(typeFilter); + var fileDialog:FileDialog = new FileDialog(); + fileDialog.onSelectMultiple.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + + fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle); + + return true; + #else + trace('WARNING: browseForMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + + return false; + #end + } + + /** + * Browses for a file location to save to, then calls `onSave(path)` when a path chosen. + * Note that on HTML5 you can't do much with this, you should call `saveFile(resource:haxe.io.Bytes)` instead. + * + * @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, ?defaultPath:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter:Null = convertTypeFilter(typeFilter); + var fileDialog:FileDialog = new FileDialog(); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + + fileDialog.browse(SAVE, filter, defaultPath, dialogTitle); + + return true; + #else + trace('WARNING: browseForSaveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + + return false; + #end + } + + /** + * Browses for a single file location, then writes the provided `haxe.io.Bytes` data and calls `onSave(path)` when done. + * Works great on desktop and HTML5. + * + * @return Whether the file dialog was opened successfully. + */ + public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String) -> Void, ?onCancel:() -> Void, ?defaultFileName:String, + ?dialogTitle:String):Bool + { + #if desktop + var filter:Null = convertTypeFilter(typeFilter); + var fileDialog:FileDialog = new FileDialog(); + 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: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); + } + + fileDialog.save(data, filter, defaultFileName, dialogTitle); + + return true; + #else + trace('WARNING: saveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + + return false; + #end + } + + /** + * Prompts the user to save multiple files. + * On desktop, this will prompt the user for a directory, then write all of the files to there. + * On HTML5, this will zip the files up and prompt the user to save that. + * + * @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, ?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 = new Array(); + for (resource in resources) + { + /* + var filePath:String = Path.join([targetPath, resource.fileName]); + try + { + if (resource.data == null) + { + trace('WARNING: File $filePath has no data or content. Skipping.'); + continue; + } + else + { + writeBytesToPath(filePath, resource.data, force ? Force : Skip); + } + } + catch (e:Dynamic) + { + throw 'Failed to write file (probably already exists): $filePath'; + } + */ + if (resource.data == null) + { + trace('WARNING: File ${resource.fileName} has no data or content. Skipping.'); + continue; + } + + var filePath:String = Path.join([targetPath, resource.fileName]); + + paths.push(filePath); + } + + 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 + trace('WARNING: saveMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + + return false; + #end + } + + /** + * 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, ?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]); + } + }; + + // Prompt the user to save the ZIP file. + saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...'); + return true; + } + + /** + * 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, ?defaultPath:String, + force:Bool = false):Bool + { + // Create a ZIP file. + var zipBytes:Bytes = createZIPFromEntries(resources); + 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...'); + return true; + } + + /** + * Takes an array of file entries and forcibly writes a ZIP to the given path. + * Only works on desktop, because HTML5 doesn't allow you to write files to arbitrary paths. + * Use `saveFilesAsZIP` instead. + * @param force Whether to force overwrite an existing file. + */ + public static function saveFilesAsZIPToPath(resources:Array, path:String, mode:FileWriteMode = Skip):Bool + { + #if desktop + // Create a ZIP file. + var zipBytes:Bytes = createZIPFromEntries(resources); + // Write the ZIP. + writeBytesToPath(path, zipBytes, mode); + return true; + #else + return false; + #end + } + + /** + * Read string file contents directly from a given path. + * Only works on desktop. + * + * @param path The path to the file. + * @return The file contents. + */ + public static function readStringFromPath(path:String):String + { + #if sys + return sys.io.File.getContent(path); + #else + throw 'Direct file reading by path is not supported on this platform.'; + #end + } + + /** + * Read bytes file contents directly from a given path. + * Only works on desktop. + * + * @param path The path to the file. + * @return The file contents. + */ + public static function readBytesFromPath(path:String):Bytes + { + #if sys + return sys.io.File.getBytes(path); + #else + throw 'Direct file reading by path is not supported on this platform.'; + #end + } + + /** + * Browse for a file to read and execute a callback once we have a file reference. + * Works great on HTML5 or desktop. + * + * @param callback The function to call when the file is loaded. + */ + 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):Void + { + var file = new FileReference(); + + file.addEventListener(Event.COMPLETE, function(e:Event) { + trace('Successfully wrote file: "$path"'); + }); + + file.addEventListener(Event.CANCEL, function(e:Event) { + trace('Cancelled writing file: "$path"'); + }); + + file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { + trace('IO error writing file: "$path"'); + }); + + file.save(data, path); + } + + /** + * Read JSON file contents directly from a given path. + * Only works on desktop. + * + * @param path The path to the file. + * @return The JSON data. + */ + public static function readJSONFromPath(path:String):Dynamic + { + #if sys + return SerializerUtil.fromJSON(sys.io.File.getContent(path)); + #else + throw 'Direct file reading by path is not supported on this platform.'; + #end + } + + /** + * Write string file contents directly to a given path. + * Only works on desktop. + * + * @param path The path to the file. + * @param data The string to write. + * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. + */ + 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 (!pathExists(path)) + { + sys.io.File.saveContent(path, data); + } + case Ask: + if (pathExists(path)) + { + // TODO: We don't have the technology to use native popups yet. + throw 'Entry at path already exists: $path'; + } + else + { + sys.io.File.saveContent(path, data); + } + } + #else + throw 'Direct file writing by path is not supported on this platform.'; + #end + } + + /** + * Write byte file contents directly to a given path. + * Only works on desktop. + * + * @param path The path to the file. + * @param data The bytes to write. + * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. + */ + 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"'; + } + + var shouldWrite:Bool = true; + switch (mode) + { + case Force: + shouldWrite = true; + case Skip: + if (!pathExists(path)) + { + shouldWrite = true; + } + case Ask: + if (pathExists(path)) + { + // TODO: We don't have the technology to use native popups yet. + throw 'Entry at path already exists: "$path"'; + } + else + { + shouldWrite = true; + } + } + + if (shouldWrite) + { + createDirIfNotExists(Path.directory(path)); + sys.io.File.saveBytes(path, data); + } + #else + throw 'Direct file writing by path is not supported on this platform.'; + #end + } + + /** + * Write string file contents directly to the end of a file at the given path. + * Only works on desktop. + * + * @param path The path to the file. + * @param data The string to append. + */ + public static function appendStringToPath(path:String, data:String):Void + { + #if sys + 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 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 + } + + /** + * Create a directory if it doesn't already exist. + * Only works on desktop. + * + * @param dir The path to the directory. + */ + public static function createDirIfNotExists(dir:String):Void + { + if (!directoryExists(dir)) + { + #if sys + sys.FileSystem.createDirectory(dir); + #else + throw 'Directory creation is not supported on this platform.'; + #end + } + } + + /** + * 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']; + + /** + * Get the path to a temporary directory we can use for writing files. + * Only works on desktop. + * + * @return The path to the temporary directory. + */ + public static function getTempDir():Null + { + if (tempDir != null) return tempDir; + #if sys + #if windows + 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/']); + return tempDir; + #else + tempDir = '/tmp/funkin/'; + return tempDir; + #end + #else + return null; + #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. + * + * @param entries The entries to add to the ZIP file. + * @return The ZIP file as a Bytes object. + */ + public static function createZIPFromEntries(entries:Array):Bytes + { + var o:haxe.io.BytesOutput = new haxe.io.BytesOutput(); + var zipWriter:haxe.zip.Writer = new haxe.zip.Writer(o); + zipWriter.write(entries.list()); + return o.getBytes(); + } + + public static function readZIPFromBytes(input:Bytes):Array + { + var bytesInput = new haxe.io.BytesInput(input); + var zippedEntries = haxe.zip.Reader.readZip(bytesInput); + 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 = new Map(); + for (entry in input) + { + results.set(entry.fileName, entry); + } + + return results; + } + + /** + * Create a ZIP file entry from a file name and its string contents. + * + * @param name The name of the file. You can use slashes to create subdirectories. + * @param content The string contents of the file. + * @return The resulting entry. + */ + public static function makeZIPEntry(name:String, content:String):Entry + { + var data:Bytes = haxe.io.Bytes.ofString(content, UTF8); + return makeZIPEntryFromBytes(name, data); + } + + /** + * Create a ZIP file entry from a file name and its string contents. + * + * @param name The name of the file. You can use slashes to create subdirectories. + * @param data The byte data of the file. + * @return The resulting entry. + */ + public static function makeZIPEntryFromBytes(name:String, data:haxe.io.Bytes):Entry + { + return { + fileName: name, + fileSize: data.length, + data: data, + dataSize: data.length, + compressed: false, + fileTime: Date.now(), + crc32: null, + extraFields: null, + }; + } + + /** + * Runs platform-specific code to open a path in the file explorer. + * + * @param pathFolder The path of the folder to open. + */ + public static function openFolder(pathFolder:String):Void + { + #if sys + pathFolder = pathFolder.trim(); + if (!directoryExists(pathFolder)) + { + throw 'Path is not a directory: "$pathFolder"'; + } + + #if windows + Sys.command('explorer', [pathFolder.replace('/', '\\')]); + #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]); + #elseif linux + // TODO: implement linux + // some shit with xdg-open :thinking: emoji... + #end + #else + throw 'External folder open is not supported on this platform.'; + #end + } + + /** + * Runs platform-specific code to open a file explorer and select a specific file. + * + * @param path The path of the file to select. + */ + public static function openSelectFile(path:String):Void + { + #if sys + path = path.trim(); + if (!pathExists(path)) + { + throw 'Path does not exist: "$path"'; + } + + #if windows + Sys.command('explorer', ['/select,', path.replace('/', '\\')]); + #elseif mac + Sys.command('open', ['-R', path]); + #elseif linux + // TODO: unsure of the linux equivalent to opening a folder and then "selecting" a file. + Sys.command('open', [path]); + #end + #else + throw 'External file selection is not supported on this platform.'; + #end + } + + private static function convertTypeFilter(?typeFilter:Array):Null + { + var filter:Null = null; + if (typeFilter != null) + { + var filters:Array = new Array(); + for (type in typeFilter) + { + 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 + { + path = (path ?? '').trim(); + if (path == '') + { + #if sys + return FileUtil.gameDirectory; + #else + return ''; + #end + } + + if (path.contains(':')) + { + path = path.substring(path.lastIndexOf(':') + 1); + } + + path = path.replace('\\', '/'); + while (path.contains('//')) + { + path = path.replace('//', '/'); + } + + final parts:Array = FileUtil.INVALID_CHARS.replace(path, '').split('/'); + final sanitized:Array = new Array(); + for (part in parts) + { + switch (part) + { + case '.' | '': + continue; + case '..': + sanitized.pop(); + default: + sanitized.push(part.trim()); + } + } + + if (sanitized.length == 0) + { + #if sys + return FileUtil.gameDirectory; + #else + return ''; + #end + } + + #if sys + // TODO: figure out how to get "real" path of symlinked paths + final realPath:String = sys.FileSystem.fullPath(Path.join([FileUtil.gameDirectory, sanitized.join('/')])); + if (!realPath.startsWith(FileUtil.gameDirectory)) + { + return FileUtil.gameDirectory; + } + + return realPath; + #else + return sanitized.join('/'); + #end + } + + /** + * Check against protected paths. + * @param path The path to check. + * @return Whether the path is protected. + */ + public static function isProtected(path:String, sanitizeFirst:Bool = true):Bool + { + if (sanitizeFirst) 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), false)) 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), false)) 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), false)) 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), false)) 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), false)) throw 'Cannot move protected path: $path'; + if (isProtected(destination = sanitizePath(destination), false)) throw 'Cannot move to protected path: $destination'; + FileUtil.moveFile(path, destination); + } + + public static function deleteFile(path:String):Void + { + if (isProtected(path = sanitizePath(path), false)) 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), false)) throw 'Cannot move protected path: "$path"'; + if (isProtected(destination = sanitizePath(destination), false)) 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), false)) 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), false)) 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):Void + { + FileUtil.openFolder(sanitizePath(pathFolder)); + } + + public static function openSelectFile(path:String):Void + { + FileUtil.openSelectFile(sanitizePath(path)); + } +} + +enum FileWriteMode +{ + /** + * Forcibly overwrite the file if it already exists. + */ + Force; + + /** + * Ask the user if they want to overwrite the file if it already exists. + */ + Ask; + + /** + * Skip the file if it already exists. + */ + Skip; +} diff --git a/source/funkin/util/FlxColorUtil.hx b/source/funkin/util/FlxColorUtil.hx new file mode 100644 index 00000000..429d536d --- /dev/null +++ b/source/funkin/util/FlxColorUtil.hx @@ -0,0 +1,22 @@ +package funkin.util; + +import flixel.util.FlxColor; + +/** + * Non inline FlxColor functions for use in hscript files + */ +class FlxColorUtil +{ + /** + * Get an interpolated color based on two different colors. + * + * @param Color1 The first color + * @param Color2 The second color + * @param Factor Value from 0 to 1 representing how much to shift Color1 toward Color2 + * @return The interpolated color + */ + public static function interpolate(Color1:FlxColor, Color2:FlxColor, Factor:Float = 0.5):FlxColor + { + return FlxColor.interpolate(Color1, Color2, Factor); + } +} diff --git a/source/funkin/util/FlxGamepadUtil.hx b/source/funkin/util/FlxGamepadUtil.hx new file mode 100644 index 00000000..4fea7500 --- /dev/null +++ b/source/funkin/util/FlxGamepadUtil.hx @@ -0,0 +1,46 @@ +package funkin.util; + +import flixel.input.gamepad.FlxGamepad; +import flixel.input.gamepad.FlxGamepadInputID; +import lime.ui.Gamepad as LimeGamepad; +import lime.ui.GamepadButton as LimeGamepadButton; + +/** + * Utilities for working with Flixel gamepads. + */ +class FlxGamepadUtil +{ + public static function getInputID(gamepad:FlxGamepad, button:LimeGamepadButton):FlxGamepadInputID + { + #if FLX_GAMEINPUT_API + // FLX_GAMEINPUT_API internally assigns 6 axes to IDs 0-5, which LimeGamepadButton doesn't account for, so we need to offset the ID by 6. + final OFFSET:Int = 6; + #else + final OFFSET:Int = 0; + #end + + var result:FlxGamepadInputID = gamepad.mapping.getID(button + OFFSET); + if (result == NONE) return NONE; + return result; + } + + public static function getLimeGamepad(input:FlxGamepad):Null + { + #if FLX_GAMEINPUT_API @:privateAccess + return input._device.getLimeGamepad(); + #else + return null; + #end + } + + @:privateAccess + public static function getFlxGamepadByLimeGamepad(gamepad:LimeGamepad):FlxGamepad + { + // Why is this so elaborate? + @:privateAccess + var gameInputDevice:openfl.ui.GameInputDevice = openfl.ui.GameInput.__getDevice(gamepad); + @:privateAccess + var gamepadIndex:Int = FlxG.gamepads.findGamepadIndex(gameInputDevice); + return FlxG.gamepads.getByID(gamepadIndex); + } +} diff --git a/source/funkin/util/FlxTweenUtil.hx b/source/funkin/util/FlxTweenUtil.hx new file mode 100644 index 00000000..4a8806bf --- /dev/null +++ b/source/funkin/util/FlxTweenUtil.hx @@ -0,0 +1,34 @@ +package funkin.util; + +import flixel.tweens.FlxTween; + +class FlxTweenUtil +{ + public static function pauseTween(tween:FlxTween):Void + { + if (tween != null) + { + tween.active = false; + } + } + + public static function resumeTween(tween:FlxTween):Void + { + if (tween != null) + { + tween.active = true; + } + } + + public static function pauseTweensOf(Object:Dynamic, ?FieldPaths:Array):Void + { + @:privateAccess + FlxTween.globalManager.forEachTweensOf(Object, FieldPaths, pauseTween); + } + + public static function resumeTweensOf(Object:Dynamic, ?FieldPaths:Array):Void + { + @:privateAccess + FlxTween.globalManager.forEachTweensOf(Object, FieldPaths, resumeTween); + } +} diff --git a/source/funkin/util/FramesJSFLParser.hx b/source/funkin/util/FramesJSFLParser.hx new file mode 100644 index 00000000..3a9ff8d0 --- /dev/null +++ b/source/funkin/util/FramesJSFLParser.hx @@ -0,0 +1,63 @@ +package funkin.util; + +import openfl.Assets; + +/** + * See `funScripts/jsfl/frames.jsfl` for more information in the art repo/folder! + * Homemade dipshit proprietary format to get simple animation info out of flash! + * Pure convienience! + */ +class FramesJSFLParser +{ + public static function parse(path:String):FramesJSFLInfo + { + var text:String = Assets.getText(path); + + // TODO: error handle if text is null + + var output:FramesJSFLInfo = {frames: []}; + + var frames:Array = text.split("\n"); + + for (frame in frames) + { + var frameInfo:Array = frame.split(" "); + + var x:Float = Std.parseFloat(frameInfo[0]); + var y:Float = Std.parseFloat(frameInfo[1]); + var alpha:Float = (frameInfo[2] != "undefined") ? Std.parseFloat(frameInfo[2]) : 100; + + var scaleX:Float = 1; + var scaleY:Float = 1; + + if (frameInfo[3] != null) scaleX = Std.parseFloat(frameInfo[4]); + if (frameInfo[4] != null) scaleY = Std.parseFloat(frameInfo[4]); + + var shit:FramesJSFLFrame = + { + x: x, + y: y, + alpha: alpha, + scaleX: scaleX, + scaleY: scaleY + }; + output.frames.push(shit); + } + + return output; + } +} + +typedef FramesJSFLInfo = +{ + var frames:Array; +} + +typedef FramesJSFLFrame = +{ + var x:Float; + var y:Float; + var alpha:Float; + var scaleX:Float; + var scaleY:Float; +} diff --git a/source/funkin/util/HaxeUIUtil.hx b/source/funkin/util/HaxeUIUtil.hx new file mode 100644 index 00000000..1ffd9cd4 --- /dev/null +++ b/source/funkin/util/HaxeUIUtil.hx @@ -0,0 +1,17 @@ +package funkin.util; + +import haxe.ui.tooltips.ToolTipRegionOptions; + +class HaxeUIUtil +{ + public static function buildTooltip(text:String, ?left:Float, ?top:Float, ?width:Float, ?height:Float):ToolTipRegionOptions + { + return { + tipData: {text: text}, + left: left ?? 0.0, + top: top ?? 0.0, + width: width ?? 0.0, + height: height ?? 0.0 + } + } +} diff --git a/source/funkin/util/InputUtil.hx b/source/funkin/util/InputUtil.hx new file mode 100644 index 00000000..8f4fcc55 --- /dev/null +++ b/source/funkin/util/InputUtil.hx @@ -0,0 +1,200 @@ +package funkin.util; + +import funkin.input.Controls.Device; +import flixel.input.gamepad.FlxGamepad; +import flixel.input.keyboard.FlxKey; + +using flixel.util.FlxStringUtil; + +/** + * Utilities for working with inputs. + */ +class InputUtil +{ + public static function format(id:Int, device:Device):String + { + return switch (device) + { + case Keys: getKeyName(id); + case Gamepad(gamepadID): getButtonName(id, FlxG.gamepads.getByID(gamepadID)); + } + } + + public static function getKeyName(id:Int):String + { + return switch (id) + { + case ZERO: "0"; + case ONE: "1"; + case TWO: "2"; + case THREE: "3"; + case FOUR: "4"; + case FIVE: "5"; + case SIX: "6"; + case SEVEN: "7"; + case EIGHT: "8"; + case NINE: "9"; + case PAGEUP: "PgUp"; + case PAGEDOWN: "PgDown"; + // case HOME : "Hm"; + // case END : "End"; + // case INSERT : "Ins"; + // case ESCAPE : "Esc"; + // case MINUS : "-"; + // case PLUS : "+"; + // case DELETE : "Del"; + case BACKSPACE: "BckSpc"; + case LBRACKET: "["; + case RBRACKET: "]"; + case BACKSLASH: "\\"; + case CAPSLOCK: "Caps"; + case SEMICOLON: ";"; + case QUOTE: "'"; + // case ENTER : "Ent"; + // case SHIFT : "Shf"; + case COMMA: ","; + case PERIOD: "."; + case SLASH: "/"; + case GRAVEACCENT: "`"; + case CONTROL: "Ctrl"; + case ALT: "Alt"; + // case SPACE : "Spc"; + // case UP : "Up"; + // case DOWN : "Dn"; + // case LEFT : "Lf"; + // case RIGHT : "Rt"; + // case TAB : "Tab"; + case PRINTSCREEN: "PrtScrn"; + case NUMPADZERO: "#0"; + case NUMPADONE: "#1"; + case NUMPADTWO: "#2"; + case NUMPADTHREE: "#3"; + case NUMPADFOUR: "#4"; + case NUMPADFIVE: "#5"; + case NUMPADSIX: "#6"; + case NUMPADSEVEN: "#7"; + case NUMPADEIGHT: "#8"; + case NUMPADNINE: "#9"; + case NUMPADMINUS: "#-"; + case NUMPADPLUS: "#+"; + case NUMPADPERIOD: "#."; + case NUMPADMULTIPLY: "#*"; + default: titleCase(FlxKey.toStringMap[id]); + } + } + + static var dirReg = ~/^(l|r).?-(left|right|down|up)$/; + + inline static public function getButtonName(id:Int, gamepad:FlxGamepad):String + { + return switch (gamepad.getInputLabel(id)) + { + // case null | "": shortenButtonName(FlxGamepadInputID.toStringMap[id]); + case label: shortenButtonName(label); + } + } + + static function shortenButtonName(name:String) + { + return switch (name == null ? "" : name.toLowerCase()) + { + case "": "[?]"; + // case "square" : "[]"; + // case "circle" : "()"; + // case "triangle": "/\\"; + // case "plus" : "+"; + // case "minus" : "-"; + // case "home" : "Hm"; + // case "guide" : "Gd"; + // case "back" : "Bk"; + // case "select" : "Bk"; + // case "start" : "St"; + // case "left" : "Lf"; + // case "right" : "Rt"; + // case "down" : "Dn"; + // case "up" : "Up"; + case dir if (dirReg.match(dir)): + dirReg.matched(1).toUpperCase() + " " + titleCase(dirReg.matched(2)); + case label: titleCase(label); + } + } + + inline static function titleCaseTrim(str:String, length = 8) + { + return str.charAt(0).toUpperCase() + str.substr(1, length - 1).toLowerCase(); + } + + inline static function titleCase(str:String) + { + return str.charAt(0).toUpperCase() + str.substr(1).toLowerCase(); + } + + inline static public function parsePadName(name:String):ControllerName + { + return ControllerName.parseName(name); + } + + inline static public function getPadName(gamepad:FlxGamepad):ControllerName + { + return ControllerName.getName(gamepad); + } + + inline static public function getPadNameById(id:Int):ControllerName + { + return ControllerName.getNameById(id); + } +} + +@:forward +enum abstract ControllerName(String) from String to String +{ + var OUYA = "Ouya"; + var PS4 = "PS4"; + var LOGI = "Logi"; + var XBOX = "XBox"; + var XINPUT = "XInput"; + var WII = "Wii"; + var PRO_CON = "Pro_Con"; + var JOYCONS = "Joycons"; + var JOYCON_L = "Joycon_L"; + var JOYCON_R = "Joycon_R"; + var MFI = "MFI"; + var PAD = "Pad"; + + static public function getAssetByDevice(device:Device):String + { + return switch (device) + { + case Keys: getAsset(null); + case Gamepad(id): getAsset(FlxG.gamepads.getByID(id)); + } + } + + static public function getAsset(gamepad:FlxGamepad):String + { + if (gamepad == null) return 'assets/images/ui/devices/Keys.png'; + + final name = parseName(gamepad.name); + var path = 'assets/images/ui/devices/$name.png'; + if (openfl.utils.Assets.exists(path)) return path; + + return 'assets/images/ui/devices/Pad.png'; + } + + inline static public function getNameById(id:Int):ControllerName + return getName(FlxG.gamepads.getByID(id)); + + inline static public function getName(gamepad:FlxGamepad):ControllerName + return parseName(gamepad.name); + + static public function parseName(name:String):ControllerName + { + name = name.toLowerCase().remove("-").remove("_"); + return if (name.contains("ouya")) OUYA; else if (name.contains("wireless controller") + || name.contains("ps4")) PS4; else if (name.contains("logitech")) LOGI; else if (name.contains("xbox")) XBOX else if (name.contains("xinput")) + XINPUT; else if (name.contains("nintendo rvlcnt01tr") + || name.contains("nintendo rvlcnt01")) WII; else if (name.contains("mayflash wiimote pc adapter")) WII; else if (name.contains("pro controller")) + PRO_CON; else if (name.contains("joycon l+r")) JOYCONS; else if (name.contains("joycon (l)")) JOYCON_L; else if (name.contains("joycon (r)")) + JOYCON_R; else if (name.contains("mfi")) MFI; else PAD; + } +} diff --git a/source/funkin/util/MathUtil.hx b/source/funkin/util/MathUtil.hx new file mode 100644 index 00000000..93b9ca66 --- /dev/null +++ b/source/funkin/util/MathUtil.hx @@ -0,0 +1,131 @@ +package funkin.util; + +/** + * Utilities for performing mathematical operations. + */ +class MathUtil +{ + /** + * Euler's constant and the base of the natural logarithm. + * Math.E is not a constant in Haxe, so we'll just define it ourselves. + */ + public static final E:Float = 2.71828182845904523536; + + /** + * Perform linear interpolation between the base and the target, based on the current framerate. + * @param base The starting value, when `progress <= 0`. + * @param target The ending value, when `progress >= 1`. + * @param ratio Value used to interpolate between `base` and `target`. + * + * @return The interpolated value. + */ + @:deprecated('Use smoothLerp instead') + public static function coolLerp(base:Float, target:Float, ratio:Float):Float + { + return base + cameraLerp(ratio) * (target - base); + } + + /** + * Perform linear interpolation based on the current framerate. + * @param lerp Value used to interpolate between `base` and `target`. + * + * @return The interpolated value. + */ + @:deprecated('Use smoothLerp instead') + public static function cameraLerp(lerp:Float):Float + { + return lerp * (FlxG.elapsed / (1 / 60)); + } + + /** + * Get the logarithm of a value with a given base. + * @param base The base of the logarithm. + * @param value The value to get the logarithm of. + * @return `log_base(value)` + */ + public static function logBase(base:Float, value:Float):Float + { + return Math.log(value) / Math.log(base); + } + + public static function easeInOutCirc(x:Float):Float + { + if (x <= 0.0) return 0.0; + if (x >= 1.0) return 1.0; + var result:Float = (x < 0.5) ? (1 - Math.sqrt(1 - 4 * x * x)) / 2 : (Math.sqrt(1 - 4 * (1 - x) * (1 - x)) + 1) / 2; + return (result == Math.NaN) ? 1.0 : result; + } + + public static function easeInOutBack(x:Float, ?c:Float = 1.70158):Float + { + if (x <= 0.0) return 0.0; + if (x >= 1.0) return 1.0; + var result:Float = (x < 0.5) ? (2 * x * x * ((c + 1) * 2 * x - c)) / 2 : (1 - 2 * (1 - x) * (1 - x) * ((c + 1) * 2 * (1 - x) - c)) / 2; + return (result == Math.NaN) ? 1.0 : result; + } + + public static function easeInBack(x:Float, ?c:Float = 1.70158):Float + { + if (x <= 0.0) return 0.0; + if (x >= 1.0) return 1.0; + return (1 + c) * x * x * x - c * x * x; + } + + public static function easeOutBack(x:Float, ?c:Float = 1.70158):Float + { + if (x <= 0.0) return 0.0; + if (x >= 1.0) return 1.0; + return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); + } + + /** + * Get the base-2 logarithm of a value. + * @param x value + * @return `2^x` + */ + public static function exp2(x:Float):Float + { + return Math.pow(2, x); + } + + /** + * Linearly interpolate between two values. + * + * @param base The starting value, when `progress <= 0`. + * @param target The ending value, when `progress >= 1`. + * @param progress Value used to interpolate between `base` and `target`. + * @return The interpolated value. + */ + public static function lerp(base:Float, target:Float, progress:Float):Float + { + return base + progress * (target - base); + } + + /** + * Perform a framerate-independent linear interpolation between the base value and the target. + * @param current The current value. + * @param target The target value. + * @param elapsed The time elapsed since the last frame. + * @param duration The total duration of the interpolation. Nominal duration until remaining distance is less than `precision`. + * @param precision The target precision of the interpolation. Defaults to 1% of distance remaining. + * @see https://twitter.com/FreyaHolmer/status/1757918211679650262 + * + * @return A value between the current value and the target value. + */ + public static function smoothLerp(current:Float, target:Float, elapsed:Float, duration:Float, precision:Float = 1 / 100):Float + { + // An alternative algorithm which uses a separate half-life value: + // var halfLife:Float = -duration / logBase(2, precision); + // lerp(current, target, 1 - exp2(-elapsed / halfLife)); + + if (current == target) return target; + + var result:Float = lerp(current, target, 1 - Math.pow(precision, elapsed / duration)); + + // TODO: Is there a better way to ensure a lerp which actually reaches the target? + // Research a framerate-independent PID lerp. + if (Math.abs(result - target) < (precision * target)) result = target; + + return result; + } +} diff --git a/source/funkin/util/MemoryUtil.hx b/source/funkin/util/MemoryUtil.hx new file mode 100644 index 00000000..10b38c03 --- /dev/null +++ b/source/funkin/util/MemoryUtil.hx @@ -0,0 +1,113 @@ +package funkin.util; + +/** + * Utilities for working with the garbage collector. + * + * HXCPP is built on Immix. + * HTML5 builds use the browser's built-in mark-and-sweep and JS has no APIs to interact with it. + * @see https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/immix/ + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management + * @see https://betterprogramming.pub/deep-dive-into-garbage-collection-in-javascript-6881610239a + * @see https://github.com/HaxeFoundation/hxcpp/blob/master/docs/build_xml/Defines.md + * @see cpp.vm.Gc + */ +class MemoryUtil +{ + public static function buildGCInfo():String + { + #if cpp + var result:String = 'HXCPP-Immix:'; + result += '\n- Memory Used: ${cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_USAGE)} bytes'; + result += '\n- Memory Reserved: ${cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_RESERVED)} bytes'; + result += '\n- Memory Current Pool: ${cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_CURRENT)} bytes'; + result += '\n- Memory Large Pool: ${cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_LARGE)} bytes'; + result += '\n- HXCPP Debugger: ${#if HXCPP_DEBUGGER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Generational Mode: ${#if HXCPP_GC_GENERATIONAL 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_MOVING 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_DYNAMIC_SIZE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_BIG_BLOCKS 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Debug Link: ${#if HXCPP_DEBUG_LINK 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Stack Trace: ${#if HXCPP_STACK_TRACE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Stack Trace Line Numbers: ${#if HXCPP_STACK_LINE 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Pointer Validation: ${#if HXCPP_CHECK_POINTER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Profiler: ${#if HXCPP_PROFILER 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP Local Telemetry: ${#if HXCPP_TELEMETRY 'Enabled' #else 'Disabled' #end}'; + result += '\n- HXCPP C++11: ${#if HXCPP_CPP11 'Enabled' #else 'Disabled' #end}'; + result += '\n- Source Annotation: ${#if annotate_source 'Enabled' #else 'Disabled' #end}'; + #elseif js + var result:String = 'JS-MNS:'; + result += '\n- Memory Used: ${getMemoryUsed()} bytes'; + #else + var result:String = 'Unknown GC'; + #end + + return result; + } + + /** + * Calculate the total memory usage of the program, in bytes. + * @return Int + */ + public static function getMemoryUsed():#if cpp Float #else Int #end + { + #if cpp + // There is also Gc.MEM_INFO_RESERVED, MEM_INFO_CURRENT, and MEM_INFO_LARGE. + return cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_USAGE); + #else + return openfl.system.System.totalMemory; + #end + } + + /** + * Enable garbage collection if it was previously disabled. + */ + public static function enable():Void + { + #if cpp + cpp.vm.Gc.enable(true); + #else + throw 'Not implemented!'; + #end + } + + /** + * Disable garbage collection entirely. + */ + public static function disable():Void + { + #if cpp + cpp.vm.Gc.enable(false); + #else + throw 'Not implemented!'; + #end + } + + /** + * Manually perform garbage collection once. + * Should only be called from the main thread. + * @param major `true` to perform major collection, whatever that means. + */ + public static function collect(major:Bool = false):Void + { + #if cpp + cpp.vm.Gc.run(major); + #else + throw 'Not implemented!'; + #end + } + + /** + * Perform major garbage collection repeatedly until less than 16kb of memory is freed in one operation. + * Should only be called from the main thread. + * + * NOTE: This is DIFFERENT from actual compaction, + */ + public static function compact():Void + { + #if cpp + cpp.vm.Gc.compact(); + #else + throw 'Not implemented!'; + #end + } +} diff --git a/source/funkin/util/MouseUtil.hx b/source/funkin/util/MouseUtil.hx new file mode 100644 index 00000000..5248437a --- /dev/null +++ b/source/funkin/util/MouseUtil.hx @@ -0,0 +1,48 @@ +package funkin.util; + +import flixel.math.FlxPoint; + +/** + * Utility functions related to the mouse. + */ +class MouseUtil +{ + static var oldCamPos:FlxPoint = new FlxPoint(); + static var oldMousePos:FlxPoint = new FlxPoint(); + + /** + * Used to be for general camera middle click dragging, now generalized for any click and drag type shit! + * Listen I don't make the rules here + * @param target what you want to be dragged, defaults to CAMERA SCROLL + * @param jusPres the "justPressed", should be a button of some sort + * @param pressed the "pressed", which should be the same button as `jusPres` + */ + public static function mouseCamDrag(?target:FlxPoint, ?jusPres:Bool, ?pressed:Bool):Void + { + if (target == null) target = FlxG.camera.scroll; + + if (jusPres == null) jusPres = FlxG.mouse.justPressedMiddle; + + if (pressed == null) pressed = FlxG.mouse.pressedMiddle; + + if (jusPres) + { + oldCamPos.set(target.x, target.y); + oldMousePos.set(FlxG.mouse.screenX, FlxG.mouse.screenY); + } + + if (pressed) + { + target.x = oldCamPos.x - (FlxG.mouse.screenX - oldMousePos.x); + target.y = oldCamPos.y - (FlxG.mouse.screenY - oldMousePos.y); + } + } + + /** + * Increment the zoom level of the current camera by the mouse wheel scroll value. + */ + public static function mouseWheelZoom():Void + { + if (FlxG.mouse.wheel != 0) FlxG.camera.zoom += FlxG.mouse.wheel * (0.1 * FlxG.camera.zoom); + } +} diff --git a/source/funkin/util/PlatformUtil.hx b/source/funkin/util/PlatformUtil.hx new file mode 100644 index 00000000..ae8c1dce --- /dev/null +++ b/source/funkin/util/PlatformUtil.hx @@ -0,0 +1,61 @@ +package funkin.util; + +/** + * Utility functions related to specific platforms. + */ +class PlatformUtil +{ + /** + * Returns true if the current platform is MacOS. + * + * NOTE: Only use this for choosing modifier keys for shortcut hints. + * @return Whether the current platform is MacOS, or HTML5 running on MacOS. + */ + public static function isMacOS():Bool + { + #if mac + return true; + #elseif html5 + return js.Browser.window.navigator.platform.startsWith('Mac') + || js.Browser.window.navigator.platform.startsWith('iPad') + || js.Browser.window.navigator.platform.startsWith('iPhone'); + #else + return false; + #end + } + + /** + * Detects and returns the current host platform. + * Always returns `HTML5` on web, regardless of the computer running that browser. + * @return The host platform, or `null` if the platform could not be detected. + */ + public static function detectHostPlatform():Null + { + #if html5 + return HTML5; + #else + switch (Sys.systemName()) + { + case ~/window/i.match(_) => true: + return WINDOWS; + case ~/linux/i.match(_) => true: + return LINUX; + case ~/mac/i.match(_) => true: + return MAC; + default: + return null; + } + #end + } +} + +/** + * Represents a host platform. + */ +enum HostPlatform +{ + WINDOWS; + LINUX; + MAC; + HTML5; +} diff --git a/source/funkin/util/ReflectUtil.hx b/source/funkin/util/ReflectUtil.hx new file mode 100644 index 00000000..97fad63a --- /dev/null +++ b/source/funkin/util/ReflectUtil.hx @@ -0,0 +1,166 @@ +package funkin.util; + +import Type.ValueType; + +class ReflectUtil +{ + public static function callMethod(obj:Dynamic, name:String, args:Array):Dynamic + { + throw "Function Reflect.callMethod is blacklisted."; + } + + public static function compare(valueA:Dynamic, valueB:Dynamic):Int + { + return compareValues(valueA, valueB); + } + + public static function compareValues(valueA:Dynamic, valueB:Dynamic):Int + { + return Reflect.compare(valueA, valueB); + } + + public static function compareMethods(functionA:Dynamic, functionB:Dynamic):Bool + { + return Reflect.compareMethods(functionA, functionB); + } + + public static function copy(obj:Dynamic):Dynamic + { + return copyAnonymousFieldsOf(obj); + } + + public static function copyAnonymousFieldsOf(obj:Dynamic):Dynamic + { + return Reflect.copy(obj); + } + + public static function delete(obj:Dynamic, name:String):Bool + { + return delete(obj, name); + } + + public static function deleteAnonymousField(obj:Dynamic, name:String):Bool + { + return Reflect.deleteField(obj, name); + } + + public static function getField(obj:Dynamic, name:String):Dynamic + { + return getAnonymousField(obj, name); + } + + public static function getAnonymousField(obj:Dynamic, name:String):Dynamic + { + return Reflect.field(obj, name); + } + + public static function getFieldsOf(obj:Dynamic):Array + { + return getAnonymousFieldsOf(obj); + } + + public static function getAnonymousFieldsOf(obj:Dynamic):Array + { + return Reflect.fields(obj); + } + + public static function getProperty(obj:Dynamic, name:String):Dynamic + { + return Reflect.getProperty(obj, name); + } + + public static function hasField(obj:Dynamic, name:String):Bool + { + return Reflect.hasField(obj, name); + } + + public static function hasAnonymousField(obj:Dynamic, name:String):Bool + { + return Reflect.hasField(obj, name); + } + + public static function isEnumValue(value:Dynamic):Bool + { + return Reflect.isEnumValue(value); + } + + public static function isFunction(value:Dynamic):Bool + { + return Reflect.isFunction(value); + } + + public static function isObject(value:Dynamic):Bool + { + return Reflect.isObject(value); + } + + public static function setField(obj:Dynamic, name:String, value:Dynamic):Void + { + return setAnonymousField(obj, name, value); + } + + public static function setAnonymousField(obj:Dynamic, name:String, value:Dynamic):Void + { + return Reflect.setField(obj, name, value); + } + + public static function setProperty(obj:Dynamic, name:String, value:Dynamic):Void + { + return Reflect.setProperty(obj, name, value); + } + + public static function createEmptyInstance(cls:Class):Dynamic + { + throw "Function Type.createEmptyInstance is blacklisted."; + } + + public static function createInstance(cls:Class, args:Array):Dynamic + { + throw "Function Type.createInstance is blacklisted."; + } + + public static function resolveClass(name:String):Class + { + throw "Function Type.resolveClass is blacklisted."; + } + + public static function resolveEnum(name:String):Enum + { + throw "Function Type.resolveEnum is blacklisted."; + } + + public static function typeof(value:Dynamic):ValueType + { + throw "Function Type.typeof is blacklisted."; + } + + public static function getClassFields(cls:Class):Array + { + return Type.getClassFields(cls); + } + + public static function getClassFieldsOf(obj:Dynamic):Array + { + return Type.getClassFields(Type.getClass(obj)); + } + + public static function getInstanceFields(cls:Class):Array + { + return Type.getInstanceFields(cls); + } + + public static function getInstanceFieldsOf(obj:Dynamic):Array + { + return Type.getInstanceFields(Type.getClass(obj)); + } + + public static function getClassName(cls:Class):String + { + return Type.getClassName(cls); + } + + public static function getClassNameOf(obj:Dynamic):String + { + return Type.getClassName(Type.getClass(obj)); + } +} diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx new file mode 100644 index 00000000..b94a1066 --- /dev/null +++ b/source/funkin/util/SerializerUtil.hx @@ -0,0 +1,91 @@ +package funkin.util; + +import haxe.Json; +import haxe.io.Bytes; + +typedef ScoreInput = +{ + var d:Int; // Key pressed + var l:Int; // Duration + var t:Int; // Start timestamp +} + +/** + * Functions dedicated to serializing and deserializing data. + * NOTE: Use `json2object` wherever possible, it's way more efficient. + */ +class SerializerUtil +{ + static final INDENT_CHAR = "\t"; + + /** + * Convert a Haxe object to a JSON string. + * NOTE: Use `json2object.JsonWriter` WHEREVER POSSIBLE. Do not use this one unless you ABSOLUTELY HAVE TO it's SLOW! + * And don't even THINK about using `haxe.Json.stringify` without the replacer! + */ + public static function toJSON(input:Dynamic, pretty:Bool = true):String + { + return Json.stringify(input, replacer, pretty ? INDENT_CHAR : null); + } + + /** + * Convert a JSON string to a Haxe object. + */ + public static function fromJSON(input:String):Dynamic + { + try + { + return Json.parse(input); + } + catch (e) + { + trace('An error occurred while parsing JSON from string data'); + trace(e); + return null; + } + } + + /** + * Convert a JSON byte array to a Haxe object. + */ + public static function fromJSONBytes(input:Bytes):Dynamic + { + try + { + return Json.parse(input.toString()); + } + catch (e:Dynamic) + { + trace('An error occurred while parsing JSON from byte data'); + trace(e); + return null; + } + } + + /** + * Customize how certain types are serialized when converting to JSON. + */ + static function replacer(key:String, value:Dynamic):Dynamic + { + // Hacky because you can't use `isOfType` on a struct. + if (key == "version") + { + if (Std.isOfType(value, String)) return value; + + // Stringify Version objects. + return serializeVersion(cast value); + } + + // Else, return the value as-is. + return value; + } + + static inline function serializeVersion(value:thx.semver.Version):String + { + var result = '${value.major}.${value.minor}.${value.patch}'; + if (value.hasPre) result += '-${value.pre}'; + // TODO: Merge fix for version.hasBuild + if (value.build.length > 0) result += '+${value.build}'; + return result; + } +} diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx new file mode 100644 index 00000000..f6d3721f --- /dev/null +++ b/source/funkin/util/SortUtil.hx @@ -0,0 +1,147 @@ +package funkin.util; + +import flixel.graphics.frames.FlxFrame; +#if !macro +import flixel.FlxBasic; +import flixel.util.FlxSort; +#end +import funkin.play.notes.NoteSprite; +import funkin.data.song.SongData.SongEventData; +import funkin.data.song.SongData.SongNoteData; + +/** + * Utility functions related to sorting. + * + * NOTE: `Array.sort()` takes a function `(x, y) -> Int`. + * If the objects are in the correct order (x before y), return a negative value. + * If the objects need to be swapped (y before x), return a negative value. + * If the objects are equal, return 0. + * + * NOTE: `Array.sort()` does NOT guarantee that the order of equal elements. `haxe.ds.ArraySort.sort()` does guarantee this. + * NOTE: `Array.sort()` may not be the most efficient sorting algorithm for all use cases (especially if the array is known to be mostly sorted). + * You may consider using one of the functions in `funkin.util.tools.ArraySortTools` instead. + * NOTE: Both sort functions modify the array in-place. You may consider using `Reflect.copy()` to make a copy of the array before sorting. + */ +class SortUtil +{ + /** + * You can use this function in FlxTypedGroup.sort() to sort FlxObjects by their z-index values. + * The value defaults to 0, but by assigning it you can easily rearrange objects as desired. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first FlxObject to compare. + * @param b The second FlxObject to compare. + * @return 1 if `a` has a higher z-index, -1 if `b` has a higher z-index. + */ + public static inline function byZIndex(order:Int, a:FlxBasic, b:FlxBasic):Int + { + if (a == null || b == null) return 0; + return FlxSort.byValues(order, a.zIndex, b.zIndex); + } + + /** + * Given two Notes, returns 1 or -1 based on whether `a` or `b` has an earlier strumtime. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Note to compare. + * @param b The second Note to compare. + * @return 1 if `a` has an earlier strumtime, -1 if `b` has an earlier strumtime. + */ + public static inline function byStrumtime(order:Int, a:NoteSprite, b:NoteSprite):Int + { + return noteDataByTime(order, a.noteData, b.noteData); + } + + /** + * Given two Note Data objects, returns 1 or -1 based on whether `a` or `b` has an earlier time. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Event to compare. + * @param b The second Event to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. + */ + public static inline function noteDataByTime(order:Int, a:SongNoteData, b:SongNoteData):Int + { + return FlxSort.byValues(order, a.time, b.time); + } + + /** + * Given two Event Data objects, returns 1 or -1 based on whether `a` or `b` has an earlier time. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Event to compare. + * @param b The second Event to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. + */ + public static inline function eventDataByTime(order:Int, a:SongEventData, b:SongEventData):Int + { + return FlxSort.byValues(order, a.time, b.time); + } + + /** + * Given two FlxFrames, sort their names alphabetically. + * + * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING` + * @param a The first Frame to compare. + * @param b The second Frame to compare. + * @return 1 if `a` has an earlier time, -1 if `b` has an earlier time. + */ + public static inline function byFrameName(a:FlxFrame, b:FlxFrame):Int + { + return alphabetically(a.name, b.name); + } + + /** + * Sort predicate for sorting strings alphabetically. + * @param a The first string to compare. + * @param b The second string to compare. + * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal + */ + public static function alphabetically(?a:String, ?b:String):Int + { + a = a.toUpperCase(); + b = b.toUpperCase(); + + // Sort alphabetically. Yes that's how this works. + return a == b ? 0 : a > b ? 1 : -1; + } + + /** + * Sort predicate which sorts two strings alphabetically, but prioritizes a specific string first. + * Example usage: `array.sort(defaultThenAlphabetical.bind('test'))` will sort the array so that the string 'test' is first. + * + * @param defaultValue The value to prioritize. + * @param a The first string to compare. + * @param b The second string to compare. + * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal + */ + public static function defaultThenAlphabetically(defaultValue:String, a:String, b:String):Int + { + if (a == b) return 0; + if (a == defaultValue) return -1; + if (b == defaultValue) return 1; + return alphabetically(a, b); + } + + /** + * Sort predicate which sorts two strings alphabetically, but prioritizes a specific string first. + * Example usage: `array.sort(defaultsThenAlphabetical.bind(['test']))` will sort the array so that the string 'test' is first. + * + * @param defaultValues The values to prioritize. + * @param a The first string to compare. + * @param b The second string to compare. + * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal + */ + public static function defaultsThenAlphabetically(defaultValues:Array, a:String, b:String):Int + { + if (a == b) return 0; + if (defaultValues.contains(a) && defaultValues.contains(b)) + { + // Sort by index in defaultValues + return defaultValues.indexOf(a) - defaultValues.indexOf(b); + }; + if (defaultValues.contains(a)) return -1; + if (defaultValues.contains(b)) return 1; + return alphabetically(a, b); + } +} diff --git a/source/funkin/util/TimerUtil.hx b/source/funkin/util/TimerUtil.hx new file mode 100644 index 00000000..eb2690e1 --- /dev/null +++ b/source/funkin/util/TimerUtil.hx @@ -0,0 +1,49 @@ +package funkin.util; + +import funkin.util.tools.FloatTools; +import haxe.Timer; + +class TimerUtil +{ + /** + * Store the current time. + */ + public static function start():Float + { + return Timer.stamp(); + } + + /** + * Return the elapsed time. + */ + static function took(start:Float, ?end:Float):Float + { + var endOrNow:Float = end != null ? end : Timer.stamp(); + return endOrNow - start; + } + + /** + * Return the elapsed time in seconds as a string. + * @param start The start time. + * @param end The end time. + * @param precision The number of decimal places to round to. + * @return The elapsed time in seconds as a string. + */ + public static function seconds(start:Float, ?end:Float, ?precision = 2):String + { + var seconds:Float = FloatTools.round(took(start, end), precision); + return '${seconds} seconds'; + } + + /** + * Return the elapsed time in milliseconds as a string. + * @param start The start time. + * @param end The end time. + * @return The elapsed time in milliseconds as a string. + */ + public static function ms(start:Float, ?end:Float):String + { + var seconds:Float = took(start, end); + return '${seconds * 1000} ms'; + } +} diff --git a/source/funkin/util/TrackerToolButtonUtil.hx b/source/funkin/util/TrackerToolButtonUtil.hx new file mode 100644 index 00000000..161d9265 --- /dev/null +++ b/source/funkin/util/TrackerToolButtonUtil.hx @@ -0,0 +1,31 @@ +package funkin.util; + +import flixel.system.debug.interaction.tools.Pointer.GraphicCursorCross; +import flixel.system.debug.interaction.Interaction; +import flixel.system.debug.interaction.tools.Tool; + +/** + * the name is a bit of a mouthful, but this adds a button to the + * FlxDebugger Interaction window, which when pressed will open a + * tracking window corresponding to whatever currently selected objects are + */ +class TrackerToolButtonUtil extends Tool +{ + override function init(brain:Interaction):Tool + { + super.init(brain); + + _name = "Add Tracker"; + setButton(GraphicCursorCross); + + button.upHandler = function() { + brain.selectedItems.forEach(function(item) { + FlxG.debugger.track(item); + }); + }; + + button.toggleMode = false; + + return this; + } +} diff --git a/source/funkin/util/TrackerUtil.hx b/source/funkin/util/TrackerUtil.hx new file mode 100644 index 00000000..a2552f63 --- /dev/null +++ b/source/funkin/util/TrackerUtil.hx @@ -0,0 +1,25 @@ +package funkin.util; + +// This may or may not already be imported via imports.hx... +import flixel.system.debug.watch.Tracker; +import flixel.FlxG; + +/** + * Utility class that helps manage adding profiles to the flixel debugger tracker + */ +class TrackerUtil +{ + /** + * Adds profiles to the debugger to help track certain game objects. + * NOTE: This isn't the full list of profiles made, as sometimes they're made + * on in various places in code for random debugging purposes! + * Might be good to put them all here though! + */ + public static function initTrackers():Void + { + #if FLX_DEBUG + Tracker.addProfile(new TrackerProfile(Highscore, ['tallies'])); + FlxG.console.registerClass(Highscore); + #end + } +} diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx new file mode 100644 index 00000000..475275cb --- /dev/null +++ b/source/funkin/util/VersionUtil.hx @@ -0,0 +1,129 @@ +package funkin.util; + +import thx.semver.Version.Identifier.StringId; + +/** + * Utility functions for operating on semantic versions. + * + * Remember, increment the patch version (1.0.x) if you make a bugfix, + * increment the minor version (1.x.0) if you make a new feature (but previous content is still compatible), + * and increment the major version (x.0.0) if you make a breaking change (e.g. new API or reorganized file format). + */ +class VersionUtil +{ + /** + * Checks that a given verison number satisisfies a given version rule. + * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports. + * @param version The semantic version to validate. + * @param versionRule The version rule to validate against. + * @return `true` if the version satisfies the rule, `false` otherwise. + */ + public static function validateVersion(version:thx.semver.Version, versionRule:thx.semver.VersionRule):Bool + { + try + { + var versionRaw:thx.semver.Version.SemVer = version; + return version.satisfies(versionRule); + } + catch (e) + { + trace('[VERSIONUTIL] Invalid semantic version: ${version}'); + return false; + } + } + + public static function repairVersion(version:thx.semver.Version):thx.semver.Version + { + var versionData:thx.semver.Version.SemVer = version; + + if (thx.Types.isAnonymousObject(versionData.version)) + { + // This is bad! versionData.version should be an array! + trace('[SAVE] Version data repair required! (got ${versionData.version})'); + // Turn the objects back into arrays. + // I'd use DynamicsT.values but IDK if it maintains order + versionData.version = [versionData.version[0], versionData.version[1], versionData.version[2]]; + + // This is so jank but it should work. + var buildData:Dynamic = cast versionData.build; + var buildDataFixed:Array = thx.Dynamics.DynamicsT.values(buildData) + .map(function(d:Dynamic) return StringId(d.toString())); + versionData.build = buildDataFixed; + + var preData:Dynamic = cast versionData.pre; + var preDataFixed:Array = thx.Dynamics.DynamicsT.values(preData).map(function(d:Dynamic) return StringId(d.toString())); + versionData.pre = preDataFixed; + + var fixedVersion:thx.semver.Version = versionData; + trace('[SAVE] Fixed version: ${fixedVersion}'); + return fixedVersion; + } + else + { + trace('[SAVE] Version data repair not required (got ${version})'); + // No need for repair. + return version; + } + } + + /** + * Checks that a given verison number satisisfies a given version rule. + * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports. + * @param version The semantic version to validate. + * @param versionRule The version rule to validate against. + * @return `true` if the version satisfies the rule, `false` otherwise. + */ + public static function validateVersionStr(version:String, versionRule:String):Bool + { + try + { + var version:thx.semver.Version = version; + var versionRule:thx.semver.VersionRule = versionRule; + return version.satisfies(versionRule); + } + catch (e) + { + trace('[VERSIONUTIL] Invalid semantic version: ${version}'); + return false; + } + } + + /** + * Get and parse the semantic version from a JSON string. + * @param input The JSON string to parse. + * @return The semantic version, or null if it could not be parsed. + */ + public static function getVersionFromJSON(input:Null):Null + { + if (input == null) return null; + var parsed:Dynamic = SerializerUtil.fromJSON(input); + if (parsed == null) return null; + if (parsed.version == null) return null; + var versionStr:String = parsed.version; // Dynamic -> String cast + var version:thx.semver.Version = versionStr; // Implicit, not explicit, cast. + return version; + } + + /** + * Get and parse the semantic version from a JSON string. + * @param input The JSON string to parse. + * @return The semantic version, or null if it could not be parsed. + */ + public static function parseVersion(input:Dynamic):Null + { + if (input == null) return null; + + if (Std.isOfType(input, String)) + { + var inputStr:String = input; + var version:thx.semver.Version = inputStr; + return version; + } + else + { + var semVer:thx.semver.Version.SemVer = input; + var version:thx.semver.Version = semVer; + return version; + } + } +} diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx new file mode 100644 index 00000000..ee64660c --- /dev/null +++ b/source/funkin/util/WindowUtil.hx @@ -0,0 +1,181 @@ +package funkin.util; + +import flixel.util.FlxSignal.FlxTypedSignal; + +using StringTools; + +/** + * Utilities for operating on the current window, such as changing the title. + */ +#if (cpp && windows) +@:cppFileCode(' +#include +#include +#include +') +#end +class WindowUtil +{ + /** + * A regex to match valid URLs. + */ + public static final URL_REGEX:EReg = ~/^https?:\/?\/?(?:www\.)?[-a-zA-Z0-9@:%_\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/; + + /** + * Sanitizes a URL via a regex. + * + * @param targetUrl The URL to sanitize. + * @return The sanitized URL, or an empty string if the URL is invalid. + */ + public static function sanitizeURL(targetUrl:String):String + { + targetUrl = (targetUrl ?? '').trim(); + if (targetUrl == '') + { + return ''; + } + + final lowerUrl:String = targetUrl.toLowerCase(); + if (!lowerUrl.startsWith('http:') && !lowerUrl.startsWith('https:')) + { + targetUrl = 'http://' + targetUrl; + } + + if (URL_REGEX.match(targetUrl)) + { + return URL_REGEX.matched(0); + } + + return ''; + } + + /** + * Runs platform-specific code to open a URL in a web browser. + * @param targetUrl The URL to open. + */ + 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 + targetUrl = sanitizeURL(targetUrl); + if (targetUrl == '') + { + throw 'Invalid URL: "$targetUrl"'; + } + + #if linux + Sys.command('/usr/bin/xdg-open $targetUrl &'); + #else + // This should work on Windows and HTML5. + FlxG.openURL(targetUrl); + #end + #else + throw 'Cannot open URLs on this platform.'; + #end + } + + #if FEATURE_DEBUG_TRACY + /** + * Initialize Tracy. + * NOTE: Call this from the main thread ONLY! + */ + public static function initTracy():Void + { + // Apply a marker to indicate frame end for the Tracy profiler. + // Do this only if Tracy is configured to prevent lag. + openfl.Lib.current.stage.addEventListener(openfl.events.Event.EXIT_FRAME, (e:openfl.events.Event) -> { + cpp.vm.tracy.TracyProfiler.frameMark(); + }); + + var appInfoMessage = funkin.util.logging.CrashHandler.buildSystemInfo(); + + trace("Friday Night Funkin': Connection to Tracy profiler successful."); + + // Post system info like Git hash + cpp.vm.tracy.TracyProfiler.messageAppInfo(appInfoMessage); + + cpp.vm.tracy.TracyProfiler.setThreadName("main"); + } + #end + + /** + * Dispatched when the game window is closed. + */ + public static final windowExit:FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); + + /** + * Wires up FlxSignals that happen based on window activity. + * For example, we can run a callback when the window is closed. + */ + public static function initWindowEvents():Void + { + // onUpdate is called every frame just before rendering. + + // onExit is called when the game window is closed. + openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int) { + windowExit.dispatch(exitCode); + }); + + openfl.Lib.current.stage.addEventListener(openfl.events.KeyboardEvent.KEY_DOWN, (e:openfl.events.KeyboardEvent) -> { + for (key in PlayerSettings.player1.controls.getKeysForAction(WINDOW_FULLSCREEN)) + { + // FlxG.stage.focus is set to null by the debug console stuff, + // so when that's in focus, we don't want to toggle fullscreen using F + // (annoying when tying "FlxG" in console... lol) + #if FLX_DEBUG + @:privateAccess + if (FlxG.game.debugger.visible) + { + return; + } + #end + + if (e.keyCode == key) + { + openfl.Lib.application.window.fullscreen = !openfl.Lib.application.window.fullscreen; + } + } + }); + } + + /** + * Turns off that annoying "Report to Microsoft" dialog that pops up when the game crashes. + */ + public static function disableCrashHandler():Void + { + #if (cpp && windows) + untyped __cpp__('SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);'); + #else + // Do nothing. + #end + } + + /** + * Sets the title of the application window. + * @param value The title to use. + */ + public static function setWindowTitle(value:String):Void + { + lime.app.Application.current.window.title = value; + } + + public static function setVSyncMode(value:lime.ui.WindowVSyncMode):Void + { + // vsync crap dont worky on mac rn derp + #if !mac + var res:Bool = FlxG.stage.application.window.setVSyncMode(value); + + // SDL_GL_SetSwapInterval returns the value we assigned on success, https://wiki.libsdl.org/SDL2/SDL_GL_GetSwapInterval#return-value. + // In lime, we can compare this to the original value to get a boolean. + if (!res) + { + trace('Failed to set VSync mode to ' + value); + FlxG.stage.application.window.setVSyncMode(lime.ui.WindowVSyncMode.OFF); + } + #end + } +} diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx new file mode 100644 index 00000000..b38c993f --- /dev/null +++ b/source/funkin/util/assets/DataAssets.hx @@ -0,0 +1,31 @@ +package funkin.util.assets; + +class DataAssets +{ + static function buildDataPath(path:String):String + { + return 'assets/data/${path}'; + } + + public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array + { + var textAssets = openfl.utils.Assets.list(TEXT); + + var queryPath = buildDataPath(path); + + var results:Array = []; + for (textPath in textAssets) + { + if (textPath.startsWith(queryPath) && textPath.endsWith(suffix)) + { + var pathNoSuffix = textPath.substring(0, textPath.length - suffix.length); + var pathNoPrefix = pathNoSuffix.substring(queryPath.length); + + // No duplicates! Why does this happen? + if (!results.contains(pathNoPrefix)) results.push(pathNoPrefix); + } + } + + return results; + } +} diff --git a/source/funkin/util/assets/FlxAnimationUtil.hx b/source/funkin/util/assets/FlxAnimationUtil.hx new file mode 100644 index 00000000..ee5b0ede --- /dev/null +++ b/source/funkin/util/assets/FlxAnimationUtil.hx @@ -0,0 +1,65 @@ +package funkin.util.assets; + +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.data.animation.AnimationData; + +class FlxAnimationUtil +{ + /** + * Properly adds an animation to a sprite based on the provided animation data. + */ + public static function addAtlasAnimation(target:FlxSprite, anim:AnimationData):Void + { + var frameRate = anim.frameRate == null ? 24 : anim.frameRate; + var looped = anim.looped == null ? false : anim.looped; + var flipX = anim.flipX == null ? false : anim.flipX; + var flipY = anim.flipY == null ? false : anim.flipY; + + if (anim.frameIndices != null && anim.frameIndices.length > 0) + { + // trace('addByIndices(${anim.name}, ${anim.prefix}, ${anim.frameIndices}, ${frameRate}, ${looped}, ${flipX}, ${flipY})'); + target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, '', frameRate, looped, flipX, flipY); + // trace('RESULT:${target.animation.getAnimationList()}'); + } + else + { + // trace('addByPrefix(${anim.name}, ${anim.prefix}, ${frameRate}, ${looped}, ${flipX}, ${flipY})'); + target.animation.addByPrefix(anim.name, anim.prefix, frameRate, looped, flipX, flipY); + // trace('RESULT:${target.animation.getAnimationList()}'); + } + } + + /** + * Properly adds multiple animations to a sprite based on the provided animation data. + */ + public static function addAtlasAnimations(target:FlxSprite, animations:Array):Void + { + for (anim in animations) + { + addAtlasAnimation(target, anim); + } + } + + /** + * Combine two FlxFramesCollection objects into one. + * @param a The first FlxFramesCollection + * @param b The second FlxFramesCollection + * @return FlxFramesCollection The combined FlxFramesCollection + */ + public static function combineFramesCollections(a:FlxFramesCollection, b:FlxFramesCollection):FlxFramesCollection + { + var result:FlxFramesCollection = new FlxFramesCollection(null, ATLAS, null); + + for (frame in a.frames) + { + result.pushFrame(frame); + } + for (frame in b.frames) + { + result.pushFrame(frame); + } + + return result; + } +} diff --git a/source/funkin/util/assets/SoundUtil.hx b/source/funkin/util/assets/SoundUtil.hx new file mode 100644 index 00000000..0b932cd0 --- /dev/null +++ b/source/funkin/util/assets/SoundUtil.hx @@ -0,0 +1,24 @@ +package funkin.util.assets; + +import haxe.io.Bytes; +import openfl.media.Sound as OpenFLSound; +import funkin.audio.FunkinSound; +import lime.media.AudioBuffer; + +class SoundUtil +{ + /** + * Convert byte data into a playable sound. + * + * @param input The byte data. + * @return The playable sound, or `null` if loading failed. + */ + public static function buildSoundFromBytes(input:Null):Null + { + if (input == null) return null; + + var openflSound:OpenFLSound = OpenFLSound.fromAudioBuffer(AudioBuffer.fromBytes(input)); + var output:FunkinSound = FunkinSound.load(openflSound, 1.0, false); + return output; + } +} diff --git a/source/funkin/util/logging/AnsiTrace.hx b/source/funkin/util/logging/AnsiTrace.hx new file mode 100644 index 00000000..55248932 --- /dev/null +++ b/source/funkin/util/logging/AnsiTrace.hx @@ -0,0 +1,143 @@ +package funkin.util.logging; + +class AnsiTrace +{ + /** + * Output a message to the log. + * Called when using `trace()`, and modified from the default to support ANSI colors. + * @param v The value to print. + */ + public static function trace(v:Dynamic, ?info:haxe.PosInfos) + { + #if (NO_FEATURE_LOG_TRACE && !FEATURE_DEBUG_FUNCTIONS) + return; + #end + var str = formatOutput(v, info); + #if FEATURE_DEBUG_TRACY + cpp.vm.tracy.TracyProfiler.message(str, flixel.util.FlxColor.WHITE); + #end + #if js + if (js.Syntax.typeof(untyped console) != "undefined" && (untyped console).log != null) (untyped console).log(str); + #elseif lua + untyped __define_feature__("use._hx_print", _hx_print(str)); + #elseif sys + Sys.println(str); + #else + throw new haxe.exceptions.NotImplementedException() + #end + } + + public static var colorSupported:Bool = #if sys (Sys.getEnv("TERM") == "xterm" || Sys.getEnv("ANSICON") != null) #else false #end; + + // ansi stuff + public static inline var RED = "\x1b[31m"; + public static inline var YELLOW = "\x1b[33m"; + public static inline var WHITE = "\x1b[37m"; + public static inline var NORMAL = "\x1b[0m"; + public static inline var BOLD = "\x1b[1m"; + public static inline var ITALIC = "\x1b[3m"; + + /** + * Format the output to use ANSI colors. + * Edited from the standard `trace()` implementation. + */ + public static function formatOutput(v:Dynamic, infos:haxe.PosInfos):String + { + var str = Std.string(v); + if (infos == null) return str; + + if (colorSupported) + { + var dirs:Array = infos.fileName.split("/"); + dirs[dirs.length - 1] = ansiWrap(dirs[dirs.length - 1], BOLD); + + // rejoin the dirs + infos.fileName = dirs.join("/"); + } + + var pstr = infos.fileName + ":" + ansiWrap(infos.lineNumber, BOLD); + if (infos.customParams != null) for (v in infos.customParams) + str += ", " + Std.string(v); + return pstr + ": " + str; + } + + /** + * Print color pixel art of BF in ANSI format. + */ + public static function traceBF() + { + #if (sys && debug) + if (colorSupported) + { + for (line in ansiBF) + Sys.stdout().writeString(line + "\n"); + Sys.stdout().flush(); + } + #end + } + + public static function ansiWrap(str:Dynamic, ansiCol:String) + { + return ansify(ansiCol) + str + ansify(NORMAL); + } + + public static function ansify(ansiCol:String) + { + return (colorSupported ? ansiCol : ""); + } + + /** + * Color pixel art of BF in ANSI format. + * Generated using https://dom111.github.io/image-to-ansi/ + */ + public static var ansiBF:Array = [ + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;184;46;83m \x1b[48;2;246;87;102m \x1b[48;2;239;83;100m \x1b[48;2;154;23;70m \x1b[48;2;154;23;69m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;191;52;87m \x1b[48;2;246;87;102m \x1b[48;2;241;84;100m \x1b[48;2;191;52;87m \x1b[48;2;153;23;69m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;234;94;114m \x1b[48;2;160;97;151m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;36;35;46m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;47;49;144m \x1b[48;2;246;87;102m \x1b[48;2;80;121;206m \x1b[48;2;193;167;177m \x1b[48;2;246;87;102m \x1b[48;2;184;46;83m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;47;49;144m \x1b[48;2;110;187;236m \x1b[48;2;109;66;125m \x1b[48;2;246;87;102m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;107;177;226m \x1b[48;2;234;94;114m \x1b[48;2;246;87;102m \x1b[48;2;237;81;99m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;106;196m \x1b[48;2;87;133;210m \x1b[48;2;64;105;174m \x1b[48;2;141;248;252m \x1b[48;2;126;219;244m \x1b[48;2;57;65;148m \x1b[48;2;47;49;144m \x1b[48;2;141;248;252m \x1b[48;2;129;225;245m \x1b[48;2;157;94;147m \x1b[48;2;246;87;102m \x1b[48;2;159;27;72m \x1b[48;2;205;63;93m \x1b[48;2;55;27;43m \x1b[48;2;21;21;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;125;216;244m \x1b[48;2;141;248;252m \x1b[48;2;126;219;244m \x1b[48;2;60;97;187m \x1b[48;2;141;248;252m \x1b[48;2;126;219;244m \x1b[48;2;104;173;229m \x1b[48;2;146;68;123m \x1b[48;2;246;87;102m \x1b[48;2;180;44;82m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;104;173;229m \x1b[48;2;146;68;123m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;73;106;199m \x1b[48;2;132;230;247m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;78;118;190m \x1b[48;2;239;83;100m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;73;106;199m \x1b[48;2;132;230;247m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;20;19;31m \x1b[48;2;110;187;236m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;78;118;190m \x1b[48;2;239;83;100m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;73;106;199m \x1b[48;2;132;230;247m \x1b[48;2;110;187;236m \x1b[48;2;20;19;31m \x1b[48;2;110;187;236m \x1b[48;2;51;72;160m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;117;138;166m \x1b[48;2;20;19;31m \x1b[48;2;110;187;236m \x1b[48;2;55;134;228m \x1b[48;2;110;187;236m \x1b[48;2;139;244;251m \x1b[48;2;205;63;93m \x1b[48;2;123;4;53m \x1b[48;2;125;6;54m \x1b[48;2;146;23;68m \x1b[48;2;205;63;93m \x1b[48;2;123;4;53m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;20;19;31m \x1b[48;2;103;130;185m \x1b[48;2;240;174;162m \x1b[48;2;74;107;200m \x1b[48;2;110;187;236m \x1b[48;2;141;248;252m \x1b[48;2;107;177;226m \x1b[48;2;74;107;200m \x1b[48;2;20;19;31m \x1b[48;2;205;63;93m \x1b[48;2;20;19;31m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;110;187;236m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;58;123;219m \x1b[48;2;74;107;200m \x1b[48;2;20;19;31m \x1b[48;2;240;174;162m \x1b[48;2;141;248;252m \x1b[48;2;135;237;249m \x1b[48;2;157;140;181m \x1b[48;2;44;30;46m \x1b[48;2;20;19;31m \x1b[48;2;205;63;93m \x1b[48;2;36;35;46m \x1b[48;2;153;78;112m \x1b[48;2;249;225;202m \x1b[48;2;240;174;162m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;74;107;200m \x1b[48;2;20;19;31m \x1b[48;2;240;174;162m \x1b[48;2;93;37;66m \x1b[48;2;240;174;162m \x1b[48;2;74;107;200m \x1b[48;2;240;174;162m \x1b[48;2;130;96;96m \x1b[48;2;20;19;31m \x1b[48;2;240;174;162m \x1b[48;2;74;107;200m \x1b[48;2;141;248;252m \x1b[48;2;110;187;236m \x1b[48;2;20;19;31m \x1b[48;2;205;63;93m \x1b[48;2;170;35;77m \x1b[48;2;196;126;137m \x1b[48;2;249;225;202m \x1b[48;2;132;70;100m \x1b[48;2;20;19;31m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;73;106;199m \x1b[48;2;132;230;247m \x1b[48;2;138;242;250m \x1b[48;2;74;107;200m \x1b[49m \x1b[48;2;20;19;31m \x1b[48;2;240;174;162m \x1b[48;2;20;19;31m \x1b[48;2;175;111;124m \x1b[48;2;235;169;159m \x1b[48;2;240;174;162m \x1b[48;2;227;160;155m \x1b[48;2;20;19;31m \x1b[48;2;232;165;158m \x1b[48;2;240;174;162m \x1b[48;2;85;109;196m \x1b[48;2;138;242;250m \x1b[48;2;112;191;237m \x1b[48;2;104;181;235m \x1b[48;2;110;187;236m \x1b[48;2;23;22;43m \x1b[48;2;26;23;37m \x1b[48;2;249;225;202m \x1b[48;2;248;220;198m \x1b[48;2;249;225;202m \x1b[48;2;137;90;124m \x1b[48;2;51;112;205m \x1b[48;2;53;128;224m \x1b[48;2;23;25;44m \x1b[48;2;18;18;28m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;73;106;199m \x1b[48;2;109;182;229m \x1b[48;2;74;107;200m \x1b[49m \x1b[48;2;151;78;111m \x1b[48;2;194;126;136m \x1b[48;2;110;100;98m \x1b[48;2;244;194;178m \x1b[48;2;72;42;63m \x1b[48;2;103;76;81m \x1b[48;2;191;136;147m \x1b[48;2;240;174;162m \x1b[48;2;206;136;142m \x1b[48;2;20;19;31m \x1b[48;2;232;165;158m \x1b[48;2;240;174;162m \x1b[48;2;65;128;218m \x1b[48;2;141;248;252m \x1b[48;2;74;107;200m \x1b[48;2;85;133;200m \x1b[48;2;88;139;214m \x1b[48;2;84;69;150m \x1b[48;2;211;167;166m \x1b[48;2;187;116;132m \x1b[48;2;213;145;147m \x1b[48;2;245;205;186m \x1b[48;2;135;83;115m \x1b[48;2;43;66;124m \x1b[48;2;47;87;174m \x1b[48;2;51;130;227m \x1b[48;2;28;40;76m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;71;103;199m \x1b[48;2;73;105;197m \x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;196;126;137m \x1b[48;2;246;209;189m \x1b[48;2;249;225;202m \x1b[48;2;226;159;154m \x1b[48;2;244;215;196m \x1b[48;2;249;225;202m \x1b[48;2;240;174;162m \x1b[48;2;226;159;154m \x1b[48;2;142;54;93m \x1b[48;2;245;213;193m \x1b[48;2;249;225;202m \x1b[48;2;213;185;192m \x1b[48;2;85;132;211m \x1b[48;2;222;158;164m \x1b[48;2;183;111;129m \x1b[48;2;110;187;236m \x1b[48;2;171;158;211m \x1b[48;2;153;78;112m \x1b[48;2;196;126;137m \x1b[48;2;240;174;162m \x1b[48;2;166;93;120m \x1b[48;2;130;70;98m \x1b[48;2;19;19;31m \x1b[48;2;29;34;56m \x1b[48;2;55;93;183m \x1b[48;2;68;101;193m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;249;225;202m \x1b[48;2;196;126;137m \x1b[48;2;218;150;149m \x1b[48;2;249;225;202m \x1b[48;2;240;174;162m \x1b[48;2;196;126;137m \x1b[48;2;47;49;144m \x1b[48;2;196;126;137m \x1b[48;2;153;78;112m \x1b[49m \x1b[48;2;20;19;31m \x1b[48;2;41;43;121m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;249;225;202m \x1b[48;2;244;215;196m \x1b[48;2;249;225;202m \x1b[48;2;145;49;90m \x1b[48;2;249;225;202m \x1b[48;2;240;174;162m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;196;126;137m \x1b[48;2;249;225;202m \x1b[48;2;200;131;139m \x1b[48;2;249;225;202m \x1b[48;2;154;63;98m \x1b[48;2;145;49;90m \x1b[48;2;240;174;162m \x1b[48;2;249;225;202m \x1b[48;2;240;174;162m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;210;141;145m \x1b[48;2;241;181;168m \x1b[48;2;249;225;202m \x1b[48;2;196;126;137m \x1b[48;2;195;125;136m \x1b[48;2;170;94;119m \x1b[48;2;237;73;115m \x1b[48;2;244;75;120m \x1b[48;2;145;49;90m \x1b[48;2;249;225;202m \x1b[48;2;241;181;167m \x1b[48;2;181;121;161m \x1b[48;2;255;224;255m \x1b[48;2;178;117;159m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;136;72;102m \x1b[48;2;190;119;133m \x1b[48;2;171;99;123m \x1b[48;2;152;74;109m \x1b[48;2;244;75;120m \x1b[48;2;145;49;90m \x1b[48;2;190;119;133m \x1b[48;2;185;128;172m \x1b[48;2;180;121;164m \x1b[48;2;255;224;255m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;147;80;107m \x1b[48;2;153;78;112m \x1b[49m \x1b[48;2;36;35;46m \x1b[48;2;98;121;155m \x1b[48;2;50;68;111m \x1b[48;2;55;73;115m \x1b[48;2;36;35;46m \x1b[48;2;251;117;129m \x1b[48;2;205;63;93m \x1b[48;2;230;143;174m \x1b[48;2;255;224;255m \x1b[48;2;145;49;90m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;251;219;252m \x1b[48;2;105;60;85m \x1b[48;2;36;35;46m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;156;82;114m \x1b[48;2;240;174;162m \x1b[48;2;153;78;112m \x1b[49m \x1b[48;2;65;84;125m \x1b[48;2;98;121;155m \x1b[48;2;124;146;175m \x1b[48;2;194;215;238m \x1b[48;2;50;68;111m \x1b[48;2;124;146;175m \x1b[48;2;98;121;155m \x1b[48;2;36;35;46m \x1b[48;2;254;224;245m \x1b[48;2;231;143;176m \x1b[48;2;255;224;255m \x1b[48;2;145;49;90m \x1b[48;2;255;224;255m \x1b[48;2;72;85;110m \x1b[48;2;240;174;162m \x1b[48;2;196;126;137m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;196;126;137m \x1b[48;2;250;227;206m \x1b[48;2;50;68;111m \x1b[48;2;65;84;125m \x1b[48;2;50;68;111m \x1b[48;2;124;146;175m \x1b[48;2;78;99;137m \x1b[48;2;194;215;238m \x1b[48;2;124;146;175m \x1b[48;2;36;35;46m \x1b[48;2;254;224;245m \x1b[48;2;253;170;192m \x1b[48;2;255;224;255m \x1b[48;2;251;117;129m \x1b[48;2;255;224;255m \x1b[48;2;170;105;144m \x1b[48;2;240;174;162m \x1b[48;2;196;126;137m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;76;111m \x1b[48;2;165;91;118m \x1b[48;2;180;108;128m \x1b[48;2;250;227;206m \x1b[48;2;116;138;169m \x1b[48;2;124;146;175m \x1b[48;2;36;35;46m \x1b[48;2;116;138;169m \x1b[48;2;172;193;218m \x1b[48;2;168;206;237m \x1b[48;2;49;62;121m \x1b[48;2;73;92;131m \x1b[48;2;115;137;168m \x1b[48;2;116;138;169m \x1b[48;2;124;146;175m \x1b[48;2;50;40;54m \x1b[48;2;57;43;58m \x1b[48;2;251;170;183m \x1b[48;2;255;206;227m \x1b[48;2;251;117;129m \x1b[48;2;252;132;140m \x1b[48;2;255;224;255m \x1b[48;2;153;78;112m \x1b[48;2;243;190;174m \x1b[48;2;249;226;203m \x1b[48;2;196;126;137m \x1b[48;2;44;70;156m \x1b[48;2;47;86;175m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;239;198;185m \x1b[48;2;250;227;206m \x1b[48;2;244;195;179m \x1b[48;2;250;227;206m \x1b[48;2;50;68;111m \x1b[48;2;166;188;213m \x1b[48;2;36;35;46m \x1b[48;2;46;58;91m \x1b[48;2;79;100;138m \x1b[48;2;71;79;97m \x1b[48;2;60;69;89m \x1b[48;2;136;158;188m \x1b[48;2;117;140;170m \x1b[48;2;36;35;46m \x1b[48;2;71;79;97m \x1b[48;2;79;100;138m \x1b[48;2;62;51;68m \x1b[48;2;246;192;224m \x1b[48;2;253;182;205m \x1b[48;2;255;224;255m \x1b[48;2;144;140;167m \x1b[48;2;82;52;72m \x1b[48;2;120;110;108m \x1b[48;2;250;227;206m \x1b[48;2;229;187;179m \x1b[48;2;169;166;186m \x1b[48;2;47;64;156m \x1b[48;2;46;87;175m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;220;168;164m \x1b[48;2;250;227;206m \x1b[48;2;50;68;111m \x1b[48;2;194;215;238m \x1b[48;2;36;35;46m \x1b[48;2;50;68;111m \x1b[48;2;98;121;155m \x1b[48;2;36;35;46m \x1b[48;2;50;68;111m \x1b[48;2;98;121;155m \x1b[48;2;95;115;147m \x1b[48;2;93;115;150m \x1b[48;2;50;68;111m \x1b[48;2;37;37;48m \x1b[48;2;51;44;59m \x1b[48;2;66;116;206m \x1b[48;2;47;83;172m \x1b[48;2;68;134;227m \x1b[48;2;250;227;206m \x1b[48;2;61;91;174m \x1b[48;2;47;85;173m \x1b[48;2;47;87;175m \x1b[48;2;47;86;175m \x1b[49m \x1b[m", + "\x1b[48;2;153;78;112m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;217;165;163m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;82;52;72m \x1b[48;2;194;215;238m \x1b[48;2;36;35;46m \x1b[48;2;50;68;111m \x1b[48;2;98;121;155m \x1b[48;2;36;35;46m \x1b[48;2;98;121;155m \x1b[48;2;50;68;111m \x1b[48;2;38;41;58m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;47;49;144m \x1b[48;2;47;87;175m \x1b[48;2;45;85;174m \x1b[49m \x1b[m", + "\x1b[48;2;153;78;112m \x1b[48;2;250;227;206m \x1b[48;2;242;185;171m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;217;165;163m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;36;35;46m \x1b[48;2;124;146;175m \x1b[48;2;36;35;46m \x1b[48;2;50;68;111m \x1b[48;2;98;121;155m \x1b[48;2;50;68;111m \x1b[48;2;36;35;46m \x1b[48;2;98;121;155m \x1b[48;2;36;35;46m \x1b[48;2;38;41;58m \x1b[48;2;47;68;159m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;47;87;175m \x1b[48;2;45;85;174m \x1b[49m \x1b[m", + "\x1b[48;2;153;78;112m \x1b[48;2;250;227;206m \x1b[48;2;242;185;171m \x1b[48;2;196;126;137m \x1b[48;2;250;227;206m \x1b[48;2;240;174;162m \x1b[48;2;206;136;142m \x1b[48;2;227;160;155m \x1b[48;2;240;174;162m \x1b[48;2;82;52;72m \x1b[48;2;50;68;111m \x1b[48;2;36;35;46m \x1b[48;2;50;68;111m \x1b[48;2;36;35;46m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;153;55;95m \x1b[48;2;47;87;175m \x1b[48;2;51;130;227m \x1b[48;2;47;87;175m \x1b[48;2;45;85;174m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;152;77;112m \x1b[48;2;228;161;155m \x1b[48;2;237;171;160m \x1b[48;2;196;126;137m \x1b[48;2;193;123;135m \x1b[48;2;177;105;126m \x1b[48;2;193;123;135m \x1b[48;2;37;37;50m \x1b[48;2;155;69;105m \x1b[48;2;36;35;46m \x1b[48;2;49;66;107m \x1b[48;2;36;35;46m \x1b[48;2;159;83;115m \x1b[48;2;155;67;103m \x1b[48;2;47;69;142m \x1b[48;2;47;87;175m \x1b[48;2;46;73;157m \x1b[48;2;47;85;173m \x1b[48;2;63;77;159m \x1b[48;2;247;97;126m \x1b[48;2;254;165;165m \x1b[48;2;253;160;161m \x1b[48;2;159;79;111m \x1b[48;2;72;106;198m \x1b[48;2;50;67;149m \x1b[48;2;53;69;151m \x1b[48;2;157;77;111m \x1b[48;2;151;22;68m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;147;80;107m \x1b[48;2;176;104;125m \x1b[48;2;240;174;162m \x1b[48;2;66;46;63m \x1b[48;2;36;35;46m \x1b[48;2;170;35;77m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;201;153;202m \x1b[48;2;246;87;102m \x1b[48;2;245;171;163m \x1b[48;2;253;156;159m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;47;87;175m \x1b[48;2;154;23;70m \x1b[48;2;246;170;163m \x1b[48;2;254;165;165m \x1b[48;2;246;87;102m \x1b[48;2;245;79;114m \x1b[48;2;118;0;50m \x1b[48;2;246;87;102m \x1b[48;2;219;68;93m \x1b[48;2;118;0;50m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;147;80;107m \x1b[48;2;187;116;132m \x1b[48;2;240;174;162m \x1b[48;2;153;78;112m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;182;124;167m \x1b[48;2;255;224;255m \x1b[48;2;251;127;141m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;225;75;97m \x1b[48;2;154;23;70m \x1b[48;2;159;27;72m \x1b[48;2;246;87;102m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;118;0;50m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;151;78;111m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;200;151;200m \x1b[48;2;216;175;226m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;246;85;105m \x1b[48;2;205;63;93m \x1b[48;2;118;0;50m \x1b[48;2;246;87;102m \x1b[48;2;159;27;72m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;144;16;64m \x1b[48;2;118;0;50m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;182;124;167m \x1b[48;2;255;224;255m \x1b[48;2;153;78;112m \x1b[48;2;182;124;167m \x1b[48;2;255;224;255m \x1b[48;2;205;63;93m \x1b[48;2;159;27;72m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;118;0;50m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;182;124;167m \x1b[48;2;168;74;106m \x1b[48;2;174;39;79m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;211;70;106m \x1b[48;2;233;196;238m \x1b[48;2;255;224;255m \x1b[48;2;178;42;81m \x1b[48;2;205;157;200m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;178;42;81m \x1b[48;2;154;23;70m \x1b[48;2;153;52;92m \x1b[48;2;136;40;82m \x1b[48;2;144;17;63m \x1b[48;2;225;75;97m \x1b[48;2;234;198;240m \x1b[48;2;255;224;255m \x1b[48;2;234;198;240m \x1b[48;2;233;196;238m \x1b[48;2;239;204;243m \x1b[48;2;212;165;204m \x1b[48;2;245;87;103m \x1b[48;2;180;44;82m \x1b[48;2;180;70;102m \x1b[48;2;152;77;112m \x1b[48;2;151;76;110m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;68;104m \x1b[48;2;213;67;95m \x1b[48;2;246;87;102m \x1b[48;2;213;67;95m \x1b[48;2;195;66;97m \x1b[48;2;173;61;105m \x1b[48;2;154;23;70m \x1b[48;2;195;55;89m \x1b[48;2;205;63;93m \x1b[48;2;125;4;54m \x1b[48;2;170;101;145m \x1b[48;2;171;102;146m \x1b[48;2;201;153;202m \x1b[48;2;182;124;167m \x1b[48;2;158;87;122m \x1b[48;2;138;36;79m \x1b[48;2;205;63;93m \x1b[48;2;196;143;183m \x1b[48;2;255;224;255m \x1b[48;2;234;94;114m \x1b[48;2;229;85;104m \x1b[48;2;236;96;117m \x1b[48;2;248;113;131m \x1b[48;2;246;88;103m \x1b[48;2;246;87;102m \x1b[48;2;195;66;97m \x1b[48;2;172;109;148m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;182;124;167m \x1b[48;2;197;147;195m \x1b[48;2;212;170;223m \x1b[48;2;182;124;167m \x1b[48;2;212;170;223m \x1b[48;2;153;78;112m \x1b[48;2;142;44;86m \x1b[48;2;154;23;70m \x1b[48;2;205;63;93m \x1b[48;2;255;224;255m \x1b[48;2;225;187;233m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;200;151;200m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;182;124;167m \x1b[48;2;147;64;101m \x1b[48;2;36;35;46m \x1b[48;2;153;78;112m \x1b[49m \x1b[48;2;66;78;122m \x1b[48;2;200;100;119m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;200;151;200m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;182;124;167m \x1b[48;2;36;35;46m \x1b[49m \x1b[48;2;66;78;122m \x1b[48;2;153;55;95m \x1b[48;2;205;63;93m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;255;224;255m \x1b[48;2;176;115;156m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;185;128;172m \x1b[48;2;153;78;112m \x1b[48;2;36;35;46m \x1b[49m \x1b[48;2;66;78;122m \x1b[48;2;153;55;95m \x1b[48;2;154;23;70m \x1b[48;2;246;87;102m \x1b[48;2;205;63;93m \x1b[48;2;154;23;70m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;176;115;156m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[49m \x1b[48;2;87;54;75m \x1b[48;2;207;163;214m \x1b[48;2;190;136;182m \x1b[48;2;202;151;191m \x1b[48;2;244;187;187m \x1b[48;2;241;206;245m \x1b[48;2;243;209;246m \x1b[48;2;212;170;223m \x1b[48;2;153;78;112m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;56;42;57m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;255;224;255m \x1b[48;2;243;208;246m \x1b[48;2;238;203;242m \x1b[48;2;255;224;255m \x1b[48;2;212;170;223m \x1b[48;2;153;78;112m \x1b[48;2;153;77;111m \x1b[49m \x1b[m", + "\x1b[39m\x1b[49m \x1b[48;2;153;78;112m \x1b[49m \x1b[m" + ]; +} diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx new file mode 100644 index 00000000..911c93a3 --- /dev/null +++ b/source/funkin/util/logging/CrashHandler.hx @@ -0,0 +1,309 @@ +package funkin.util.logging; + +import openfl.Lib; +import openfl.events.UncaughtErrorEvent; +import flixel.util.FlxSignal.FlxTypedSignal; +import flixel.FlxG.FlxRenderMethod; + +/** + * A custom crash handler that writes to a log file and displays a message box. + */ +@:nullSafety +class CrashHandler +{ + public static final LOG_FOLDER = 'logs'; + + /** + * Called before exiting the game when a standard error occurs, like a thrown exception. + * @param message The error message. + */ + public static var errorSignal(default, null):FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); + + /** + * Called before exiting the game when a critical error occurs, like a stack overflow or null object reference. + * CAREFUL: The game may be in an unstable state when this is called. + * @param message The error message. + */ + public static var criticalErrorSignal(default, null):FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); + + /** + * Initializes + */ + public static function initialize():Void + { + trace('[LOG] Enabling standard uncaught error handler...'); + Lib.current.loaderInfo.uncaughtErrorEvents.addEventListener(UncaughtErrorEvent.UNCAUGHT_ERROR, onUncaughtError); + + #if cpp + trace('[LOG] Enabling C++ critical error handler...'); + untyped __global__.__hxcpp_set_critical_error_handler(onCriticalError); + #end + } + + /** + * Called when an uncaught error occurs. + * This handles most thrown errors, and is sufficient to handle everything alone on HTML5. + * @param error Information on the error that was thrown. + */ + static function onUncaughtError(error:UncaughtErrorEvent):Void + { + try + { + errorSignal.dispatch(generateErrorMessage(error)); + + #if sys + logError(error); + #end + + displayError(error); + } + catch (e:Dynamic) + { + trace('Error while handling crash: ' + e); + } + + #if sys + Sys.sleep(1); // wait a few moments of margin to process. + // Exit the game. Since it threw an error, we use a non-zero exit code. + openfl.Lib.application.window.close(); + #end + } + + static function onCriticalError(message:String):Void + { + try + { + criticalErrorSignal.dispatch(message); + + #if sys + logErrorMessage(message, true); + #end + + displayErrorMessage(message); + } + catch (e:Dynamic) + { + trace('Error while handling crash: $e'); + + trace('Message: $message'); + } + + #if sys + Sys.sleep(1); // wait a few moments of margin to process. + // Exit the game. Since it threw an error, we use a non-zero exit code. + openfl.Lib.application.window.close(); + #end + } + + static function displayError(error:UncaughtErrorEvent):Void + { + displayErrorMessage(generateErrorMessage(error)); + } + + static function displayErrorMessage(message:String):Void + { + lime.app.Application.current.window.alert(message, "Fatal Uncaught Exception"); + } + + #if sys + static function logError(error:UncaughtErrorEvent):Void + { + logErrorMessage(generateErrorMessage(error)); + } + + static function logErrorMessage(message:String, critical:Bool = false):Void + { + FileUtil.createDirIfNotExists(LOG_FOLDER); + + sys.io.File.saveContent('$LOG_FOLDER/crash${critical ? '-critical' : ''}-${DateUtil.generateTimestamp()}.log', buildCrashReport(message)); + } + #end + + static function buildCrashReport(message:String):String + { + var fullContents:String = '=====================\n'; + fullContents += ' Funkin Crash Report\n'; + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += buildSystemInfo(); + + fullContents += '\n\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + var currentState:String = 'No state loaded'; + if (FlxG.state != null) + { + var currentStateCls:Null> = Type.getClass(FlxG.state); + if (currentStateCls != null) + { + currentState = Type.getClassName(currentStateCls) ?? 'No state loaded'; + } + } + + fullContents += 'Flixel Current State: ${currentState}\n'; + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += 'Haxelibs: \n'; + + for (lib in Constants.LIBRARY_VERSIONS) + { + fullContents += '- ${lib}\n'; + } + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += 'Loaded mods: \n'; + + if (funkin.modding.PolymodHandler.loadedModIds.length == 0) + { + fullContents += 'No mods loaded.\n'; + } + else + { + for (mod in funkin.modding.PolymodHandler.loadedModIds) + { + fullContents += '- ${mod}\n'; + } + } + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += message; + + fullContents += '\n'; + + return fullContents; + } + + public static function buildSystemInfo():String + { + var fullContents = 'Generated by: ${Constants.GENERATED_BY}\n'; + fullContents += ' Git hash: ${Constants.GIT_HASH} (${Constants.GIT_HAS_LOCAL_CHANGES ? 'MODIFIED' : 'CLEAN'})\n'; + fullContents += 'System timestamp: ${DateUtil.generateTimestamp()}\n'; + var driverInfo = FlxG?.stage?.context3D?.driverInfo ?? 'N/A'; + fullContents += 'Driver info: ${driverInfo}\n'; + #if sys + fullContents += 'Platform: ${Sys.systemName()}\n'; + #end + fullContents += 'Render method: ${renderMethod()}\n'; + + fullContents += '\n'; + + fullContents += '=====================\n'; + + fullContents += '\n'; + + fullContents += MemoryUtil.buildGCInfo(); + + return fullContents; + } + + static function generateErrorMessage(error:UncaughtErrorEvent):String + { + var errorMessage:String = ""; + var callStack:Array = haxe.CallStack.exceptionStack(true); + + errorMessage += '${error.error}\n'; + + for (stackItem in callStack) + { + switch (stackItem) + { + case FilePos(innerStackItem, file, line, column): + errorMessage += ' in ${file}#${line}'; + if (column != null) errorMessage += ':${column}'; + case CFunction: + errorMessage += '[Function] '; + case Module(m): + errorMessage += '[Module(${m})] '; + case Method(classname, method): + errorMessage += '[Function(${classname}.${method})] '; + case LocalFunction(v): + errorMessage += '[LocalFunction(${v})] '; + } + errorMessage += '\n'; + } + + return errorMessage; + } + + public static function queryStatus():Void + { + @:privateAccess + var currentStatus = Lib.current.stage.__uncaughtErrorEvents.__enabled; + trace('ERROR HANDLER STATUS: ' + currentStatus); + + #if openfl_enable_handle_error + trace('Define: openfl_enable_handle_error is enabled'); + #else + trace('Define: openfl_enable_handle_error is disabled'); + #end + + #if openfl_disable_handle_error + trace('Define: openfl_disable_handle_error is enabled'); + #else + trace('Define: openfl_disable_handle_error is disabled'); + #end + } + + public static function induceBasicCrash():Void + { + throw "This is an example of an uncaught exception."; + } + + public static function induceNullObjectReference():Void + { + var obj:Dynamic = null; + var value = obj.test; + } + + public static function induceNullObjectReference2():Void + { + var obj:Dynamic = null; + var value = obj.test(); + } + + public static function induceNullObjectReference3():Void + { + var obj:Dynamic = null; + var value = obj(); + } + + static function renderMethod():String + { + var outputStr:String = 'UNKNOWN'; + outputStr = try + { + switch (FlxG.renderMethod) + { + case FlxRenderMethod.DRAW_TILES: 'DRAW_TILES'; + case FlxRenderMethod.BLITTING: 'BLITTING'; + default: 'UNKNOWN'; + } + } + catch (e) + { + 'ERROR ON QUERY RENDER METHOD: ${e}'; + } + + return outputStr; + } +} diff --git a/source/funkin/util/logging/Perf.hx b/source/funkin/util/logging/Perf.hx new file mode 100644 index 00000000..83da7a32 --- /dev/null +++ b/source/funkin/util/logging/Perf.hx @@ -0,0 +1,76 @@ +package funkin.util.logging; + +/** + * A small utility class for timing how long functions take. + * Specify a string as a label (or don't, by default it uses the name of the function it was called from.) + * + * Example: + * ```haxe + * + * var perf = new Perf(); + * ... + * perf.print(); + * ``` + */ +class Perf +{ + final startTime:Float; + final label:Null; + final posInfos:Null; + + /** + * Create a new performance marker. + * @param label Optionally specify a label to use for the performance marker. Defaults to the function name. + * @param posInfos The position of the calling function. Used to build the default label. + * Note: `haxe.PosInfos` is magic and automatically populated by the compiler! + */ + public function new(?label:String, ?posInfos:haxe.PosInfos) + { + this.label = label; + this.posInfos = posInfos; + startTime = current(); + } + + /** + * The current timestamp, in fractional seconds. + * @return The current timestamp. + */ + static function current():Float + { + #if sys + // This one is more accurate if it's available. + return Sys.time(); + #else + return haxe.Timer.stamp(); + #end + } + + /** + * The duration in seconds since this `Perf` was created. + * @return The duration in seconds + */ + public function duration():Float + { + return current() - startTime; + } + + /** + * A rounded millisecond duration + * @return The duration in milliseconds + */ + public function durationClean():Float + { + var round:Float = 100; + return Math.floor(duration() * Constants.MS_PER_SEC * round) / round; + } + + /** + * Cleanly prints the duration since this `Perf` was created. + */ + public function print():Void + { + var label:String = label ?? (posInfos == null ? 'unknown' : '${posInfos.className}#${posInfos.methodName}()'); + + trace('[PERF] [$label] Took ${durationClean()}ms.'); + } +} diff --git a/source/funkin/util/macro/ClassMacro.hx b/source/funkin/util/macro/ClassMacro.hx new file mode 100644 index 00000000..0918d408 --- /dev/null +++ b/source/funkin/util/macro/ClassMacro.hx @@ -0,0 +1,200 @@ +package funkin.util.macro; + +import haxe.macro.Context; +import haxe.macro.Expr; +import haxe.macro.Type.ClassType; + +/** + * Macros to generate lists of classes at compile time. + * + * This code is a bitch glad Jason figured it out. + * Based on code from CompileTime: https://github.com/jasononeil/compiletime + */ +class ClassMacro +{ + /** + * Gets a list of `Class` for all classes in a specified package. + * + * Example: `var list:Array> = listClassesInPackage("funkin", true);` + * + * @param targetPackage A String containing the package name to query. + * @param includeSubPackages Whether to include classes located in sub-packages of the target package. + * @return A list of classes matching the specified criteria. + */ + public static macro function listClassesInPackage(targetPackage:String, includeSubPackages:Bool = true):ExprOf>> + { + if (!onGenerateCallbackRegistered) + { + onGenerateCallbackRegistered = true; + Context.onGenerate(onGenerate); + } + + var request:String = 'package~${targetPackage}~${includeSubPackages ? "recursive" : "nonrecursive"}'; + + classListsToGenerate.push(request); + + return macro funkin.util.macro.CompiledClassList.get($v{request}); + } + + /** + * Get a list of `Class` for all classes extending a specified class. + * + * Example: `var list:Array> = listSubclassesOf(FlxSprite);` + * + * @param targetClass The class to query for subclasses. + * @return A list of classes matching the specified criteria. + */ + public static macro function listSubclassesOf(targetClassExpr:ExprOf>):ExprOf>> + { + if (!onGenerateCallbackRegistered) + { + onGenerateCallbackRegistered = true; + Context.onGenerate(onGenerate); + } + + var targetClass:ClassType = MacroUtil.getClassTypeFromExpr(targetClassExpr); + var targetClassPath:String = null; + if (targetClass != null) targetClassPath = targetClass.pack.join('.') + '.' + targetClass.name; + + var request:String = 'extend~${targetClassPath}'; + + classListsToGenerate.push(request); + + return macro funkin.util.macro.CompiledClassList.getTyped($v{request}, ${targetClassExpr}); + } + + #if macro + /** + * Callback executed after the typing phase but before the generation phase. + * Receives a list of `haxe.macro.Type` for all types in the program. + * + * Only metadata can be modified at this time, which makes it a BITCH to access the data at runtime. + */ + static function onGenerate(allTypes:Array) + { + // Reset these, since onGenerate persists across multiple builds. + classListsRaw = []; + + for (request in classListsToGenerate) + { + classListsRaw.set(request, []); + } + + for (type in allTypes) + { + switch (type) + { + // Class instances + case TInst(t, _params): + var classType:ClassType = t.get(); + var className:String = t.toString(); + + if (classType.isInterface) + { + // Ignore interfaces. + } + else + { + for (request in classListsToGenerate) + { + if (doesClassMatchRequest(classType, request)) + { + classListsRaw.get(request).push(className); + } + } + } + // Other types (things like enums) + default: + continue; + } + } + + compileClassLists(); + } + + /** + * At this stage in the program, `classListsRaw` is generated, but only accessible by macros. + * To make it accessible at runtime, we must: + * - Convert the String names to actual `Class` instances, and store it as `classLists` + * - Insert the `classLists` into the metadata of the `CompiledClassList` class. + * `CompiledClassList` then extracts the metadata and stores it where it can be accessed at runtime. + */ + static function compileClassLists() + { + var compiledClassList:ClassType = MacroUtil.getClassType("funkin.util.macro.CompiledClassList"); + + if (compiledClassList == null) throw "Could not find CompiledClassList class."; + + // Reset outdated metadata. + if (compiledClassList.meta.has('classLists')) compiledClassList.meta.remove('classLists'); + + var classLists:Array = []; + // Generate classLists. + for (request in classListsToGenerate) + { + // Expression contains String, [Class...] + var classListEntries:Array = [macro $v{request}]; + for (i in classListsRaw.get(request)) + { + // TODO: Boost performance by making this an Array> instead of an Array + // How to perform perform macro reificiation to types given a name? + classListEntries.push(macro $v{i}); + } + + classLists.push(macro $a{classListEntries}); + } + + // Insert classLists into metadata. + compiledClassList.meta.add('classLists', classLists, Context.currentPos()); + } + + static function doesClassMatchRequest(classType:ClassType, request:String):Bool + { + var splitRequest:Array = request.split('~'); + + var requestType:String = splitRequest[0]; + + switch (requestType) + { + case 'package': + var targetPackage:String = splitRequest[1]; + var recursive:Bool = splitRequest[2] == 'recursive'; + + var classPackage:String = classType.pack.join('.'); + + if (recursive) + { + return StringTools.startsWith(classPackage, targetPackage); + } + else + { + var regex:EReg = ~/^${targetPackage}(\.|$)/; + return regex.match(classPackage); + } + case 'extend': + var targetClassName:String = splitRequest[1]; + + var targetClassType:ClassType = MacroUtil.getClassType(targetClassName); + + if (MacroUtil.implementsInterface(classType, targetClassType)) + { + return true; + } + else if (MacroUtil.isSubclassOf(classType, targetClassType)) + { + return true; + } + + return false; + + default: + throw 'Unknown request type: ${requestType}'; + } + } + + static var onGenerateCallbackRegistered:Bool = false; + + static var classListsRaw:Map> = []; + static var classListsToGenerate:Array = []; + #end +} diff --git a/source/funkin/util/macro/CompiledClassList.hx b/source/funkin/util/macro/CompiledClassList.hx new file mode 100644 index 00000000..68415b1b --- /dev/null +++ b/source/funkin/util/macro/CompiledClassList.hx @@ -0,0 +1,68 @@ +package funkin.util.macro; + +import haxe.rtti.Meta; + +/** + * A complement to `ClassMacro`. See `ClassMacro` for more information. + */ +class CompiledClassList +{ + static var classLists:Map>>; + + /** + * Class lists are injected into this class's metadata during the typing phase. + * This function extracts the metadata, at runtime, and stores it in `classLists`. + */ + static function init():Void + { + classLists = []; + + // Meta.getType returns Dynamic>. + var metaData = Meta.getType(CompiledClassList); + + if (metaData.classLists != null) + { + for (list in metaData.classLists) + { + var data:Array = cast list; + + // First element is the list ID. + var id:String = cast data[0]; + + // All other elements are class types. + var classes:List> = new List(); + for (i in 1...data.length) + { + var className:String = cast data[i]; + // var classType:Class = cast data[i]; + var classType:Class = cast Type.resolveClass(className); + classes.push(classType); + } + + classLists.set(id, classes); + } + } + else + { + throw "Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project."; + } + } + + public static function get(request:String):List> + { + if (classLists == null) init(); + + if (!classLists.exists(request)) + { + trace('[WARNING] Class list $request not properly generated. Please debug the build macro.'); + classLists.set(request, new List()); // Make the error only appear once. + } + + return classLists.get(request); + } + + public static inline function getTyped(request:String, type:Class):List> + { + return cast get(request); + } +} diff --git a/source/funkin/util/macro/FlxMacro.hx b/source/funkin/util/macro/FlxMacro.hx new file mode 100644 index 00000000..cec6b72e --- /dev/null +++ b/source/funkin/util/macro/FlxMacro.hx @@ -0,0 +1,37 @@ +package funkin.util.macro; + +#if !display +#if macro +class FlxMacro +{ + /** + * A macro to be called targeting the `FlxBasic` class. + * @return An array of fields that the class contains. + */ + public static macro function buildFlxBasic():Array + { + var pos:haxe.macro.Expr.Position = haxe.macro.Context.currentPos(); + // The FlxBasic class. We can add new properties to this class. + var cls:haxe.macro.Type.ClassType = haxe.macro.Context.getLocalClass().get(); + // The fields of the FlxClass. + var fields:Array = haxe.macro.Context.getBuildFields(); + + // haxe.macro.Context.info('[INFO] ${cls.name}: Adding zIndex attribute...', pos); + + // Here, we add the zIndex attribute to all FlxBasic objects. + // This has no functional code tied to it, but it can be used as a target value + // for the FlxTypedGroup.sort method, to rearrange the objects in the scene. + fields = fields.concat([ + { + name: "zIndex", // Field name. + access: [haxe.macro.Expr.Access.APublic], // Access level + kind: haxe.macro.Expr.FieldType.FVar(macro :Int, macro $v{0}), // Variable type and default value + pos: pos, // The field's position in code. + } + ]); + + return fields; + } +} +#end +#end diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx new file mode 100644 index 00000000..9e3305c4 --- /dev/null +++ b/source/funkin/util/macro/GitCommit.hx @@ -0,0 +1,115 @@ +package funkin.util.macro; + +#if !display +class GitCommit +{ + /** + * Get the SHA1 hash of the current Git commit. + */ + public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf + { + #if !display + // Get the current line number. + var pos = haxe.macro.Context.currentPos(); + + var process = new sys.io.Process('git', ['rev-parse', 'HEAD']); + if (process.exitCode() != 0) + { + var message = process.stderr.readAll().toString(); + haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); + } + + // read the output of the process + var commitHash:String = process.stdout.readLine(); + var commitHashSplice:String = commitHash.substr(0, 7); + + process.close(); + + trace('Git Commit ID: ${commitHashSplice}'); + + // Generates a string expression + return macro $v{commitHashSplice}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call git on every hint. + var commitHash:String = ""; + return macro $v{commitHashSplice}; + #end + } + + /** + * Get the branch name of the current Git commit. + */ + public static macro function getGitBranch():haxe.macro.Expr.ExprOf + { + #if !display + // Get the current line number. + var pos = haxe.macro.Context.currentPos(); + var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + + if (branchProcess.exitCode() != 0) + { + var message = branchProcess.stderr.readAll().toString(); + haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); + } + + var branchName:String = branchProcess.stdout.readLine(); + branchProcess.close(); + trace('Git Branch Name: ${branchName}'); + + // Generates a string expression + return macro $v{branchName}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call git on every hint. + var branchName:String = ""; + return macro $v{branchName}; + #end + } + + /** + * Get whether the local Git repository is dirty or not. + */ + public static macro function getGitHasLocalChanges():haxe.macro.Expr.ExprOf + { + #if !display + // Get the current line number. + var pos = haxe.macro.Context.currentPos(); + var branchProcess = new sys.io.Process('git', ['status', '--porcelain']); + + if (branchProcess.exitCode() != 0) + { + var message = branchProcess.stderr.readAll().toString(); + haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos); + } + + var output:String = ''; + try + { + output = branchProcess.stdout.readLine(); + branchProcess.close(); + } + catch (e) + { + if (e.message == 'Eof') + { + // Do nothing. + // Eof = No output. + } + else + { + // Rethrow other exceptions. + throw e; + } + } + trace('Git Status Output: ${output}'); + + // Generates a string expression + return macro $v{output.length > 0}; + #else + // `#if display` is used for code completion. In this case we just assume true. + return macro $v{true}; + #end + } +} +#end diff --git a/source/funkin/util/macro/HaxelibVersions.hx b/source/funkin/util/macro/HaxelibVersions.hx new file mode 100644 index 00000000..08ec3a7c --- /dev/null +++ b/source/funkin/util/macro/HaxelibVersions.hx @@ -0,0 +1,56 @@ +package funkin.util.macro; + +import haxe.io.Path; + +class HaxelibVersions +{ + public static macro function getLibraryVersions():haxe.macro.Expr.ExprOf> + { + #if !display + return macro $v{formatHmmData()}; + #else + // `#if display` is used for code completion. In this case returning an + // empty string is good enough; We don't want to call functions on every hint. + var commitHash:Array = []; + return macro $v{commitHash}; + #end + } + + #if (macro) + @SuppressWarnings('checkstyle:Dynamic') + static function formatHmmData():Array + { + var result:Array = []; + + var hmmData:Dynamic = haxe.Json.parse(sys.io.File.getContent('hmm.json')); + var dependencies:Array = cast hmmData.dependencies; + for (library in dependencies) + { + switch (library.type) + { + case 'haxelib': + result.push('${library.name} haxelib(${library.version ?? 'None'})'); + case 'git': + result.push('${library.name} git(${library.url}/${library.dir ?? ''}:${library.ref ?? 'None'}'); + case 'mercurial': + result.push('${library.name} mercurial(${library.url}/${library.dir ?? ''}:${library.ref ?? 'None'})'); + case 'dev': + result.push('${library.name} dev(${library.path})'); + case ty: + throw 'Unhandled hmm library type ${ty}'; + } + } + + return result; + } + + static function readLibraryCurrentVersion(libraryName:String):String + { + var path = Path.join([Path.addTrailingSlash(Sys.getCwd()), '.haxelib', libraryName, '.current']); + // This is compile time so we should always have Sys available. + var result = sys.io.File.getContent(path); + + return result; + } + #end +} diff --git a/source/funkin/util/macro/InlineMacro.hx b/source/funkin/util/macro/InlineMacro.hx new file mode 100644 index 00000000..c4025740 --- /dev/null +++ b/source/funkin/util/macro/InlineMacro.hx @@ -0,0 +1,37 @@ +package funkin.util.macro; + +#if macro +using funkin.util.tools.ArrayTools; +#end + +/** + * A macro to make fields inline. + */ +class InlineMacro +{ + /** + * For the given class, find the (static?) field with the given name and make it inline. + * @param field + * @param isStatic + */ + public static macro function makeInline(field:String, isStatic:Bool = false):Array + { + var pos:haxe.macro.Expr.Position = haxe.macro.Context.currentPos(); + // The FlxBasic class. We can add new properties to this class. + var cls:haxe.macro.Type.ClassType = haxe.macro.Context.getLocalClass().get(); + // The fields of the FlxClass. + var fields:Array = haxe.macro.Context.getBuildFields(); + + // Find the field with the given name. + var targetField:Null = thx.Arrays.find(fields, function(f) return f.name == field + && (MacroUtil.isFieldStatic(f) == isStatic)); + + // If the field was not found, throw an error. + if (targetField == null) haxe.macro.Context.error("Field " + field + " not found in class " + cls.name, pos); + + // Add the inline access modifier to the field. + targetField.access.push(AInline); + + return fields; + } +} diff --git a/source/funkin/util/macro/MacroUtil.hx b/source/funkin/util/macro/MacroUtil.hx new file mode 100644 index 00000000..bbe6e71a --- /dev/null +++ b/source/funkin/util/macro/MacroUtil.hx @@ -0,0 +1,231 @@ +package funkin.util.macro; + +import haxe.macro.Context; +import haxe.macro.Expr; +import haxe.macro.Type; + +/** + * A collection of utility functions for Haxe macros. + */ +class MacroUtil +{ + /** + * Gets the value of a Haxe compiler define. + * + * @param key The name of the define to get the value of. + * @param defaultValue The value to return if the define is not set. + * @return An expression containing the value of the define. + */ + public static macro function getDefine(key:String, ?defaultValue:String):haxe.macro.Expr + { + var value:Null = haxe.macro.Context.definedValue(key); + if (value == null) value = defaultValue; + return macro $v{value}; + } + + /** + * Gets the current date and time (at compile time). + * @return A `Date` object containing the current date and time. + */ + public static macro function getDate():ExprOf + { + var date:Date = Date.now(); + var year:Expr = toExpr(date.getFullYear()); + var month:Expr = toExpr(date.getMonth()); + var day:Expr = toExpr(date.getDate()); + var hours:Expr = toExpr(date.getHours()); + var mins:Expr = toExpr(date.getMinutes()); + var secs:Expr = toExpr(date.getSeconds()); + return macro new Date($year, $month, $day, $hours, $mins, $secs); + } + + #if macro + // + // MACRO HELPER FUNCTIONS + // + + /** + * Convert an ExprOf> to a ClassType. + * @see https://github.com/jasononeil/compiletime/blob/master/src/CompileTime.hx#L201 + * @param e The expression to convert. + * @return The `ClassType` + */ + public static function getClassTypeFromExpr(e:Expr):ClassType + { + var classType:ClassType = null; + + var parts:Array = []; + var nextSection:ExprDef = e.expr; + + while (nextSection != null) + { + var section:ExprDef = nextSection; + nextSection = null; + + switch (section) + { + // Expression is a class name with no packages + case EConst(c): + switch (c) + { + case CIdent(cn): + if (cn != "null") parts.unshift(cn); + default: + } + // Expression is a fully qualified package name. + // We need to traverse the expression tree to get the full package name. + case EField(exp, field): + nextSection = exp.expr; + parts.unshift(field); + + // We've reached the end of the expression tree. + default: + } + } + + var fullClassName:String = parts.join('.'); + if (fullClassName != "") + { + var classType:Type = Context.getType(fullClassName); + // Follow typedefs to get the actual class type. + var classTypeParsed:Type = Context.follow(classType, false); + + switch (classTypeParsed) + { + case TInst(t, params): + return t.get(); + default: + // We couldn't parse this class type. + // This function may need to be updated to be more robust. + throw 'Class type could not be parsed: ${fullClassName}'; + } + } + + return null; + } + + /** + * Determine whether a field is static. + * @param field The field to check. + * @return Whether the field is static. + */ + public static function isFieldStatic(field:haxe.macro.Expr.Field):Bool + { + return field.access.contains(AStatic); + } + + /** + * Converts a value to an equivalent macro expression. + */ + public static function toExpr(value:Any):ExprOf + { + return Context.makeExpr(value, Context.currentPos()); + } + + /** + * Determine whether two classes are equal. + * @param class1 The first class to compare. + * @param class2 The second class to compare. + * @return Whether the two classes are equivalent. + */ + public static function areClassesEqual(class1:ClassType, class2:ClassType):Bool + { + return class1.pack.join('.') == class2.pack.join('.') && class1.name == class2.name; + } + + /** + * Retrieve a ClassType from a string name. + * @param name The name of the class to retrieve. + * @return The `ClassType` + */ + public static function getClassType(name:String):ClassType + { + switch (Context.getType(name)) + { + case TInst(t, _params): + return t.get(); + default: + throw 'Class type could not be parsed: ${name}'; + } + } + + /** + * If we are in a build macro, return whether a field already exists on the current class. + * @param name The name of the field to check for. + * @return Whether the field already exists. + */ + public static function fieldAlreadyExists(name:String):Bool + { + for (field in Context.getBuildFields()) + { + if (field.name == name && !((field.access ?? []).contains(Access.AAbstract))) + { + return true; + } + } + + function fieldAlreadyExistsSuper(name:String, superClass:Null) + { + if (superClass == null) + { + return false; + } + + for (field in superClass.fields.get()) + { + if (field.name == name && !field.isAbstract) + { + return true; + } + } + + // recursively check superclasses + return fieldAlreadyExistsSuper(name, superClass.superClass?.t.get()); + } + + return fieldAlreadyExistsSuper(name, Context.getLocalClass().get().superClass?.t.get()); + } + + /** + * Determine whether a given ClassType is a subclass of a given superclass. + * @param classType The class to check. + * @param superClass The superclass to check for. + * @return Whether the class is a subclass of the superclass. + */ + public static function isSubclassOf(classType:ClassType, superClass:ClassType):Bool + { + if (areClassesEqual(classType, superClass)) return true; + + if (classType.superClass != null) + { + return isSubclassOf(classType.superClass.t.get(), superClass); + } + + return false; + } + + /** + * Determine whether a given ClassType implements a given interface. + * @param classType The class to check. + * @param interfaceType The interface to check for. + * @return Whether the class implements the interface. + */ + public static function implementsInterface(classType:ClassType, interfaceType:ClassType):Bool + { + for (i in classType.interfaces) + { + if (areClassesEqual(i.t.get(), interfaceType)) + { + return true; + } + } + + if (classType.superClass != null) + { + return implementsInterface(classType.superClass.t.get(), interfaceType); + } + + return false; + } + #end +} diff --git a/source/funkin/util/macro/RegistryMacro.hx b/source/funkin/util/macro/RegistryMacro.hx new file mode 100644 index 00000000..e7c0d401 --- /dev/null +++ b/source/funkin/util/macro/RegistryMacro.hx @@ -0,0 +1,391 @@ +package funkin.util.macro; + +import haxe.macro.Context; +import haxe.macro.Expr; +import haxe.macro.Expr.ComplexType; +import haxe.macro.Expr.Field; +import haxe.macro.Expr.TypeDefKind; +import haxe.macro.Expr.MetadataEntry; +import haxe.macro.Type; +import haxe.macro.Type.ClassType; + +using Lambda; +using haxe.macro.ExprTools; +using haxe.macro.TypeTools; +using StringTools; + +/** + * The type parameters for a class extending `BaseRegistry`. + */ +typedef RegistryTypeParams = +{ + /** + * The class type of the entry. Must implement `IRegistryEntry`. + */ + var entryType:ClassType; + + /** + * The type for the data of an entry. This is usually a typedef of a struct. + */ + var dataType:Any; // DefType or ClassType + +} + +/** + * A set of build macros to be applied to `Registry` classes in the `funkin.data` package. + * + * @see `funkin.data.BaseRegistry` + */ +class RegistryMacro +{ + static final DATA_FILE_BASE_PATH:String = 'assets/preload/data'; + + /** + * Builds the registry class. + * + * @return The modified list of fields for the target class. + */ + public static macro function buildRegistry():Array + { + var cls:ClassType = Context.getLocalClass().get(); + var fields:Array = Context.getBuildFields(); + + // Classes with the `@:funkinBase` meta or `@:funkinProcessed` meta should be ignored. + var baseMeta:Null = cls.meta.get().find(function(m) return m.name == ':funkinBase'); + if (baseMeta != null || alreadyProcessed(cls)) return fields; + + var typeParams:RegistryTypeParams = getTypeParams(cls); + + // Build an internal class with static functions that allow the Entry class to call functions on the Registry class. + buildEntryImpl(typeParams.entryType, cls); + + fields = fields.concat(buildRegistryMethods(cls, fields, typeParams.entryType, typeParams.dataType)); + + // Indicate that the class has been processed so we don't process twice. + cls.meta.add(":funkinProcessed", [], cls.pos); + + return fields; + } + + /** + * Builds the registry entry class. + * + * @return The modified list of fields for the target class. + */ + public static macro function buildEntry():Array + { + var cls:ClassType = Context.getLocalClass().get(); + var fields:Array = Context.getBuildFields(); + + // Classes with the `@:funkinProcessed` meta should be ignored. + if (alreadyProcessed(cls)) return fields; + + // Get the type of the JSON data for an entry. + var entryData:Any = getEntryData(cls); + + // Build variables and methods for the entry. + fields = fields.concat(buildEntryVariables(cls, entryData)); + fields = fields.concat(buildEntryMethods(cls)); + + // Indicate that the class has been processed so we don't process twice. + cls.meta.add(":funkinProcessed", [], cls.pos); + + return fields; + } + + #if macro + /** + * Retrieve the type parameters for a class extending `BaseRegistry`. + * @param cls The class to retrieve the type parameters for. + * @return The type parameters for the class. + */ + static function getTypeParams(cls:ClassType):RegistryTypeParams + { + switch (cls.superClass.t.get().kind) + { + case KGenericInstance(_, params): + var typeParams:Array = []; + for (param in params) + { + switch (param) + { + case TInst(t, _): + typeParams.push(t.get()); + case TType(t, _): + typeParams.push(t.get()); + default: + throw 'Not a class'; + } + } + return {entryType: typeParams[0], dataType: typeParams[1]}; + default: + throw '${cls.name}: Could not interpret type parameters of Registry class.'; + } + } + + /** + * Builds new static and instance methods for a registry class. + * + * @param cls The registry class to build the methods for. + * @param fields The fields of the registry class. + * @param entryType The class type of entries in the registry. + * @param dataType The type of the data for entries in the registry. + * @return The modified list of fields for the target class. + */ + static function buildRegistryMethods(cls:ClassType, fields:Array, entryType:ClassType, dataType:Dynamic):Array + { + var scriptedEntryClsName:String = entryType.pack.join('.') + '.Scripted' + entryType.name; + + var getScriptedClassName:String = '${scriptedEntryClsName}'; + + var createScriptedEntry:String = '${scriptedEntryClsName}.init(clsName, "unknown")'; + + var newJsonParser:String = 'new json2object.JsonParser<${dataType.module}.${dataType.name}>()'; + + var dataFilePath:String = getRegistryDataFilePath(cls, fields); + var baseGameEntryIds:Array = listBaseGameEntryIds('${DATA_FILE_BASE_PATH}/${dataFilePath}/'); + + return (macro class TempClass + { + public function listBaseGameEntryIds():Array + { + return $a{baseGameEntryIds}; + } + + public function listModdedEntryIds():Array + { + return listEntryIds().filter(function(id:String):Bool { + return listBaseGameEntryIds().indexOf(id) == -1; + }); + } + + function getScriptedClassNames() + { + return ${Context.parse(getScriptedClassName, Context.currentPos())}.listScriptClasses(); + } + + function createScriptedEntry(clsName:String) + { + return ${Context.parse(createScriptedEntry, Context.currentPos())}; + } + + public function parseEntryData(id:String) + { + var parser = ${Context.parse(newJsonParser, Context.currentPos())}; + parser.ignoreUnknownVariables = false; + + @:privateAccess + switch (this.loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + @:privateAccess + this.printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + public function parseEntryDataRaw(contents:String, ?fileName:String) + { + var parser = ${Context.parse(newJsonParser, Context.currentPos())}; + parser.ignoreUnknownVariables = false; + parser.fromJson(contents, fileName); + + if (parser.errors.length > 0) + { + @:privateAccess + this.printErrors(parser.errors, fileName); + return null; + } + return parser.value; + } + }).fields.filter((field) -> return !MacroUtil.fieldAlreadyExists(field.name)); + } + + /** + * Retrieve the type of the JSON data for an entry. + * @param cls The entry class to retrieve the type of the JSON data for. + * @return Will be either a `DefType` or a `ClassType`. + */ + static function getEntryData(cls:ClassType):Any // DefType or ClassType + { + try + { + switch (cls.interfaces[0].params[0]) + { + case Type.TInst(t, _): + return t.get(); + case Type.TType(t, _): + return t.get(); + default: + throw '${cls.name}: Type parameter for Entry must be a Class or typedef'; + } + } + catch (e) + { + throw '${cls.name}: IRegistryEntry must be the last implemented interface'; + } + } + + /** + * Add fields to the entry class. + * @param cls The entry class to add fields to. + * @param entryData The type of the data for the entry. + * @return The modified list of fields for the target class. + */ + static function buildEntryVariables(cls:ClassType, entryData:Dynamic):Array + { + var entryDataType:ComplexType = Context.getType('${entryData.module}.${entryData.name}').toComplexType(); + + return (macro class TempClass + { + public final id:String; + + public final _data:Null<$entryDataType>; + }).fields.filter((field) -> return !MacroUtil.fieldAlreadyExists(field.name)); + } + + /** + * Add methods to the entry class. + * @param cls The entry class to add methods to. + * @return The modified list of fields for the target class. + */ + static function buildEntryMethods(cls:ClassType):Array + { + // The internal class built by `buildEntryImpl`. + var impl:String = 'funkin.macro.impl._${cls.name}_Impl'; + + return (macro class TempClass + { + public function _fetchData(id:String) + { + return ${Context.parse(impl, Context.currentPos())}._fetchData(this, id); + } + + public function toString() + { + return ${Context.parse(impl, Context.currentPos())}.toString(this); + } + + public function destroy() + { + ${Context.parse(impl, Context.currentPos())}.destroy(this); + } + }).fields.filter((field) -> return !MacroUtil.fieldAlreadyExists(field.name)); + } + + /** + * Build an internal class that calls functions for an associated registry class. + * @param cls The entry class to build the internal class for. + * @param registryCls The registry class that the entry class is associated with. + */ + static function buildEntryImpl(cls:ClassType, registryCls:ClassType):Void + { + var clsType:ComplexType = Context.getType('${cls.module}.${cls.name}').toComplexType(); + + var registry:String = '${registryCls.module}.${registryCls.name}'; + + Context.defineType( + { + pos: Context.currentPos(), + pack: ['funkin', 'macro', 'impl'], + name: '_${cls.name}_Impl', + kind: TypeDefKind.TDClass(null, [], false, false, false), + fields: (macro class TempClass + { + public static inline function _fetchData(me:$clsType, id:String) + { + return $ + { + Context.parse(registry, Context.currentPos()) + }.instance.parseEntryDataWithMigration(id, ${Context.parse(registry, Context.currentPos())}.instance.fetchEntryVersion(id)); + } + + public static inline function toString(me:$clsType) + { + return $v{cls.name} + '(' + me.id + ')'; + } + + public static inline function destroy(me:$clsType) {} + }).fields + }); + } + + static function getRegistryDataFilePath(cls:ClassType, fields:Array):String + { + for (field in fields) + { + if (field.name == 'new') + { + // We found a field called `new`, it's probably the constructor. + switch (field.kind) + { + case FFun(f): + // Inside the function. + switch (f.expr.expr) + { + // Inside the block. + case EBlock(exprs): + var superCall:Expr = exprs[0]; + switch (superCall.expr) + { + // Inside the super() call. + case ECall(_, args): + // var registryId:String = args[0].toString(); + var dataPath:String = args[1].toString().replace('"', '').replace("'", ''); + // var versionRule = args[2].toString(); + + return dataPath; + default: + Context.error('${cls.name}.new: RegistryMacro expected super call', field.pos); + } + default: + Context.error('${cls.name}.new: RegistryMacro expected super call', field.pos); + } + default: + // Continue looking for the actual constructor. + } + } + } + + return ''; + } + + static function listBaseGameEntryIds(dataFilePath:String):Array + { + var result:Array = []; + var files:Array = sys.FileSystem.readDirectory(dataFilePath); + + for (file in files) + { + result.push(macro $v{file.replace('.json', '')}); + } + + return result; + } + + /** + * Check whether this class has already been processed by the RegistryMacro, + * as indicated by the `@:funkinProcessed` meta. + * @param cls The class to check. + * @return `true` if the class has already been processed, `false` otherwise. + */ + static function alreadyProcessed(cls:ClassType):Bool + { + // Check for the `@:funkinProcessed` meta. + var processedMeta:MetadataEntry = cls.meta.get().find(function(m) return m.name == ':funkinProcessed'); + if (processedMeta != null) return true; + + // If it's not found, check the superclass. + if (cls.superClass != null) return alreadyProcessed(cls.superClass.t.get()); + return false; + } + #end +} diff --git a/source/funkin/util/macro/SingletonMacro.hx b/source/funkin/util/macro/SingletonMacro.hx new file mode 100644 index 00000000..4148c7c1 --- /dev/null +++ b/source/funkin/util/macro/SingletonMacro.hx @@ -0,0 +1,46 @@ +package funkin.util.macro; + +import haxe.macro.Context; +import haxe.macro.Type.ClassType; +import haxe.macro.Expr.Field; +import haxe.macro.Expr.ComplexType; + +using haxe.macro.Tools; + +/** + * A macro which automatically creates a Singleton `instance` property for a class. + * Add `implements funkin.util.tools.ISingleton` to your class to use. + */ +class SingletonMacro +{ + /** + * Applies an `instance` static field to the target class. + * @return The modified list of fields for the target class. + */ + public static macro function build():Array + { + var cls:ClassType = Context.getLocalClass().get(); + + var fields:Array = Context.getBuildFields(); + + var clsType:ComplexType = Context.getType('${cls.module}.${cls.name}').toComplexType(); + var newExpr:String = 'new ${cls.module}.${cls.name}()'; + + fields = fields.concat((macro class TempClass + { + static var _instance:Null<$clsType>; + public static var instance(get, never):$clsType; + + static function get_instance():$clsType + { + if (_instance == null) + { + _instance = ${Context.parse(newExpr, Context.currentPos())}; + } + return _instance; + } + }).fields); + + return fields; + } +} diff --git a/source/funkin/util/plugins/EvacuateDebugPlugin.hx b/source/funkin/util/plugins/EvacuateDebugPlugin.hx new file mode 100644 index 00000000..eb292b85 --- /dev/null +++ b/source/funkin/util/plugins/EvacuateDebugPlugin.hx @@ -0,0 +1,35 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which adds functionality to press `F4` to immediately transition to the main menu. + * This is useful for debugging or if you get softlocked or something. + */ +class EvacuateDebugPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new EvacuateDebugPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.F4) + { + FlxG.switchState(() -> new funkin.ui.mainmenu.MainMenuState()); + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/ForceCrashPlugin.hx b/source/funkin/util/plugins/ForceCrashPlugin.hx new file mode 100644 index 00000000..6f402c4e --- /dev/null +++ b/source/funkin/util/plugins/ForceCrashPlugin.hx @@ -0,0 +1,37 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which forcibly crashes the application. + * TODO: Should we disable this in release builds? + */ +class ForceCrashPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new ForceCrashPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Ctrl + Alt + Shift + L = Crash the game for debugging purposes + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.pressed.L) + { + // TODO: Make this message 87% funnier. + throw "DEBUG: Crashing the game via debug keybind!"; + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/MemoryGCPlugin.hx b/source/funkin/util/plugins/MemoryGCPlugin.hx new file mode 100644 index 00000000..4b89fa63 --- /dev/null +++ b/source/funkin/util/plugins/MemoryGCPlugin.hx @@ -0,0 +1,37 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; +import funkin.util.TimerUtil; + +/** + * A plugin which adds functionality to press `Ins` to immediately perform memory garbage collection. + */ +class MemoryGCPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new MemoryGCPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (FlxG.keys.justPressed.INSERT) + { + var perfStart:Float = TimerUtil.start(); + funkin.util.MemoryUtil.collect(true); + trace('Memory GC took: ${TimerUtil.seconds(perfStart)}'); + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/NewgroundsMedalPlugin.hx b/source/funkin/util/plugins/NewgroundsMedalPlugin.hx new file mode 100644 index 00000000..4b3cb278 --- /dev/null +++ b/source/funkin/util/plugins/NewgroundsMedalPlugin.hx @@ -0,0 +1,144 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; +import flixel.group.FlxContainer.FlxTypedContainer; +import flixel.text.FlxText; +import funkin.audio.FunkinSound; +import flixel.graphics.FlxGraphic; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import flixel.math.FlxRect; +import funkin.api.newgrounds.Medals; + +class NewgroundsMedalPlugin extends FlxTypedContainer +{ + var medal:FlxAtlasSprite = null; + var points:FlxText = null; + var name:FlxText = null; + + public static var instance:NewgroundsMedalPlugin = null; + + var tween:Bool = false; + + var am:Float = 20; + + var funcs:ArrayVoid> = []; + + public function new() + { + super(); + + #if FLX_DEBUG + FlxG.console.registerFunction("medal_test", NewgroundsMedalPlugin.play); + FlxG.console.registerClass(Medals); + #end + + var ecs = (FlxG.width - 250) * 0.5; + var why = FlxG.height - 100; + FlxGraphic.defaultPersist = true; + medal = new FlxAtlasSprite(ecs, why, Paths.animateAtlas("ui/medal")); + + points = new FlxText(171 + ecs, 10 + why, 50, 12, false); + points.fieldHeight = 18; + points.systemFont = "Arial"; + points.bold = true; + points.italic = true; + points.alignment = "right"; + + points.text = "100"; + points.visible = false; + points.scrollFactor.set(); + + name = new FlxText(73 + ecs, 34 + why, 0, 26); + name.font = Paths.font("ShareTechMono-Regular.ttf"); + name.letterSpacing = -2; + + name.text = "Ono Boners Deluxe"; + + name.clipRect = FlxRect.get(0, 0, 164, 35.2); + + name.visible = false; + name.scrollFactor.set(); + + medal.scrollFactor.set(); + medal.anim.swfRender = true; + medal.visible = false; + + var fr = medal.anim.curSymbol.timeline.get(0).get(0); + fr.name = "START"; // woerkaround + // fr.add(() -> FunkinSound.playOnce(Paths.sound('NGFadeIn'), 1.)); + + medal.anim.getFrameLabel("show").add(function() { + points.visible = true; + name.visible = true; + if (name.width > name.clipRect.width) + { + am = (name.text.length * (name.size + 2) * 1.25) / name.clipRect.width * 10; + tween = true; + // FlxTimer.wait(0.3, () -> tween = true); + } + }); + // medal.anim.getFrameLabel("idle").add(() -> medal.anim.pause()); + medal.anim.getFrameLabel("fade").add(() -> FunkinSound.playOnce(Paths.sound('NGFadeOut'), 1.)); + + medal.anim.getFrameLabel("hide").add(function() { + points.visible = false; + name.visible = false; + tween = false; + name.offset.x = 0; + name.clipRect.x = 0; + name.resetFrame(); + medal.replaceFrameGraphic(3, null); + }); + + add(medal); + add(points); + add(name); + FlxGraphic.defaultPersist = false; + } + + override public function update(elapsed:Float) + { + super.update(elapsed); + if (tween) + { + var val = am * elapsed; + name.offset.x += val; + name.clipRect.x += val; + name.resetFrame(); + } + } + + public static function initialize() + { + FlxG.plugins.drawOnTop = true; + instance = new NewgroundsMedalPlugin(); + FlxG.plugins.add(instance); + + instance.medal.anim.onComplete.add(function() { + if (instance.funcs.length > 0) + { + instance.funcs.shift()(); + } + }); + } + + public static function play(points:Int = 100, name:String = "I LOVE CUM I LOVE CUM I LOVE CUM I LOVE CUM", graphic:FlxGraphic = null) + { + var func = function() { + instance.points.visible = false; + instance.name.visible = false; + instance.points.text = Std.string(points); + instance.name.text = name; + + instance.medal.visible = true; + instance.medal.replaceFrameGraphic(3, graphic); + instance.medal.anim.play(true); + + FunkinSound.playOnce(Paths.sound('NGFadeIn'), 1.0); + } + + if (!instance.medal.anim.isPlaying && instance.funcs.length == 0) func(); + else + instance.funcs.push(func); + } +} diff --git a/source/funkin/util/plugins/README.md b/source/funkin/util/plugins/README.md new file mode 100644 index 00000000..fe87d36e --- /dev/null +++ b/source/funkin/util/plugins/README.md @@ -0,0 +1,5 @@ +# funkin.util.plugins + +Flixel plugins are objects with `update()` functions that are called from every state. + +See: https://github.com/HaxeFlixel/flixel/blob/dev/flixel/system/frontEnds/PluginFrontEnd.hx diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx new file mode 100644 index 00000000..0e1e238a --- /dev/null +++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx @@ -0,0 +1,50 @@ +package funkin.util.plugins; + +import flixel.FlxG; +import flixel.FlxBasic; +import funkin.ui.MusicBeatState; +import funkin.ui.MusicBeatSubState; + +/** + * A plugin which adds functionality to press `F5` to reload all game assets, then reload the current state. + * This is useful for hot reloading assets during development. + */ +class ReloadAssetsDebugPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new ReloadAssetsDebugPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + #if html5 + if (FlxG.keys.justPressed.FIVE && FlxG.keys.pressed.SHIFT) + #else + if (FlxG.keys.justPressed.F5) + #end + { + var state:Dynamic = FlxG.state; + if (state is MusicBeatState || state is MusicBeatSubState) state.reloadAssets(); + else + { + funkin.modding.PolymodHandler.forceReloadAssets(); + + // Create a new instance of the current state, so old data is cleared. + FlxG.resetState(); + } + } + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/ScreenshotPlugin.hx b/source/funkin/util/plugins/ScreenshotPlugin.hx new file mode 100644 index 00000000..dad60421 --- /dev/null +++ b/source/funkin/util/plugins/ScreenshotPlugin.hx @@ -0,0 +1,663 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; +import flixel.FlxG; +import flixel.FlxState; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSignal; +import flixel.util.FlxTimer; +import funkin.util.WindowUtil; +import funkin.util.logging.CrashHandler; +import flixel.addons.util.FlxAsyncLoop; +import funkin.input.Cursor; +import funkin.audio.FunkinSound; +import openfl.display.Bitmap; +import openfl.display.Sprite; +import openfl.display.BitmapData; +import openfl.display.PNGEncoderOptions; +import openfl.geom.Rectangle; +import openfl.utils.ByteArray; +import openfl.events.MouseEvent; +import funkin.Preferences; + +/** + * Parameters for starting the `ScreenshotPlugin`. + */ +typedef ScreenshotPluginParams = +{ + ?region:Rectangle, +}; + +/** + * 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(get, never):ScreenshotPlugin; + + static var _instance:Null = null; + + static function get_instance():ScreenshotPlugin + { + if (_instance == null) + { + _instance = new ScreenshotPlugin({}); + } + return _instance; + } + + /** + * The folder where screenshots are saved + */ + public static final SCREENSHOT_FOLDER:String = 'screenshots'; + + var region:Null; + + /** + * A signal fired before the screenshot is taken. + */ + public var onPreScreenshot(default, null):FlxSignal; + + /** + * A signal fired after the screenshot is taken. + * @param bitmap The bitmap that was captured. + */ + public var onPostScreenshot(default, null):FlxTypedSignalVoid>; + + static var lastWidth:Int = 0; + static var lastHeight:Int = 0; + + var flashSprite:Sprite; + var flashBitmap:Bitmap; + var previewSprite:Sprite; + var shotPreviewBitmap:Bitmap; + var outlineBitmap:Bitmap; + + var wasMouseHidden:Bool = false; // Used for hiding and then showing the mouse + var wasMouseShown:Bool = false; // Used for showing and then hiding the mouse + var screenshotTakenFrame:Int = 0; + + var screenshotBeingSpammed:Bool = false; + + var screenshotSpammedTimer:Null = null; + + var screenshotBuffer:Array = []; + var screenshotNameBuffer:Array = []; + + var unsavedScreenshotBuffer:Array = []; + var unsavedScreenshotNameBuffer:Array = []; + + var stateChanging:Bool = false; + var noSavingScreenshots:Bool = false; + + var flashTween:Null = null; + + var previewFadeInTween:Null = null; + var previewFadeOutTween:Null = null; + + var asyncLoop:Null = null; + + public function new(params:ScreenshotPluginParams) + { + super(); + + lastWidth = FlxG.width; + lastHeight = FlxG.height; + + flashSprite = new Sprite(); + flashSprite.alpha = 0; + flashBitmap = new Bitmap(new BitmapData(lastWidth, lastHeight, true, Preferences.flashingLights ? FlxColor.WHITE : FlxColor.TRANSPARENT)); + flashSprite.addChild(flashBitmap); + + previewSprite = new Sprite(); + previewSprite.alpha = 0; + + outlineBitmap = new Bitmap(new BitmapData(Std.int(lastWidth / 5) + 10, Std.int(lastHeight / 5) + 10, true, 0xFFFFFFFF)); + outlineBitmap.x = 5; + outlineBitmap.y = 5; + previewSprite.addChild(outlineBitmap); + + shotPreviewBitmap = new Bitmap(); + shotPreviewBitmap.scaleX /= 5; + shotPreviewBitmap.scaleY /= 5; + + previewSprite.addChild(shotPreviewBitmap); + FlxG.stage.addChild(flashSprite); + + region = params.region ?? null; + + onPreScreenshot = new FlxTypedSignalVoid>(); + onPostScreenshot = new FlxTypedSignalVoid>(); + FlxG.signals.gameResized.add(this.resizeBitmap); + FlxG.signals.preStateSwitch.add(this.saveUnsavedBufferedScreenshots); + FlxG.signals.postStateSwitch.add(this.postStateSwitch); + // Called when clicking the X button on the window. + WindowUtil.windowExit.add(onWindowClose); + + // Called when the game crashes. + CrashHandler.errorSignal.add(onWindowCrash); + CrashHandler.criticalErrorSignal.add(onWindowCrash); + } + + public override function update(elapsed:Float):Void + { + if (asyncLoop != null) + { + // If the loop hasn't started yet, start it + if (!asyncLoop.started) + { + asyncLoop.start(); + } + else + { + // if the loop has been started, and is finished, then we kill. it + if (asyncLoop.finished) + { + if (screenshotBuffer != []) + { + trace("finished processing screenshot buffer"); + screenshotBuffer = []; + screenshotNameBuffer = []; + } + // your honor, league of legends + asyncLoop.kill(); + asyncLoop.destroy(); + asyncLoop = null; + } + } + // Examples ftw! + } + super.update(elapsed); + + /** + * This looks scary, oh no I pressed the button but no screenshot because screenshotTakenFrame != 0! + * But if you're crazy enough to have a macro that bumps into this + * then you're probably also going to hit 100 screenshots real fast + */ + if (hasPressedScreenshot() && screenshotTakenFrame == 0) + { + if (FlxG.keys.pressed.SHIFT) + { + openScreenshotsFolder(); + return; // We're only opening the screenshots folder (we don't want to accidentally take a screenshot after this) + } + if (Preferences.shouldHideMouse && !wasMouseHidden && FlxG.mouse.visible) + { + wasMouseHidden = true; + Cursor.hide(); + } + for (sprite in [flashSprite, previewSprite]) + { + FlxTween.cancelTweensOf(sprite); + sprite.alpha = 0; + } + // screenshot spamming timer + 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 + screenshotBeingSpammed = false; + if (screenshotBuffer[0] != null) saveBufferedScreenshots(screenshotBuffer, screenshotNameBuffer); + if (!Preferences.fancyPreview && wasMouseHidden && !FlxG.mouse.visible) + { + wasMouseHidden = false; + Cursor.show(); + } + }); + } + else // Pressing the screenshot key more than once every second enables the screenshot spam mode and resets the timer + { + screenshotBeingSpammed = true; + screenshotSpammedTimer.reset(1); + } + FlxG.stage.removeChild(previewSprite); + screenshotTakenFrame++; + } + else if (screenshotTakenFrame > 1) + { + screenshotTakenFrame = 0; + capture(); // After all these checks and waiting a frame, we finally try taking a screenshot + } + else if (screenshotTakenFrame > 0) + { + screenshotTakenFrame++; + } + } + + /** + * Initialize the screenshot plugin. + */ + public static function initialize():Void + { + FlxG.plugins.addPlugin(new ScreenshotPlugin({})); + } + + /** + * @return `true` if the screenshot key is currently pressed. + */ + public function hasPressedScreenshot():Bool + { + #if FEATURE_SCREENSHOTS + return PlayerSettings.player1.controls.WINDOW_SCREENSHOT; + #else + return false; + #end + } + + function resizeBitmap(width:Int, height:Int):Void + { + lastWidth = width; + lastHeight = height; + 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); + } + + /** + * Capture the game screen as a bitmap. + */ + public function capture():Void + { + onPreScreenshot.dispatch(); + + var shot:Bitmap = new Bitmap(BitmapData.fromImage(FlxG.stage.window.readPixels())); + if (screenshotBeingSpammed) + { + // Save the screenshots to the buffer instead + if (screenshotBuffer.length < 100) + { + screenshotBuffer.push(shot); + screenshotNameBuffer.push('screenshot-${DateUtil.generateTimestamp()}'); + + unsavedScreenshotBuffer.push(shot); + unsavedScreenshotNameBuffer.push('screenshot-${DateUtil.generateTimestamp()}'); + } + else + { + noSavingScreenshots = true; + throw "You've tried taking more than 100 screenshots at a time. Give the game a funkin break! Jeez. If you wanted those screenshots, well too bad!"; + } + showCaptureFeedback(); + if (wasMouseHidden && !FlxG.mouse.visible && Preferences.flashingLights) // Just in case + { + wasMouseHidden = false; + Cursor.show(); + } + if (!Preferences.previewOnSave) showFancyPreview(shot); + } + else + { + // Save the screenshot immediately, so it doesn't get lost by a state change + saveScreenshot(shot, 'screenshot-${DateUtil.generateTimestamp()}', 1, false); + // Show some feedback. + showCaptureFeedback(); + if (wasMouseHidden && !FlxG.mouse.visible) + { + wasMouseHidden = false; + Cursor.show(); + } + if (!Preferences.previewOnSave) showFancyPreview(shot); + } + onPostScreenshot.dispatch(shot); + } + + static final CAMERA_FLASH_DURATION:Float = 0.25; + + /** + * Visual and audio feedback when a screenshot is taken. + */ + function showCaptureFeedback():Void + { + if (stateChanging) return; // Flash off! + flashSprite.alpha = 1; + FlxTween.tween(flashSprite, {alpha: 0}, 0.15); + + FunkinSound.playOnce(Paths.sound('screenshot'), 1.0); + } + + 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 + */ + function showFancyPreview(shot:Bitmap):Void + { + if (!Preferences.fancyPreview || screenshotBeingSpammed && !Preferences.flashingLights || stateChanging) return; // Sorry, the previews' been cancelled + shotPreviewBitmap.bitmapData = shot.bitmapData; + shotPreviewBitmap.x = outlineBitmap.x + 5; + shotPreviewBitmap.y = outlineBitmap.y + 5; + + shotPreviewBitmap.width = outlineBitmap.width - 10; + shotPreviewBitmap.height = outlineBitmap.height - 10; + + // Remove the existing preview + FlxG.stage.removeChild(previewSprite); + + // ermmm stealing this?? + + if (!wasMouseShown && !wasMouseHidden && !FlxG.mouse.visible) + { + wasMouseShown = true; + Cursor.show(); + } + + // so that it doesnt change the alpha when tweening in/out + var changingAlpha:Bool = false; + var targetAlpha:Float = 1; + + // 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:MouseEvent->Void = function(e:MouseEvent) { + if (!changingAlpha) e.target.alpha = 0.6; + targetAlpha = 0.6; + }; + + var onHoverOut:MouseEvent->Void = function(e:MouseEvent) { + if (!changingAlpha) e.target.alpha = 1; + targetAlpha = 1; + } + + // used for movement + button stuff + previewSprite.buttonMode = true; + previewSprite.addEventListener(MouseEvent.MOUSE_DOWN, previewSpriteOpenScreenshotsFolder); + previewSprite.addEventListener(MouseEvent.MOUSE_MOVE, onHover); + previewSprite.addEventListener(MouseEvent.MOUSE_OUT, onHoverOut); + + FlxTween.cancelTweensOf(previewSprite); // Reset the tweens + FlxG.stage.addChild(previewSprite); + previewSprite.alpha = 0.0; + previewSprite.y -= 10; + // set the alpha to 0.6 if the mouse is already over the preview sprite + if (previewSprite.hitTestPoint(previewSprite.mouseX, previewSprite.mouseY)) targetAlpha = 0.6; + // Wait to fade in. + new FlxTimer().start(PREVIEW_INITIAL_DELAY, function(_) { + // Fade in. + changingAlpha = true; + FlxTween.tween(previewSprite, {alpha: targetAlpha, y: 0}, PREVIEW_FADE_IN_DURATION, + { + ease: FlxEase.quartOut, + onComplete: function(_) { + changingAlpha = false; + // Wait to fade out. + new FlxTimer().start(PREVIEW_FADE_OUT_DELAY, function(_) { + changingAlpha = true; + // Fade out. + FlxTween.tween(previewSprite, {alpha: 0.0, y: 10}, PREVIEW_FADE_OUT_DURATION, + { + ease: FlxEase.quartInOut, + onComplete: function(_) { + if (wasMouseShown && FlxG.mouse.visible) + { + wasMouseShown = false; + Cursor.hide(); + } + else if (wasMouseHidden && !FlxG.mouse.visible) + { + wasMouseHidden = false; + Cursor.show(); + } + + previewSprite.removeEventListener(MouseEvent.MOUSE_DOWN, previewSpriteOpenScreenshotsFolder); + previewSprite.removeEventListener(MouseEvent.MOUSE_OVER, onHover); + previewSprite.removeEventListener(MouseEvent.MOUSE_OUT, onHoverOut); + + FlxG.stage.removeChild(previewSprite); + } + }); + }); + } + }); + }); + } + + /** + * This is a separate function, as running the previewsprite check + * in the other one would mean you can't open the folder when the preview's hidden, lol + * That, and it needs a mouse event as a parameter to work. + */ + function previewSpriteOpenScreenshotsFolder(e:MouseEvent):Void + { + if (previewSprite.alpha <= 0) return; + openScreenshotsFolder(); + } + + function openScreenshotsFolder():Void + { + FileUtil.openFolder(SCREENSHOT_FOLDER); + } + + // Save them, save the screenshots + function onWindowClose(exitCode:Int):Void + { + if (noSavingScreenshots) return; // sike + saveUnsavedBufferedScreenshots(); + } + + function onWindowCrash(message:String):Void + { + if (noSavingScreenshots) return; + saveUnsavedBufferedScreenshots(); + } + + static function getCurrentState():FlxState + { + var state:FlxState = FlxG.state; + while (state.subState != null) + { + state = state.subState; + } + return state; + } + + static function getScreenshotPath():String + { + return '$SCREENSHOT_FOLDER/'; + } + + static function makeScreenshotPath():Void + { + FileUtil.createDirIfNotExists(SCREENSHOT_FOLDER); + } + + /** + * Convert a Bitmap to a PNG or JPEG ByteArray to save to a file. + */ + function encode(bitmap:Bitmap):ByteArray + { + var compressor:PNGEncoderOptions = new PNGEncoderOptions(); + return bitmap.bitmapData.encode(bitmap.bitmapData.rect, compressor); + } + + var previousScreenshotName:Null = null; + var previousScreenshotCopyNum:Int = 0; + + /** + * Save the generated bitmap to a file. + * @param bitmap The bitmap to save. + * @param targetPath The name of the screenshot. + * @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):Void + { + makeScreenshotPath(); + // Check that we're not overriding a previous image, and keep making a unique path until we can + if (previousScreenshotName != targetPath && previousScreenshotName != (targetPath + ' (${previousScreenshotCopyNum})')) + { + previousScreenshotName = targetPath; + targetPath = getScreenshotPath() + targetPath + '.' + Std.string(Preferences.saveFormat).toLowerCase(); + previousScreenshotCopyNum = 2; + } + else + { + var newTargetPath:String = targetPath + ' (${previousScreenshotCopyNum})'; + while (previousScreenshotName == newTargetPath) + { + previousScreenshotCopyNum++; + newTargetPath = targetPath + ' (${previousScreenshotCopyNum})'; + } + previousScreenshotName = newTargetPath; + targetPath = getScreenshotPath() + newTargetPath + '.' + Std.string(Preferences.saveFormat).toLowerCase(); + } + + // 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) + new FlxTimer().start(screenShotNum, function(_) { + var pngData:ByteArray = encode(bitmap); + + if (pngData == null) + { + trace('[WARN] Failed to encode ${Preferences.saveFormat} data'); + previousScreenshotName = null; + // Just in case + unsavedScreenshotBuffer.shift(); + unsavedScreenshotNameBuffer.shift(); + return; + } + else + { + trace('Saving screenshot to: ' + targetPath); + FileUtil.writeBytesToPath(targetPath, pngData); + // Remove the screenshot from the unsaved buffer because we literally just saved it + unsavedScreenshotBuffer.shift(); + unsavedScreenshotNameBuffer.shift(); + if (Preferences.previewOnSave) showFancyPreview(bitmap); // Only show the preview after a screenshot is saved + } + }); + } + else // Save the screenshot immediately + { + var pngData:ByteArray = encode(bitmap); + + if (pngData == null) + { + trace('[WARN] Failed to encode ${Preferences.saveFormat} data'); + previousScreenshotName = null; + return; + } + else + { + trace('Saving screenshot to: ' + targetPath); + FileUtil.writeBytesToPath(targetPath, pngData); + if (Preferences.previewOnSave) showFancyPreview(bitmap); // Only show the preview after a screenshot is saved + } + } + } + + // I' m very happy with this code, all of it just works + function saveBufferedScreenshots(screenshots:Array, screenshotNames:Array):Void + { + trace('Saving screenshot buffer'); + var i:Int = 0; + + asyncLoop = new FlxAsyncLoop(screenshots.length, () -> { + if (screenshots[i] != null) + { + saveScreenshot(screenshots[i], screenshotNames[i], i); + } + i++; + }, 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():Void + { + stateChanging = true; + // Cancel the tweens of the capture feedback if they're running + if (flashSprite.alpha != 0 || previewSprite.alpha != 0) + { + for (sprite in [flashSprite, previewSprite]) + { + FlxTween.cancelTweensOf(sprite); + sprite.alpha = 0; + } + } + + // Undo the mouse stuff - we don't know what the next state will do with it + if (wasMouseShown && FlxG.mouse.visible) + { + wasMouseShown = false; + Cursor.hide(); + } + else if (wasMouseHidden && !FlxG.mouse.visible) + { + wasMouseHidden = false; + Cursor.show(); + } + + if (unsavedScreenshotBuffer[0] == null) return; + // There's unsaved screenshots, let's save them! (haha, get it?) + + trace('Saving unsaved screenshots in buffer!'); + + for (i in 0...unsavedScreenshotBuffer.length) + { + if (unsavedScreenshotBuffer[i] != null) saveScreenshot(unsavedScreenshotBuffer[i], unsavedScreenshotNameBuffer[i], i, false); + } + + unsavedScreenshotBuffer = []; + unsavedScreenshotNameBuffer = []; + } + + function postStateSwitch():Void + { + stateChanging = false; + screenshotBeingSpammed = false; + FlxG.stage.removeChild(previewSprite); + } + + override public function destroy():Void + { + if (instance == this) _instance = null; + + if (FlxG.plugins.list.contains(this)) FlxG.plugins.remove(this); + + FlxG.signals.gameResized.remove(this.resizeBitmap); + FlxG.signals.preStateSwitch.remove(this.saveUnsavedBufferedScreenshots); + FlxG.signals.postStateSwitch.remove(this.postStateSwitch); + FlxG.stage.removeChild(previewSprite); + FlxG.stage.removeChild(flashSprite); + WindowUtil.windowExit.remove(onWindowClose); + CrashHandler.errorSignal.remove(onWindowCrash); + CrashHandler.criticalErrorSignal.remove(onWindowCrash); + + super.destroy(); + + @: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 + } +} diff --git a/source/funkin/util/plugins/VolumePlugin.hx b/source/funkin/util/plugins/VolumePlugin.hx new file mode 100644 index 00000000..9fdb6555 --- /dev/null +++ b/source/funkin/util/plugins/VolumePlugin.hx @@ -0,0 +1,41 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * Handles volume control in a way that is compatible with alternate control schemes. + */ +class VolumePlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize() + { + FlxG.plugins.addPlugin(new VolumePlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + var isHaxeUIFocused:Bool = haxe.ui.focus.FocusManager.instance?.focus != null; + + if (!isHaxeUIFocused) + { + // Rebindable volume keys. + if (PlayerSettings.player1.controls.VOLUME_MUTE) FlxG.sound.toggleMuted(); + else if (PlayerSettings.player1.controls.VOLUME_UP) FlxG.sound.changeVolume(0.1); + else if (PlayerSettings.player1.controls.VOLUME_DOWN) FlxG.sound.changeVolume(-0.1); + } + } + + override public function destroy():Void + { + if (FlxG.plugins.list.contains(this)) FlxG.plugins.remove(this); + + super.destroy(); + } +} diff --git a/source/funkin/util/plugins/WatchPlugin.hx b/source/funkin/util/plugins/WatchPlugin.hx new file mode 100644 index 00000000..a0311582 --- /dev/null +++ b/source/funkin/util/plugins/WatchPlugin.hx @@ -0,0 +1,52 @@ +package funkin.util.plugins; + +import flixel.FlxBasic; + +/** + * A plugin which adds functionality to display several universally important values + * in the Flixel variable watch window. + */ +class WatchPlugin extends FlxBasic +{ + public function new() + { + super(); + } + + public static function initialize():Void + { + FlxG.plugins.addPlugin(new WatchPlugin()); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + var stateClassName = Type.getClassName(Type.getClass(FlxG.state)); + FlxG.watch.addQuick("currentState", stateClassName); + var subStateClassNames = []; + var subState = FlxG.state.subState; + while (subState != null) + { + subStateClassNames.push(Type.getClassName(Type.getClass(subState))); + subState = subState.subState; + } + FlxG.watch.addQuick("currentSubStates", subStateClassNames.join(", ")); + + FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition); + FlxG.watch.addQuick("songPositionNoOffset", Conductor.instance.songPosition + Conductor.instance.instrumentalOffset); + + FlxG.watch.addQuick("musicLength", FlxG.sound?.music?.length ?? 0.0); + FlxG.watch.addQuick("musicTime", FlxG.sound?.music?.time ?? 0.0); + + FlxG.watch.addQuick("bpm", Conductor.instance.bpm); + FlxG.watch.addQuick("currentMeasureTime", Conductor.instance.currentMeasureTime); + FlxG.watch.addQuick("currentBeatTime", Conductor.instance.currentBeatTime); + FlxG.watch.addQuick("currentStepTime", Conductor.instance.currentStepTime); + } + + public override function destroy():Void + { + super.destroy(); + } +} diff --git a/source/funkin/util/tools/ArraySortTools.hx b/source/funkin/util/tools/ArraySortTools.hx new file mode 100644 index 00000000..9a3f8b34 --- /dev/null +++ b/source/funkin/util/tools/ArraySortTools.hx @@ -0,0 +1,157 @@ +package funkin.util.tools; + +/** + * Contains code for sorting arrays using various algorithms. + * @see https://algs4.cs.princeton.edu/20sorting/ + */ +class ArraySortTools +{ + /** + * Sorts the input array using the merge sort algorithm. + * Stable and guaranteed to run in linearithmic time `O(n log n)`, + * but less efficient in "best-case" situations. + * + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function mergeSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + // Haxe implements merge sort by default. + haxe.ds.ArraySort.sort(input, compare); + } + + /** + * Sorts the input array using the quick sort algorithm. + * More efficient on smaller arrays, but is inefficient `O(n^2)` in "worst-case" situations. + * Not stable; relative order of equal elements is not preserved. + * + * @see https://stackoverflow.com/questions/33884057/quick-sort-stackoverflow-error-for-large-arrays + * Fix for stack overflow issues. + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function quickSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + quickSortInner(input, 0, input.length - 1, compare); + } + + /** + * Internal recursive function for the quick sort algorithm. + * Written with ChatGPT! + */ + static function quickSortInner(input:Array, low:Int, high:Int, compare:CompareFunction):Void + { + // When low == high, the array is empty or too small to sort. + + // EDIT: Recurse on the smaller partition, and loop for the larger partition. + while (low < high) + { + // Designate the first element in the array as the pivot, then partition the array around it. + // Elements less than the pivot will be to the left, and elements greater than the pivot will be to the right. + // Return the index of the pivot. + var pivot:Int = quickSortPartition(input, low, high, compare); + + if ((pivot) - low <= high - (pivot + 1)) + { + quickSortInner(input, low, pivot, compare); + low = pivot + 1; + } + else + { + quickSortInner(input, pivot + 1, high, compare); + high = pivot; + } + } + } + + /** + * Internal function for sorting a partition of an array in the quick sort algorithm. + * Written with ChatGPT! + */ + static function quickSortPartition(input:Array, low:Int, high:Int, compare:CompareFunction):Int + { + // Designate the first element in the array as the pivot. + var pivot:T = input[low]; + // Designate two pointers, used to divide the array into two partitions. + var i:Int = low - 1; + var j:Int = high + 1; + + while (true) + { + // Move the left pointer to the right until it finds an element greater than the pivot. + do + { + i++; + } + while (compare(input[i], pivot) < 0); + + // Move the right pointer to the left until it finds an element less than the pivot. + do + { + j--; + } + while (compare(input[j], pivot) > 0); + + // If i and j have crossed, the array has been partitioned, and the pivot will be at the index j. + if (i >= j) return j; + + // Else, swap the elements at i and j, and start over. + // This slowly moves the pivot towards the middle of the partition, + // while moving elements less than the pivot to the left and elements greater than the pivot to the right. + var temp:T = input[i]; + input[i] = input[j]; + input[j] = temp; + } + + // Don't expect to get here. + return -1; + } + + /** + * Sorts the input array using the insertion sort algorithm. + * Stable and is very fast on nearly-sorted arrays, + * but is inefficient `O(n^2)` in "worst-case" situations. + * + * @param input The array to sort in-place. + * @param compare The comparison function to use. + */ + public static function insertionSort(input:Array, compare:CompareFunction):Void + { + if (input == null || input.length <= 1) return; + if (compare == null) throw 'No comparison function provided.'; + + // Iterate through the array, starting at the second element. + for (i in 1...input.length) + { + // Store the current element. + var current:T = input[i]; + // Store the index of the previous element. + var j:Int = i - 1; + + // While the previous element is greater than the current element, + // move the previous element to the right and move the index to the left. + while (j >= 0 && compare(input[j], current) > 0) + { + input[j + 1] = input[j]; + j--; + } + + // Insert the current element into the array. + input[j + 1] = current; + } + } +} + +/** + * A comparison function. + * Returns a negative number if the first argument is less than the second, + * a positive number if the first argument is greater than the second, + * or zero if the two arguments are equal. + */ +typedef CompareFunction = T->T->Int; diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx new file mode 100644 index 00000000..0fe245e3 --- /dev/null +++ b/source/funkin/util/tools/ArrayTools.hx @@ -0,0 +1,99 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Arrays. + */ +class ArrayTools +{ + /* + * Push an element to the array if it is not already present. + * @param input The array to push to + * @param element The element to push + * @return Whether the element was pushed + */ + public static function pushUnique(input:Array, element:T):Bool + { + if (input.contains(element)) return false; + input.push(element); + return true; + } + + /** + * Remove all elements from the array, without creating a new array. + * @param array The array to clear. + */ + public static function clear(array:Array):Void + { + // This method is faster than array.splice(0, array.length) + while (array.length > 0) + array.pop(); + } + + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone(array:Array):Array + { + return [for (element in array) element]; + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone>(array:Array):Array + { + return [for (element in array) element.clone()]; + } + + /** + * Return true only if both arrays contain the same elements (possibly in a different order). + * @param a The first array to compare. + * @param b The second array to compare. + * @return Weather both arrays contain the same elements. + */ + public static function isEqualUnordered(a:Array, b:Array):Bool + { + if (a.length != b.length) return false; + for (element in a) + { + if (!b.contains(element)) return false; + } + for (element in b) + { + if (!a.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param superset The array to query for each element. + * @param subset The array containing the elements to query for. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSuperset(superset:Array, subset:Array):Bool + { + // Shortcuts. + if (subset.length == 0) return true; + if (subset.length > superset.length) return false; + + // Check each element. + for (element in subset) + { + if (!superset.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param subset The array containing the elements to query for. + * @param superset The array to query for each element. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSubset(subset:Array, superset:Array):Bool + { + // Switch it around. + return isSuperset(superset, subset); + } +} diff --git a/source/funkin/util/tools/FloatTools.hx b/source/funkin/util/tools/FloatTools.hx new file mode 100644 index 00000000..e3411049 --- /dev/null +++ b/source/funkin/util/tools/FloatTools.hx @@ -0,0 +1,24 @@ +package funkin.util.tools; + +/** + * Utilities for performing common math operations. + */ +class FloatTools +{ + /** + * Constrain a float between a minimum and maximum value. + */ + public static function clamp(value:Float, min:Float, max:Float):Float + { + return Math.max(min, Math.min(max, value)); + } + + /** + Round a float to a certain number of decimal places. + **/ + public static function round(number:Float, ?precision = 2):Float + { + number *= Math.pow(10, precision); + return Math.round(number) / Math.pow(10, precision); + } +} diff --git a/source/funkin/util/tools/ICloneable.hx b/source/funkin/util/tools/ICloneable.hx new file mode 100644 index 00000000..33f19f16 --- /dev/null +++ b/source/funkin/util/tools/ICloneable.hx @@ -0,0 +1,10 @@ +package funkin.util.tools; + +/** + * Implement this on a class to enable `Array.deepClone()` to work on it. + * NOTE: T should be the type of the class that implements this interface. + */ +interface ICloneable +{ + public function clone():T; +} diff --git a/source/funkin/util/tools/ISingleton.hx b/source/funkin/util/tools/ISingleton.hx new file mode 100644 index 00000000..642ec617 --- /dev/null +++ b/source/funkin/util/tools/ISingleton.hx @@ -0,0 +1,7 @@ +package funkin.util.tools; + +/** + * An interface which applies a macro to add a Singleton `instance` property to the class. + */ +@:autoBuild(funkin.util.macro.SingletonMacro.build()) +interface ISingleton {} diff --git a/source/funkin/util/tools/Int64Tools.hx b/source/funkin/util/tools/Int64Tools.hx new file mode 100644 index 00000000..b6ac84ad --- /dev/null +++ b/source/funkin/util/tools/Int64Tools.hx @@ -0,0 +1,42 @@ +package funkin.util.tools; + +import haxe.Int64; + +/** + * Why `haxe.Int64` doesn't have a built-in `toFloat` function is beyond me. + */ +class Int64Tools +{ + private inline static var MAX_32_PRECISION:Float = 4294967296.0; + + public static function fromFloat(f:Float):Int64 + { + var h = Std.int(f / MAX_32_PRECISION); + var l = Std.int(f); + return Int64.make(h, l); + } + + public static function toFloat(i:Int64):Float + { + var f:Float = i.low; + if (f < 0) f += MAX_32_PRECISION; + return (i.high * MAX_32_PRECISION + f); + } + + public static function isToIntSafe(i:Int64):Bool + { + return i.high != i.low >> 31; + } + + public static function toIntSafe(i:Int64):Int + { + try + { + return Int64.toInt(i); + } + catch (e:Dynamic) + { + throw 'Could not represent value "${Int64.toStr(i)}" as an integer.'; + } + } +} diff --git a/source/funkin/util/tools/IntTools.hx b/source/funkin/util/tools/IntTools.hx new file mode 100644 index 00000000..1d660ad1 --- /dev/null +++ b/source/funkin/util/tools/IntTools.hx @@ -0,0 +1,16 @@ +package funkin.util.tools; + +/** + * Utilities for performing common math operations. + */ +class IntTools +{ + /** + * Constrain an integer between a minimum and maximum value. + */ + public static function clamp(value:Int, min:Int, max:Int):Int + { + // Don't use Math.min because it returns a Float. + return value < min ? min : value > max ? max : value; + } +} diff --git a/source/funkin/util/tools/IteratorTools.hx b/source/funkin/util/tools/IteratorTools.hx new file mode 100644 index 00000000..49d9477f --- /dev/null +++ b/source/funkin/util/tools/IteratorTools.hx @@ -0,0 +1,34 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Iterators. + * + * For example, add `using IteratorTools` then call `iterator.array()`. + * + * @see https://haxe.org/manual/lf-static-extension.html + */ +class IteratorTools +{ + public static function array(iterator:Iterator):Array + { + return [for (i in iterator) i]; + } + + public static function count(iterator:Iterator, ?predicate:(item:T) -> Bool):Int + { + var n = 0; + + if (predicate == null) + { + for (_ in iterator) + n++; + } + else + { + for (x in iterator) + if (predicate(x)) n++; + } + + return n; + } +} diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx new file mode 100644 index 00000000..807f0aeb --- /dev/null +++ b/source/funkin/util/tools/MapTools.hx @@ -0,0 +1,85 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Maps. + * + * For example, add `using MapTools` then call `map.values()`. + * + * @see https://haxe.org/manual/lf-static-extension.html + */ +class MapTools +{ + /** + * Return the quantity of keys in the map. + */ + public static function size(map:Map):Int + { + if (map == null) return 0; + return map.keys().array().length; + } + + /** + * Return a list of values from the map, as an array. + */ + public static function values(map:Map):Array + { + if (map == null) return []; + return [for (i in map.iterator()) i]; + } + + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone(map:Map):Map + { + if (map == null) return null; + return map.copy(); + } + + /** + * Create a new map which is a combination of the two given maps. + * @param a The base map. + * @param b The other map. The values from this take precedence. + * @return The combined map. + */ + public static function merge(a:Map, b:Map):Map + { + var result = a.copy(); + + for (pair in b.keyValueIterator()) + { + result.set(pair.key, pair.value); + } + + return result; + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone>(map:Map):Map + { + // TODO: This function does NOT work. + throw "Not implemented"; + + /* + var newMap:Map = []; + // Replace each value with a clone of itself. + for (key in newMap.keys()) + { + newMap.set(key, newMap.get(key).clone()); + } + return newMap; + */ + } + + /** + * Return a list of keys from the map (as an array, rather than an iterator). + * TODO: Rename this? + */ + public static function keyValues(map:Map):Array + { + if (map == null) return []; + return map.keys().array(); + } +} diff --git a/source/funkin/util/tools/SongEventDataArrayTools.hx b/source/funkin/util/tools/SongEventDataArrayTools.hx new file mode 100644 index 00000000..38e298dd --- /dev/null +++ b/source/funkin/util/tools/SongEventDataArrayTools.hx @@ -0,0 +1,67 @@ +package funkin.util.tools; + +import funkin.data.song.SongData.SongEventData; + +/** + * A static extension which provides utility functions for `Array`s. + */ +class SongEventDataArrayTools +{ + /** + * Queries whether the provided `SongEventData` is contained in the provided array. + * The input array must be already sorted by `time`. + * Vastly more efficient than `array.indexOf`. + * This is not crazy or premature optimization, I'm writing this because `ChartEditorState.handleNoteDisplay` is using like 71% of its CPU time on this. + * @param arr The array to search. + * @param note The note to search for. + * @param predicate + * @return The index of the note in the array, or `-1` if it is not present. + */ + public static function fastIndexOf(input:Array, note:SongEventData):Int + { + // I would have made this use a generic/predicate, but that would have made it slower! + + // Thank you Github Copilot for suggesting a binary search! + var lowIndex:Int = 0; + var highIndex:Int = input.length - 1; + var midIndex:Int; + var midNote:SongEventData; + + // When lowIndex overtakes highIndex + while (lowIndex <= highIndex) + { + // Get the middle index of the range. + midIndex = Std.int((lowIndex + highIndex) / 2); + + // Compare the middle note of the range to the note we're looking for. + // If it matches, return the index, else halve the range and try again. + midNote = input[midIndex]; + if (midNote.time < note.time) + { + // Search the upper half of the range. + lowIndex = midIndex + 1; + } + else if (midNote.time > note.time) + { + // Search the lower half of the range. + highIndex = midIndex - 1; + } + // Found it? Make a more thorough check. + else if (midNote == note) + { + return midIndex; + } + else + { + // We may be close, so constrain the range (but only a little) and try again. + highIndex -= 1; + } + } + return -1; + } + + public static inline function fastContains(input:Array, note:SongEventData):Bool + { + return fastIndexOf(input, note) != -1; + } +} diff --git a/source/funkin/util/tools/SongNoteDataArrayTools.hx b/source/funkin/util/tools/SongNoteDataArrayTools.hx new file mode 100644 index 00000000..59495922 --- /dev/null +++ b/source/funkin/util/tools/SongNoteDataArrayTools.hx @@ -0,0 +1,69 @@ +package funkin.util.tools; + +import funkin.data.song.SongData.SongNoteData; + +/** + * A static extension which provides utility functions for `Array`s. + */ +class SongNoteDataArrayTools +{ + /** + * Queries whether the provided `SongNoteData` is contained in the provided array. + * The input array must be already sorted by `time`. + * Vastly more efficient than `array.indexOf`. + * This is not crazy or premature optimization, I'm writing this because `ChartEditorState.handleNoteDisplay` is using like 71% of its CPU time on this. + * @param arr The array to search. + * @param note The note to search for. + * @param predicate + * @return The index of the note in the array, or `-1` if it is not present. + */ + public static function fastIndexOf(input:Array, note:SongNoteData):Int + { + // I would have made this use a generic/predicate, but that would have made it slower! + + // Prefix with some simple checks to save time. + if (input.length == 0) return -1; + if (note.time < input[0].time || note.time > input[input.length - 1].time) return -1; + + // Thank you Github Copilot for suggesting a binary search! + var lowIndex:Int = 0; + var highIndex:Int = input.length - 1; + + // When lowIndex overtakes highIndex + while (lowIndex <= highIndex) + { + // Get the middle index of the range. + var midIndex = Std.int((lowIndex + highIndex) / 2); + + // Compare the middle note of the range to the note we're looking for. + // If it matches, return the index, else halve the range and try again. + var midNote = input[midIndex]; + if (midNote.time < note.time) + { + // Search the upper half of the range. + lowIndex = midIndex + 1; + } + else if (midNote.time > note.time) + { + // Search the lower half of the range. + highIndex = midIndex - 1; + } + // Found it? Do a more thorough check. + else if (midNote == note) + { + return midIndex; + } + else + { + // We may be close, so constrain the range (but only a little) and try again. + highIndex -= 1; + } + } + return -1; + } + + public static inline function fastContains(input:Array, note:SongNoteData):Bool + { + return fastIndexOf(input, note) != -1; + } +} diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx new file mode 100644 index 00000000..cb660db0 --- /dev/null +++ b/source/funkin/util/tools/StringTools.hx @@ -0,0 +1,109 @@ +package funkin.util.tools; + +/** + * A static extension which provides utility functions for Strings. + */ +class StringTools +{ + /** + * Converts a string to title case. For example, "hello world" becomes "Hello World". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toTitleCase(value:String):String + { + var words:Array = value.split(' '); + var result:String = ''; + for (i in 0...words.length) + { + var word:String = words[i]; + result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase(); + if (i < words.length - 1) + { + result += ' '; + } + } + return result; + } + + /** + * Strip a given prefix from a string. + * @param value The string to strip. + * @param prefix The prefix to strip. If the prefix isn't found, the original string is returned. + * @return The stripped string. + */ + public static function stripPrefix(value:String, prefix:String):String + { + if (value.startsWith(prefix)) + { + return value.substr(prefix.length); + } + return value; + } + + /** + * Strip a given suffix from a string. + * @param value The string to strip. + * @param suffix The suffix to strip. If the suffix isn't found, the original string is returned. + * @return The stripped string. + */ + public static function stripSuffix(value:String, suffix:String):String + { + if (value.endsWith(suffix)) + { + return value.substr(0, value.length - suffix.length); + } + return value; + } + + /** + * Converts a string to lower kebab case. For example, "Hello World" becomes "hello-world". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toLowerKebabCase(value:String):String + { + return value.toLowerCase().replace(' ', '-'); + } + + /** + * Converts a string to upper kebab case, aka screaming kebab case. For example, "Hello World" becomes "HELLO-WORLD". + * + * @param value The string to convert. + * @return The converted string. + */ + public static function toUpperKebabCase(value:String):String + { + return value.toUpperCase().replace(' ', '-'); + } + + /** + * The regular expression to sanitize strings. + */ + static final SANTIZE_REGEX:EReg = ~/[^-a-zA-Z0-9]/g; + + /** + * Remove all instances of symbols other than alpha-numeric characters (and dashes)from a string. + * @param value The string to sanitize. + * @return The sanitized string. + */ + public static function sanitize(value:String):String + { + return SANTIZE_REGEX.replace(value, ''); + } + + /** + * Parses the string data as JSON and returns the resulting object. + * This is here so you can use `string.parseJSON()` when `using StringTools`. + * + * TODO: Remove this and replace with `json2object` + * @param value The + * @return The parsed object. + */ + public static function parseJSON(value:String):Dynamic + { + return SerializerUtil.fromJSON(value); + } +} diff --git a/source/haxe/ui/backend/flixel/UIStateBase.hx b/source/haxe/ui/backend/flixel/UIStateBase.hx new file mode 100644 index 00000000..38377206 --- /dev/null +++ b/source/haxe/ui/backend/flixel/UIStateBase.hx @@ -0,0 +1,6 @@ +package haxe.ui.backend.flixel; + +/** + * Override HaxeUI to use `MusicBeatState` instead of `FlxState`. + */ +typedef UIStateBase = funkin.ui.MusicBeatState; diff --git a/source/haxe/ui/backend/flixel/UISubStateBase.hx b/source/haxe/ui/backend/flixel/UISubStateBase.hx new file mode 100644 index 00000000..52c9a723 --- /dev/null +++ b/source/haxe/ui/backend/flixel/UISubStateBase.hx @@ -0,0 +1,6 @@ +package haxe.ui.backend.flixel; + +/** + * Override HaxeUI to use `MusicBeatSubState` instead of `FlxSubState`. + */ +typedef UISubStateBase = funkin.ui.MusicBeatSubState; diff --git a/source/module.xml b/source/module.xml new file mode 100644 index 00000000..c3437d99 --- /dev/null +++ b/source/module.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/tests/unit/.gitignore b/tests/unit/.gitignore new file mode 100644 index 00000000..abb6c9cf --- /dev/null +++ b/tests/unit/.gitignore @@ -0,0 +1,7 @@ + +bin/ +# auto-generated +source/TestSuite.hx +.temp/ +report/ +build/ diff --git a/tests/unit/.munit b/tests/unit/.munit new file mode 100644 index 00000000..61de5796 --- /dev/null +++ b/tests/unit/.munit @@ -0,0 +1,7 @@ +version=2.3.5 +src=source +bin=bin +report=report +hxml=test.hxml +classPaths=../../source +coveragePackages=funkin diff --git a/tests/unit/.vscode/launch.json b/tests/unit/.vscode/launch.json new file mode 100644 index 00000000..a255f164 --- /dev/null +++ b/tests/unit/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Lime", + "type": "lime", + "request": "launch" + } + ] +} \ No newline at end of file diff --git a/tests/unit/README.md b/tests/unit/README.md new file mode 100644 index 00000000..ae8d273b --- /dev/null +++ b/tests/unit/README.md @@ -0,0 +1,56 @@ +Flixel Unit Tests +----------------- + +This is a unit test project using [munit](https://github.com/massiveinteractive/MassiveUnit). It's good practice to add tests for fixed bugs or new features. + +TODO Make sure the unit tests are automatically run on GitHub Actions. + +There's a 1:1 mapping between `.hx` files in `source/` and the unit test project - tests for `funkin.Conductor` go into `funkin.ConductorTest` etc. + +### Building + +Run one of the `test-*.hxml` files in this directory to run the tests on that specific target, e.g. `haxe test-cpp.hxml`. Currently supported are: + +- `web` (HTML5) +- `cpp` (Native) + +Alternatively, this can be done from within Visual Studio Code - (`F1` -> `Tasks: Run Task` -> Choose the target to test). + +#### Adding Tests + +- Run `haxelib run munit create com.FooBarTest -for com.Foo` +- Use `@:allow(full.package.name.ClassName)` to allow a test class to call private functions. +- Use `mockatoo.Mockatoo.mock(ClassName)` to mock a class. See [Mockatoo docs](https://github.com/misprintt/mockatoo). + +#### Functions + +- `@Before` functions are named `before()` +- Each `@Test` function starts with `test` and describes what exactly it tests. This can lead to long function names like `FlxEmitter#testStartShouldNotReviveMembers()` and serves as self-documentation. +- Another thing that helps with self-documentation is adding a comment for tests that are related an issue on GitHub. + + ```haxe + @Test // #1203 + function testColorWithAlphaComparison() + ``` + +### `FunkinTest` base class + +Test classes extend `FunkinTest`, which is a base class with some utility functions for testing. + +### `step()` + +`step()` advances the `FlxGame` exactly one step. This is useful for tests that depend on game time advancing / `FlxGame#step()` being executed, such as physics of `add()`ed objects, state switches, or just time passing for tweens or timers. + +There are two parameters: +- `steps` - specifies the amount of steps to advance (defaults to 1) +- `callback` - an optional callback function that is executed after each step + +### `testDestroy()` + +`testDestroy()` tests whether an `IFlxDestroyable` can safely be `destroy()`ed more than once (null reference errors are fairly common here). For this, `destroyable` has to be set during `before()` of the test class. + +### Null Safety + +Append each test class with `@:nullSafety` to prevent crash bugs while developing. + +Note that `Assert.isNotNull(target)` is considered a vlid diff --git a/tests/unit/assets/preload/data/anotherFile.txt b/tests/unit/assets/preload/data/anotherFile.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/assets/preload/data/levels/blankpathtest.json b/tests/unit/assets/preload/data/levels/blankpathtest.json new file mode 100644 index 00000000..70901225 --- /dev/null +++ b/tests/unit/assets/preload/data/levels/blankpathtest.json @@ -0,0 +1,47 @@ +{ + "version": "1.0.0", + "name": "SHOULD FAIL TO PARSE", + "titleAsset": "", + "props": [ + { + "assetPath": "storymenu/props/gf", + "scale": 1.0, + "danceEvery": 2, + "offsets": [80, 80], + "animations": [ + { + "name": "danceLeft", + "prefix": "idle0", + "frameIndices": [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + }, + { + "name": "danceRight", + "prefix": "idle0", + "frameIndices": [ + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 + ] + } + ] + }, + { + "assetPath": "storymenu/props/bf", + "scale": 1.0, + "danceEvery": 2, + "offsets": [150, 80], + "animations": [ + { + "name": "idle", + "prefix": "idle0", + "frameRate": 24 + }, + { + "name": "confirm", + "prefix": "confirm0", + "frameRate": 24 + } + ] + } + ], + "background": "#F9CF51", + "songs": ["tutorial"] +} diff --git a/tests/unit/assets/preload/data/levels/test.json b/tests/unit/assets/preload/data/levels/test.json new file mode 100644 index 00000000..8b004e21 --- /dev/null +++ b/tests/unit/assets/preload/data/levels/test.json @@ -0,0 +1,47 @@ +{ + "version": "1.0.0", + "name": "TEACHING TIME", + "titleAsset": "storymenu/titles/tutorial", + "props": [ + { + "assetPath": "storymenu/props/gf", + "scale": 1.0, + "danceEvery": 2, + "offsets": [80, 80], + "animations": [ + { + "name": "danceLeft", + "prefix": "idle0", + "frameIndices": [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + }, + { + "name": "danceRight", + "prefix": "idle0", + "frameIndices": [ + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 + ] + } + ] + }, + { + "assetPath": "storymenu/props/bf", + "scale": 1.0, + "danceEvery": 2, + "offsets": [150, 80], + "animations": [ + { + "name": "idle", + "prefix": "idle0", + "frameRate": 24 + }, + { + "name": "confirm", + "prefix": "confirm0", + "frameRate": 24 + } + ] + } + ], + "background": "#F9CF51", + "songs": ["tutorial"] +} diff --git a/tests/unit/assets/preload/data/mytype/blablabla.json b/tests/unit/assets/preload/data/mytype/blablabla.json new file mode 100644 index 00000000..cf7d1674 --- /dev/null +++ b/tests/unit/assets/preload/data/mytype/blablabla.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "id": "blablabla", + "name": "blablabla API", + "data": [] +} diff --git a/tests/unit/assets/preload/data/mytype/fizzbuzz.json b/tests/unit/assets/preload/data/mytype/fizzbuzz.json new file mode 100644 index 00000000..9b160ff5 --- /dev/null +++ b/tests/unit/assets/preload/data/mytype/fizzbuzz.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "id": "fizzbuzz", + "name": "nodejs-express-mongoose-demo", + "data": [ + { + "foo": "bar", + "bar": "foo" + }, + { + "foo": "baz", + "bar": "baz" + } + ] +} diff --git a/tests/unit/assets/preload/data/mytype/foobar.json b/tests/unit/assets/preload/data/mytype/foobar.json new file mode 100644 index 00000000..0377a226 --- /dev/null +++ b/tests/unit/assets/preload/data/mytype/foobar.json @@ -0,0 +1,5 @@ +{ + "version": "0.1.0", + "id": 1, + "name": "My First App" +} diff --git a/tests/unit/assets/preload/data/mytype/junk.json b/tests/unit/assets/preload/data/mytype/junk.json new file mode 100644 index 00000000..13ae5d1a --- /dev/null +++ b/tests/unit/assets/preload/data/mytype/junk.json @@ -0,0 +1,4 @@ +{ + "version": "3.0.0", + "info": "stuff" +} diff --git a/tests/unit/assets/preload/data/notestyles/funkin.json b/tests/unit/assets/preload/data/notestyles/funkin.json new file mode 100644 index 00000000..a36dd8c1 --- /dev/null +++ b/tests/unit/assets/preload/data/notestyles/funkin.json @@ -0,0 +1,59 @@ +{ + "version": "1.0.0", + "name": "Funkin'", + "author": "PhantomArcade", + "fallback": null, + "assets": { + "note": { + "assetPath": "shared:arrows", + "scale": 1.0, + "isPixel": false, + "data": { + "left": { "prefix": "noteLeft" }, + "down": { "prefix": "noteDown" }, + "up": { "prefix": "noteUp" }, + "right": { "prefix": "noteRight" } + } + }, + "noteStrumline": { + "assetPath": "shared:arrows", + "scale": 1.0, + "offsets": [28, 32], + "isPixel": false, + "data": { + "leftStatic": { "prefix": "staticLeft0" }, + "leftPress": { "prefix": "pressedLeft0" }, + "leftConfirm": { "prefix": "confirmLeft0" }, + "leftConfirmHold": { "prefix": "confirmLeft0" }, + "downStatic": { "prefix": "staticDown0" }, + "downPress": { "prefix": "pressedDown0" }, + "downConfirm": { "prefix": "confirmDown0" }, + "downConfirmHold": { "prefix": "confirmDown0" }, + "upStatic": { "prefix": "staticUp0" }, + "upPress": { "prefix": "pressedUp0" }, + "upConfirm": { "prefix": "confirmUp0" }, + "upConfirmHold": { "prefix": "confirmUp0" }, + "rightStatic": { "prefix": "staticRight0" }, + "rightPress": { "prefix": "pressedRight0" }, + "rightConfirm": { "prefix": "confirmRight0" }, + "rightConfirmHold": { "prefix": "confirmRight0" } + } + }, + "holdNote": { + "assetPath": "NOTE_hold_assets", + "data": {} + }, + "noteSplash": { + "assetPath": "", + "data": { + "enabled": true + } + }, + "holdNoteCover": { + "assetPath": "", + "data": { + "enabled": true + } + } + } +} diff --git a/tests/unit/assets/preload/data/notestyles/test2.json b/tests/unit/assets/preload/data/notestyles/test2.json new file mode 100644 index 00000000..95a66907 --- /dev/null +++ b/tests/unit/assets/preload/data/notestyles/test2.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "name": "Test2", + "author": "Eric", + "fallback": "funkin", + "assets": { + "note": { + "assetPath": "shared:coolstuff", + "scale": 1.8, + "data": { + "left": { "prefix": "noteLeft1" }, + "down": { "prefix": "noteDown3" }, + "up": { "prefix": "noteUp2" }, + "right": { "prefix": "noteRight4" } + } + } + } +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json new file mode 100644 index 00000000..c7f7629d --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "songName": "Bopeebo", + "artist": "Kawai Sprite", + "timeFormat": "ms", + "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }], + "playData": { + "songVariations": [], + "difficulties": ["easy", "normal", "hard"], + "playableChars": { "bf": { "g": "gf", "o": "dad" } }, + "stage": "mainStage", + "noteSkin": "Normal" + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json new file mode 100644 index 00000000..53817f96 --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json @@ -0,0 +1,1738 @@ +{ + "version": "2.0.0", + "scrollSpeed": { + "default": 1.0, + "hard": 1.3 + }, + "events": [ + { "t": 0, "e": "FocusCamera", "v": 1 }, + { "t": 2400, "e": "FocusCamera", "v": 0 }, + { + "t": 4200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 4800, "e": "FocusCamera", "v": 1 }, + { "t": 7200, "e": "FocusCamera", "v": 0 }, + { + "t": 9000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 9600, "e": "FocusCamera", "v": 1 }, + { "t": 12000, "e": "FocusCamera", "v": 0 }, + { + "t": 13800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 14400, "e": "FocusCamera", "v": 1 }, + { "t": 16800, "e": "FocusCamera", "v": 0 }, + { + "t": 18600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 19200, "e": "FocusCamera", "v": 1 }, + { "t": 21600, "e": "FocusCamera", "v": 0 }, + { + "t": 23400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 24000, "e": "FocusCamera", "v": 1 }, + { "t": 26400, "e": "FocusCamera", "v": 0 }, + { + "t": 28200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 28800, "e": "FocusCamera", "v": 1 }, + { "t": 31200, "e": "FocusCamera", "v": 0 }, + { + "t": 33000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 33600, "e": "FocusCamera", "v": 1 }, + { "t": 36000, "e": "FocusCamera", "v": 0 }, + { + "t": 37800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 38400, "e": "FocusCamera", "v": 1 }, + { "t": 40800, "e": "FocusCamera", "v": 0 }, + { + "t": 42600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 43200, "e": "FocusCamera", "v": 1 }, + { "t": 45600, "e": "FocusCamera", "v": 0 }, + { + "t": 47400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 48000, "e": "FocusCamera", "v": 1 }, + { "t": 50400, "e": "FocusCamera", "v": 0 }, + { + "t": 52200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 52800, "e": "FocusCamera", "v": 1 }, + { "t": 55200, "e": "FocusCamera", "v": 0 }, + { + "t": 57000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 57600, "e": "FocusCamera", "v": 1 }, + { "t": 60000, "e": "FocusCamera", "v": 0 }, + { + "t": 61800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 62400, "e": "FocusCamera", "v": 1 }, + { "t": 64800, "e": "FocusCamera", "v": 0 }, + { + "t": 66600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 67200, "e": "FocusCamera", "v": 1 }, + { "t": 69600, "e": "FocusCamera", "v": 0 }, + { + "t": 71400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 72000, "e": "FocusCamera", "v": 1 }, + { "t": 74400, "e": "FocusCamera", "v": 0 }, + { + "t": 76200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + } + ], + "notes": { + "easy": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 450 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 450 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "normal": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "hard": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34575, + "d": 4 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 36975, + "d": 0 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63375, + "d": 4 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 65775, + "d": 0 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 72975, + "d": 4 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75375, + "d": 0 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ] + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json new file mode 100644 index 00000000..53817f96 --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json @@ -0,0 +1,1738 @@ +{ + "version": "2.0.0", + "scrollSpeed": { + "default": 1.0, + "hard": 1.3 + }, + "events": [ + { "t": 0, "e": "FocusCamera", "v": 1 }, + { "t": 2400, "e": "FocusCamera", "v": 0 }, + { + "t": 4200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 4800, "e": "FocusCamera", "v": 1 }, + { "t": 7200, "e": "FocusCamera", "v": 0 }, + { + "t": 9000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 9600, "e": "FocusCamera", "v": 1 }, + { "t": 12000, "e": "FocusCamera", "v": 0 }, + { + "t": 13800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 14400, "e": "FocusCamera", "v": 1 }, + { "t": 16800, "e": "FocusCamera", "v": 0 }, + { + "t": 18600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 19200, "e": "FocusCamera", "v": 1 }, + { "t": 21600, "e": "FocusCamera", "v": 0 }, + { + "t": 23400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 24000, "e": "FocusCamera", "v": 1 }, + { "t": 26400, "e": "FocusCamera", "v": 0 }, + { + "t": 28200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 28800, "e": "FocusCamera", "v": 1 }, + { "t": 31200, "e": "FocusCamera", "v": 0 }, + { + "t": 33000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 33600, "e": "FocusCamera", "v": 1 }, + { "t": 36000, "e": "FocusCamera", "v": 0 }, + { + "t": 37800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 38400, "e": "FocusCamera", "v": 1 }, + { "t": 40800, "e": "FocusCamera", "v": 0 }, + { + "t": 42600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 43200, "e": "FocusCamera", "v": 1 }, + { "t": 45600, "e": "FocusCamera", "v": 0 }, + { + "t": 47400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 48000, "e": "FocusCamera", "v": 1 }, + { "t": 50400, "e": "FocusCamera", "v": 0 }, + { + "t": 52200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 52800, "e": "FocusCamera", "v": 1 }, + { "t": 55200, "e": "FocusCamera", "v": 0 }, + { + "t": 57000, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 57600, "e": "FocusCamera", "v": 1 }, + { "t": 60000, "e": "FocusCamera", "v": 0 }, + { + "t": 61800, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 62400, "e": "FocusCamera", "v": 1 }, + { "t": 64800, "e": "FocusCamera", "v": 0 }, + { + "t": 66600, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 67200, "e": "FocusCamera", "v": 1 }, + { "t": 69600, "e": "FocusCamera", "v": 0 }, + { + "t": 71400, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + }, + { "t": 72000, "e": "FocusCamera", "v": 1 }, + { "t": 74400, "e": "FocusCamera", "v": 0 }, + { + "t": 76200, + "e": "PlayAnimation", + "v": { + "target": "bf", + "anim": "hey", + "force": true + } + } + ], + "notes": { + "easy": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 450 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 450 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "normal": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ], + "hard": [ + { + "t": 0, + "d": 6 + }, + { + "t": 600, + "d": 7, + "l": 600 + }, + { + "t": 1200, + "d": 7, + "l": 600 + }, + { + "t": 2400, + "d": 2 + }, + { + "t": 3000, + "d": 3, + "l": 600 + }, + { + "t": 3600, + "d": 3, + "l": 600 + }, + { + "t": 4800, + "d": 5, + "l": 300 + }, + { + "t": 5400, + "d": 4, + "l": 300 + }, + { + "t": 6000, + "d": 7, + "l": 600 + }, + { + "t": 7200, + "d": 1, + "l": 300 + }, + { + "t": 7800, + "d": 0, + "l": 300 + }, + { + "t": 8400, + "d": 3, + "l": 600 + }, + { + "t": 9600, + "d": 5, + "l": 300 + }, + { + "t": 10200, + "d": 7 + }, + { + "t": 10500, + "d": 4 + }, + { + "t": 10800, + "d": 5, + "l": 600 + }, + { + "t": 12000, + "d": 1, + "l": 300 + }, + { + "t": 12600, + "d": 3 + }, + { + "t": 12900, + "d": 0 + }, + { + "t": 13200, + "d": 1, + "l": 600 + }, + { + "t": 14400, + "d": 7 + }, + { + "t": 14700, + "d": 5 + }, + { + "t": 15300, + "d": 4 + }, + { + "t": 15600, + "d": 6, + "l": 600 + }, + { + "t": 16800, + "d": 3 + }, + { + "t": 17100, + "d": 1 + }, + { + "t": 17700, + "d": 0 + }, + { + "t": 18000, + "d": 2, + "l": 600 + }, + { + "t": 19200, + "d": 4 + }, + { + "t": 19500, + "d": 7 + }, + { + "t": 19800, + "d": 5, + "l": 900 + }, + { + "t": 21600, + "d": 0 + }, + { + "t": 21900, + "d": 3 + }, + { + "t": 22200, + "d": 1, + "l": 900 + }, + { + "t": 24000, + "d": 5 + }, + { + "t": 24300, + "d": 7 + }, + { + "t": 24600, + "d": 4, + "l": 900 + }, + { + "t": 26400, + "d": 1 + }, + { + "t": 26700, + "d": 3 + }, + { + "t": 27000, + "d": 0, + "l": 900 + }, + { + "t": 28800, + "d": 6 + }, + { + "t": 29100, + "d": 7 + }, + { + "t": 29400, + "d": 4, + "l": 1200 + }, + { + "t": 31200, + "d": 2 + }, + { + "t": 31500, + "d": 3 + }, + { + "t": 31800, + "d": 0, + "l": 1200 + }, + { + "t": 33300, + "d": 6 + }, + { + "t": 33600, + "d": 4 + }, + { + "t": 33900, + "d": 7 + }, + { + "t": 34500, + "d": 6 + }, + { + "t": 34575, + "d": 4 + }, + { + "t": 34800, + "d": 5, + "l": 600 + }, + { + "t": 35700, + "d": 2 + }, + { + "t": 36000, + "d": 0 + }, + { + "t": 36300, + "d": 3 + }, + { + "t": 36900, + "d": 2 + }, + { + "t": 36975, + "d": 0 + }, + { + "t": 37200, + "d": 1, + "l": 600 + }, + { + "t": 38400, + "d": 6, + "l": 450 + }, + { + "t": 39000, + "d": 7, + "l": 300 + }, + { + "t": 39600, + "d": 4, + "l": 600 + }, + { + "t": 40800, + "d": 2, + "l": 450 + }, + { + "t": 41400, + "d": 3, + "l": 300 + }, + { + "t": 42000, + "d": 0, + "l": 600 + }, + { + "t": 43200, + "d": 5 + }, + { + "t": 43800, + "d": 6 + }, + { + "t": 44400, + "d": 5 + }, + { + "t": 44550, + "d": 5 + }, + { + "t": 44700, + "d": 5 + }, + { + "t": 45000, + "d": 6 + }, + { + "t": 45600, + "d": 1 + }, + { + "t": 46200, + "d": 2 + }, + { + "t": 46800, + "d": 1 + }, + { + "t": 46950, + "d": 1 + }, + { + "t": 47100, + "d": 1 + }, + { + "t": 47400, + "d": 2 + }, + { + "t": 48000, + "d": 6, + "l": 450 + }, + { + "t": 48600, + "d": 7, + "l": 300 + }, + { + "t": 49200, + "d": 4, + "l": 450 + }, + { + "t": 50400, + "d": 2, + "l": 450 + }, + { + "t": 51000, + "d": 3, + "l": 300 + }, + { + "t": 51600, + "d": 0, + "l": 450 + }, + { + "t": 52800, + "d": 7, + "l": 1800 + }, + { + "t": 55200, + "d": 3, + "l": 1800 + }, + { + "t": 57600, + "d": 6 + }, + { + "t": 57900, + "d": 7 + }, + { + "t": 58200, + "d": 4, + "l": 1200 + }, + { + "t": 60000, + "d": 2 + }, + { + "t": 60300, + "d": 3 + }, + { + "t": 60600, + "d": 0, + "l": 1200 + }, + { + "t": 62100, + "d": 6 + }, + { + "t": 62400, + "d": 4 + }, + { + "t": 62700, + "d": 7 + }, + { + "t": 63300, + "d": 6 + }, + { + "t": 63375, + "d": 4 + }, + { + "t": 63600, + "d": 5, + "l": 600 + }, + { + "t": 64500, + "d": 2 + }, + { + "t": 64800, + "d": 0 + }, + { + "t": 65100, + "d": 3 + }, + { + "t": 65700, + "d": 2 + }, + { + "t": 65775, + "d": 0 + }, + { + "t": 66000, + "d": 1, + "l": 600 + }, + { + "t": 67200, + "d": 6 + }, + { + "t": 67500, + "d": 7 + }, + { + "t": 67800, + "d": 4 + }, + { + "t": 68100, + "d": 6 + }, + { + "t": 68400, + "d": 5, + "l": 600 + }, + { + "t": 69600, + "d": 2 + }, + { + "t": 69900, + "d": 3 + }, + { + "t": 70200, + "d": 0 + }, + { + "t": 70500, + "d": 2 + }, + { + "t": 70800, + "d": 1, + "l": 600 + }, + { + "t": 71700, + "d": 6 + }, + { + "t": 72000, + "d": 4 + }, + { + "t": 72300, + "d": 7 + }, + { + "t": 72900, + "d": 6 + }, + { + "t": 72975, + "d": 4 + }, + { + "t": 73200, + "d": 5, + "l": 600 + }, + { + "t": 74100, + "d": 2 + }, + { + "t": 74400, + "d": 0 + }, + { + "t": 74700, + "d": 3 + }, + { + "t": 75300, + "d": 2 + }, + { + "t": 75375, + "d": 0 + }, + { + "t": 75600, + "d": 1, + "l": 600 + } + ] + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json new file mode 100644 index 00000000..c7f7629d --- /dev/null +++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "songName": "Bopeebo", + "artist": "Kawai Sprite", + "timeFormat": "ms", + "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }], + "playData": { + "songVariations": [], + "difficulties": ["easy", "normal", "hard"], + "playableChars": { "bf": { "g": "gf", "o": "dad" } }, + "stage": "mainStage", + "noteSkin": "Normal" + }, + "generatedBy": "MasterEric (by hand)" +} diff --git a/tests/unit/assets/preload/data/test/blablabla.json b/tests/unit/assets/preload/data/test/blablabla.json new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/assets/preload/data/test/test1.json b/tests/unit/assets/preload/data/test/test1.json new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/assets/preload/data/test/test2.json b/tests/unit/assets/preload/data/test/test2.json new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/project.xml b/tests/unit/project.xml new file mode 100644 index 00000000..6f534e22 --- /dev/null +++ b/tests/unit/project.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/unit/report-linux.sh b/tests/unit/report-linux.sh new file mode 100644 index 00000000..bc9ac2b7 --- /dev/null +++ b/tests/unit/report-linux.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +cd ./report/ +genhtml -o ./html/ ./lcov.info diff --git a/tests/unit/source/FunkinAssert.hx b/tests/unit/source/FunkinAssert.hx new file mode 100644 index 00000000..6a0b0a9a --- /dev/null +++ b/tests/unit/source/FunkinAssert.hx @@ -0,0 +1,180 @@ +package; + +import flixel.math.FlxPoint; +import flixel.math.FlxRect; +import haxe.PosInfos; +import massive.munit.Assert; + +using flixel.util.FlxArrayUtil; + +/** + * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit + */ +@:nullSafety +class FunkinAssert +{ + /** + * Assert if `expected` is within `margin` of `actual`, and fail if not. + * Useful for comparting Float values. + * + * @param expected The expected value of the test. + * @param actual The actual value of the test. + * @param margin The allowed margin of error between the expected and actual values. + * @param info Info on the position this function was called from. Magic value, passed automatically. + */ + public static function areNear(expected:Float, ?actual:Float, margin:Float = 0.001, ?info:PosInfos):Void + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + if (areNearHelper(expected, actual)) Assert.assertionCount++; + else + Assert.fail('Value [$actual] is not within [$margin] of [$expected]', info); + } + + public static function rectsNear(expected:FlxRect, ?actual:FlxRect, margin:Float = 0.001, ?info:PosInfos):Void + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + var areNear = areNearHelper(expected.x, actual.x, margin) + && areNearHelper(expected.y, actual.y, margin) + && areNearHelper(expected.width, actual.width, margin) + && areNearHelper(expected.height, actual.height, margin); + + if (areNear) Assert.assertionCount++; + else + Assert.fail('Value [$actual] is not within [$margin] of [$expected]', info); + } + + static function areNearHelper(expected:Float, actual:Float, margin:Float = 0.001):Bool + { + return actual >= expected - margin && actual <= expected + margin; + } + + public static function arraysEqual(expected:Array, ?actual:Array, ?info:PosInfos):Void + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + if (expected.equals(actual)) Assert.assertionCount++; + else + Assert.fail('\nExpected\n ${expected}\nbut was\n ${actual}\n', info); + } + + public static function arraysNotEqual(expected:Array, ?actual:Array, ?info:PosInfos):Void + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + if (!expected.equals(actual)) Assert.assertionCount++; + else + Assert.fail('\nValue\n ${actual}\nwas equal to\n ${expected}\n', info); + } + + public static function pointsEqual(expected:FlxPoint, ?actual:FlxPoint, ?msg:String, ?info:PosInfos) + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + if (expected.equals(actual)) Assert.assertionCount++; + else if (msg != null) Assert.fail(msg, info); + else + Assert.fail("Value [" + actual + "] was not equal to expected value [" + expected + "]", info); + } + + public static function pointsNotEqual(expected:FlxPoint, ?actual:FlxPoint, ?msg:String, ?info:PosInfos) + { + if (actual == null) Assert.fail('Value [$actual] is null, and cannot be compared to [$expected]', info); + if (!expected.equals(actual)) Assert.assertionCount++; + else if (msg != null) Assert.fail(msg, info); + else + Assert.fail("Value [" + actual + "] was equal to value [" + expected + "]", info); + } + + /** + * Execute `targetFunc`, expecting it to throw an exception. + * If it doesn't, or if the exception doesn't validate against the provided `predicate`, fail. + */ + public static function validateThrows(targetFunc:Void->Void, predicate:Dynamic->Bool, ?info:PosInfos) + { + try + { + targetFunc(); + Assert.fail("Expected exception to be thrown, got no failure.", info); + } + catch (e:Dynamic) + { + if (predicate(e)) + { + Assert.assertionCount++; + } + else + { + Assert.fail('Expected exception to match predicate, but failed (got ${e})', info); + } + } + } + + /** + * Execute `targetFunc`, expecting it to throw a `json2object.Error.CustomFunctionException` with a message matching `expected`. + * I made this its own function since it's the most common specific use case of `validateThrows`. + */ + public static function validateThrowsJ2OCustom(targetFunc:Void->Void, expected:String, ?info:PosInfos) + { + var predicate:Dynamic->Bool = function(err:Dynamic):Bool { + if (!Std.isOfType(err, json2object.Error)) Assert.fail('Expected error of type json2object.Error, got ${Type.typeof(err)}'); + + switch (err) + { + case json2object.Error.CustomFunctionException(msg, pos): + if (msg != expected) Assert.fail('Expected message [${expected}], got [${msg}].'); + default: + Assert.fail('Expected error of type CustomFunctionException, got [${err}].'); + } + + return true; + }; + validateThrows(targetFunc, predicate, info); + } + + static var capturedTraces:Array = []; + + public static function initAssertTrace():Void + { + var oldTrace = haxe.Log.trace; + haxe.Log.trace = function(v:Dynamic, ?infos:haxe.PosInfos) { + onTrace(v, infos); + // oldTrace(v, infos); + }; + } + + public static function clearTraces():Void + { + capturedTraces = []; + } + + @:nullSafety(Off) // Why isn't haxe.std null-safe? + static function onTrace(v:Dynamic, ?infos:haxe.PosInfos) + { + // var str:String = haxe.Log.formatOutput(v, infos); + var str:String = Std.string(v); + capturedTraces.push(str); + + #if (sys && echo_traces) + Sys.println('[TESTLOG] $str'); + #end + } + + /** + * Check the first string that was traced and validate it. + * @param expected + */ + public static inline function assertTrace(expected:String):Void + { + var actual:Null = capturedTraces.shift(); + Assert.isNotNull(actual); + Assert.areEqual(expected, actual); + } + + /** + * Check the first string that was traced and validate it. + * @param expected + */ + public static inline function assertLastTrace(expected:String):Void + { + var actual:Null = capturedTraces.pop(); + Assert.isNotNull(actual); + Assert.areEqual(expected, actual); + } +} diff --git a/tests/unit/source/FunkinTest.hx b/tests/unit/source/FunkinTest.hx new file mode 100644 index 00000000..8f47a7d3 --- /dev/null +++ b/tests/unit/source/FunkinTest.hx @@ -0,0 +1,106 @@ +package; + +import openfl.utils.Assets; +import openfl.errors.Error; +import flixel.FlxG; +import flixel.FlxState; +import flixel.tweens.FlxTween; +import flixel.util.FlxDestroyUtil.IFlxDestroyable; +import massive.munit.Assert; + +/** + * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit + */ +@:nullSafety +class FunkinTest +{ + public static final MS_PER_STEP:Float = 1.0 / 60.0 * 1000; + + // approx. amount of ticks at 60 fps + static inline var TICKS_PER_FRAME:UInt = 25; + static var totalSteps:UInt = 0; + + var destroyable:Null = null; + + public function new() {} + + @After + @:access(flixel) + function after() + { + // Redefine how the game gets the time during tests. + FlxG.game.getTimer = function() { + return totalSteps * TICKS_PER_FRAME; + } + + // make sure we have the same starting conditions for each test + resetGame(); + } + + /** + * Advance the game simulation. + * @param steps The amount to advance the game by. + * @param callback A function to call after each step. + */ + @:access(flixel) + function step(steps:UInt = 1, ?callback:Void->Void) + { + for (i in 0...steps) + { + FlxG.game.step(); + if (callback != null) callback(); + totalSteps++; + } + } + + function resetGame() + { + FlxG.resetGame(); + step(); + } + + function switchState(nextState:FlxState) + { + FlxG.switchState(nextState); + step(); + } + + function resetState() + { + FlxG.resetState(); + step(); + } + + @Test + public function testAssert() + { + Assert.areEqual(4, 2 + 2); + } + + @Test + function testDestroy() + { + if (destroyable == null) + { + return; + } + + try + { + destroyable.destroy(); + destroyable.destroy(); + } + catch (e:Error) + { + Assert.fail(e.message); + } + } + + function finishTween(tween:FlxTween) + { + while (!tween.finished) + { + step(); + } + } +} diff --git a/tests/unit/source/MockTest.hx b/tests/unit/source/MockTest.hx new file mode 100644 index 00000000..e9345dc2 --- /dev/null +++ b/tests/unit/source/MockTest.hx @@ -0,0 +1,57 @@ +package; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.DateUtil; + +@:nullSafety +class MockTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testMock() + { + // Test that mocking works. + + var mockSprite = Mockatoo.mock(flixel.FlxSprite); + var mockAnim = Mockatoo.mock(flixel.animation.FlxAnimationController); + mockSprite.animation = mockAnim; + + var animData:funkin.data.animation.AnimationData = + { + name: "testAnim", + prefix: "blablabla" + }; + + mockSprite.animation.addByPrefix("testAnim", "blablabla", 24, false, false, false); + + // Verify that the method was called once. + // If not, a VerificationException will be thrown and the test will fail. + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); + + FunkinAssert.validateThrows(function() { + // Attempt to verify the method was called. + // This should FAIL, since we didn't call the method. + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); + }, function(err) { + return Std.isOfType(err, mockatoo.exception.VerificationException); + }); + } +} diff --git a/tests/unit/source/TestMain.hx b/tests/unit/source/TestMain.hx new file mode 100644 index 00000000..11eb87b3 --- /dev/null +++ b/tests/unit/source/TestMain.hx @@ -0,0 +1,94 @@ +package; + +import openfl.Lib; +import flixel.FlxGame; +import flixel.FlxState; +import massive.munit.TestRunner; +import massive.munit.client.HTTPClient; +import massive.munit.client.SummaryReportClient; +import funkin.util.logging.CrashHandler; +import funkin.util.FileUtil; + +/** + * Auto generated Test Application. + * Refer to munit command line tool for more information (haxelib run munit) + */ +@:nullSafety +class TestMain +{ + /** + * If true, include a report with each ignored test and their descriptions. + */ + static final INCLUDE_IGNORED_REPORT:Bool = false; + + static final COVERAGE_FOLDER:String = "../../../report"; + + static function main() + { + new TestMain(); + } + + public function new() + { + try + { + CrashHandler.initialize(); + + // Flixel was not designed for unit testing so we can only have one instance for now. + Lib.current.stage.addChild(new FlxGame(640, 480, FlxState, 60, 60, true)); + + var suites = new Array>(); + suites.push(TestSuite); + + #if MCOVER + // Print individual test results alongside coverage results for each test class, + // as well as a final coverage report for the entire test suite. + var innerClient = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT); + var client = new mcover.coverage.munit.client.MCoverPrintClient(innerClient); + // Print final test results alongside detailed coverage results for the test suite. + var httpClient = new HTTPClient(new mcover.coverage.munit.client.MCoverSummaryReportClient()); + // NOTE: You can also create a custom ICoverageTestResultClient implementation + + // Output coverage in LCOV format. + FileUtil.createDirIfNotExists(COVERAGE_FOLDER); + mcover.coverage.MCoverage.getLogger().addClient(new mcover.coverage.client.LcovPrintClient("Funkin' Coverage Report", '${COVERAGE_FOLDER}/lcov.info')); + #else + // Print individual test results. + var client = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT); + // Print final test suite results. + var httpClient = new HTTPClient(new SummaryReportClient()); + #end + + var runner = new TestRunner(client); + runner.addResultClient(httpClient); + + runner.completionHandler = completionHandler; + runner.run(suites); + } + catch (e) + { + trace('UNCAUGHT EXCEPTION'); + trace(e); + } + } + + /** + * updates the background color and closes the current browser + * for flash and html targets (useful for continuos integration servers) + */ + function completionHandler(successful:Bool):Void + { + try + { + #if flash + openfl.external.ExternalInterface.call("testResult", successful); + #elseif js + js.Lib.eval("testResult(" + successful + ");"); + #elseif sys + Sys.exit(successful ? 0 : 1); + #end + } + // if run from outside browser can get error which we can ignore + catch (e:Dynamic) {} + } +} diff --git a/tests/unit/source/_template.hxtemplate b/tests/unit/source/_template.hxtemplate new file mode 100644 index 00000000..47a546bd --- /dev/null +++ b/tests/unit/source/_template.hxtemplate @@ -0,0 +1,30 @@ +package funkin.play; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.play.PlayState; + +@:access(funkin.play.PlayState) +class Template extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function test() {} +} diff --git a/tests/unit/source/flixel/FlxGTest.hx b/tests/unit/source/flixel/FlxGTest.hx new file mode 100644 index 00000000..5f201a9f --- /dev/null +++ b/tests/unit/source/flixel/FlxGTest.hx @@ -0,0 +1,107 @@ +package flixel; + +import massive.munit.Assert; + +@:access(flixel.FlxG) +class FlxGTest extends FunkinTest +{ + @Test function testVERSIONNull():Void + Assert.isNotNull(FlxG.VERSION); + + @Test function testGameNull():Void + Assert.isNotNull(FlxG.game); + + @Test function testStageNull():Void + Assert.isNotNull(FlxG.stage); + + @Test function testStateNull():Void + Assert.isNotNull(FlxG.state); + + @Test function testWorldBoundsNull():Void + Assert.isNotNull(FlxG.worldBounds); + + @Test function testSaveNull():Void + Assert.isNotNull(FlxG.save); + + #if FLX_MOUSE + @Test function testMouseNull():Void + Assert.isNotNull(FlxG.mouse); + #end + + #if FLX_TOUCH + @Test function testTouchNull():Void + Assert.isNotNull(FlxG.touches); + #end + + #if FLX_POINTER_INPUT + @Test function testSwipesNull():Void + Assert.isNotNull(FlxG.swipes); + #end + + #if FLX_KEYBOARD + @Test function testKeysNull():Void + Assert.isNotNull(FlxG.keys); + #end + + #if FLX_GAMEPAD + @Test function testGamepadsNull():Void + Assert.isNotNull(FlxG.gamepads); + #end + + #if android + @Test function testAndroidNull():Void + Assert.isNotNull(FlxG.android); + #end + + #if js + @Test function testHtml5Null():Void + Assert.isNotNull(FlxG.html5); + #end + + @Test function testInputsNull():Void + Assert.isNotNull(FlxG.inputs); + + @Test function testConsoleNull():Void + Assert.isNotNull(FlxG.console); + + @Test function testLogNull():Void + Assert.isNotNull(FlxG.log); + + @Test function testWatchNull():Void + Assert.isNotNull(FlxG.watch); + + @Test function testDebuggerNull():Void + Assert.isNotNull(FlxG.debugger); + + @Test function testVcrNull():Void + Assert.isNotNull(FlxG.vcr); + + @Test function testBitmapNull():Void + Assert.isNotNull(FlxG.bitmap); + + @Test function testCamerasNull():Void + Assert.isNotNull(FlxG.cameras); + + @Test function testPluginsNull():Void + Assert.isNotNull(FlxG.plugins); + + #if FLX_SOUND_SYSTEM + @Test function testSoundNull():Void + Assert.isNotNull(FlxG.sound); + #end + + @Test function testScaleModeNull():Void + Assert.isNotNull(FlxG.scaleMode); + + @Test + function testDefaultWidth():Void + { + Assert.areEqual(640, FlxG.width); + } + + @Test + function testDefaultHeight():Void + { + Assert.areEqual(480, FlxG.height); + } +} diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx new file mode 100644 index 00000000..a0cfedba --- /dev/null +++ b/tests/unit/source/funkin/ConductorTest.hx @@ -0,0 +1,509 @@ +package funkin; + +import flixel.FlxG; +import flixel.FlxState; +import funkin.Conductor; +import funkin.data.song.SongData.SongTimeChange; +import funkin.util.Constants; +import massive.munit.Assert; + +@:nullSafety +@:access(funkin.Conductor) +class ConductorTest extends FunkinTest +{ + var conductorState:Null = null; + + @Before + function before() + { + FunkinAssert.initAssertTrace(); + + resetGame(); + + // The ConductorState will advance the conductor when step() is called. + FlxG.switchState(conductorState = new ConductorState()); + + Conductor.reset(); + } + + @Test + function testDefaultValues():Void + { + // NOTE: Expected value comes first. + + Assert.areEqual([], Conductor.instance.timeChanges); + Assert.areEqual(null, Conductor.instance.currentTimeChange); + + Assert.areEqual(0, Conductor.instance.songPosition); + Assert.areEqual(Constants.DEFAULT_BPM, Conductor.instance.bpm); + Assert.areEqual(null, Conductor.instance.bpmOverride); + + Assert.areEqual(600, Conductor.instance.beatLengthMs); + + Assert.areEqual(4, Conductor.instance.timeSignatureNumerator); + Assert.areEqual(4, Conductor.instance.timeSignatureDenominator); + + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + Assert.areEqual(0.0, Conductor.instance.currentStepTime); + + Assert.areEqual(150, Conductor.instance.stepLengthMs); + } + + /** + * Tests implementation of `update()`, and how it affects + * `currentBeat`, `currentStep`, `currentStepTime`, and the `beatHit` and `stepHit` signals. + */ + @Test + function testUpdate():Void + { + var currentConductorState:Null = conductorState; + Assert.isNotNull(currentConductorState); + + Assert.areEqual(0, Conductor.instance.songPosition); + + step(); // 1 + + var BPM_100_STEP_TIME = 1 / 9; + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(1 / 9, Conductor.instance.currentStepTime); + + step(7); // 8 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(8 / 9, Conductor.instance.currentStepTime); + + Assert.areEqual(0, currentConductorState.beatsHit); + Assert.areEqual(0, currentConductorState.stepsHit); + + step(); // 9 + + Assert.areEqual(0, currentConductorState.beatsHit); + Assert.areEqual(1, currentConductorState.stepsHit); + currentConductorState.beatsHit = 0; + currentConductorState.stepsHit = 0; + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(1, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime); + + step(35 - 9); // 35 + + Assert.areEqual(0, currentConductorState.beatsHit); + Assert.areEqual(2, currentConductorState.stepsHit); + currentConductorState.beatsHit = 0; + currentConductorState.stepsHit = 0; + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(3, Conductor.instance.currentStep); + FunkinAssert.areNear(3.0 + 8 / 9, Conductor.instance.currentStepTime); + + step(); // 36 + + Assert.areEqual(1, currentConductorState.beatsHit); + Assert.areEqual(1, currentConductorState.stepsHit); + currentConductorState.beatsHit = 0; + currentConductorState.stepsHit = 0; + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime); + + step(50 - 36); // 50 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(5, Conductor.instance.currentStep); + FunkinAssert.areNear(5.555555, Conductor.instance.currentStepTime); + + step(49); // 99 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(11, Conductor.instance.currentStep); + FunkinAssert.areNear(11.0, Conductor.instance.currentStepTime); + + step(1); // 100 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(11, Conductor.instance.currentStep); + FunkinAssert.areNear(11.111111, Conductor.instance.currentStepTime); + } + + @Test + function testUpdateForcedBPM():Void + { + Conductor.instance.forceBPM(60); + + Assert.areEqual(0, Conductor.instance.songPosition); + + // 60 beats per minute = 1 beat per second + // 1 beat per second = 1/60 beats per frame = 4/60 steps per frame + step(); // Advances time 1/60 of 1 second. + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + + step(14 - 1); // 14 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0 - 4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + + step(); // 15 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(1, Conductor.instance.currentStep); + FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step + + step(45 - 1); // 59 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(3, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0 - 4 / 60, Conductor.instance.currentStepTime); + + step(); // 60 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime); + + step(); // 61 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(4, Conductor.instance.currentStep); + FunkinAssert.areNear(4.0 + 4 / 60, Conductor.instance.currentStepTime); + } + + @Test + function testSingleTimeChange():Void + { + // Start the song with a BPM of 120. + var songTimeChanges:Array = [new SongTimeChange(0, 120)]; + Conductor.instance.mapTimeChanges(songTimeChanges); + + // All should be at 0. + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + + // 120 beats per minute = 2 beat per second + // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame + step(); // Advances time 1/60 of 1 second. + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + + step(15 - 1); // 15 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(2, Conductor.instance.currentStep); + FunkinAssert.areNear(2.0, Conductor.instance.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step + + step(45 - 1); // 59 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime); + + step(); // 60 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); + + step(); // 61 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); + } + + @Test + function testDoubleTimeChange():Void + { + // Start the song with a BPM of 120. + var songTimeChanges:Array = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)]; + Conductor.instance.mapTimeChanges(songTimeChanges); + + // All should be at 0. + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + + // 120 beats per minute = 2 beat per second + // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame + step(); // Advances time 1/60 of 1 second. + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step + + step(60 - 1 - 1); // 59 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime); + + step(); // 60 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); + + step(); // 61 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); + + step(179 - 61); // 179 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition); + Assert.areEqual(5, Conductor.instance.currentBeat); + Assert.areEqual(23, Conductor.instance.currentStep); + FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime); + + step(); // 180 (3 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime); + + step(); // 181 (3 + 1/60 seconds) + // BPM has switched to 90! + // 90 beats per minute = 1.5 beat per second + // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame + // = 12/120 steps per frame + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime); + + step(59); // 240 (4 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime); + + step(); // 241 (4 + 1/60 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime); + } + + @Test + function testTripleTimeChange():Void + { + // Start the song with a BPM of 120, then move to 90, then move to 180. + var songTimeChanges:Array = [ + new SongTimeChange(0, 120), + new SongTimeChange(3000, 90), + new SongTimeChange(6000, 180) + ]; + Conductor.instance.mapTimeChanges(songTimeChanges); + + // Verify time changes. + Assert.areEqual(3, Conductor.instance.timeChanges.length); + FunkinAssert.areNear(0, Conductor.instance.timeChanges[0].beatTime); + FunkinAssert.areNear(6, Conductor.instance.timeChanges[1].beatTime); + FunkinAssert.areNear(10.5, Conductor.instance.timeChanges[2].beatTime); + + // All should be at 0. + FunkinAssert.areNear(0, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step + + // 120 beats per minute = 2 beat per second + // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame + step(); // Advances time 1/60 of 1 second. + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition); + Assert.areEqual(0, Conductor.instance.currentBeat); + Assert.areEqual(0, Conductor.instance.currentStep); + FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step + + step(60 - 1 - 1); // 59 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition); + Assert.areEqual(1, Conductor.instance.currentBeat); + Assert.areEqual(7, Conductor.instance.currentStep); + FunkinAssert.areNear(7 + 104 / 120, Conductor.instance.currentStepTime); + + step(); // 60 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime); + + step(); // 61 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition); + Assert.areEqual(2, Conductor.instance.currentBeat); + Assert.areEqual(8, Conductor.instance.currentStep); + FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime); + + step(179 - 61); // 179 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition); + Assert.areEqual(5, Conductor.instance.currentBeat); + Assert.areEqual(23, Conductor.instance.currentStep); + FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime); + + step(); // 180 (3 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); // 23.999 => 24 + FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime); + + step(); // 181 (3 + 1/60 seconds) + // BPM has switched to 90! + // 90 beats per minute = 1.5 beat per second + // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame + // = 12/120 steps per frame + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition); + Assert.areEqual(6, Conductor.instance.currentBeat); + Assert.areEqual(24, Conductor.instance.currentStep); + FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime); + + step(60 - 1 - 1); // 240 (4 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(29, Conductor.instance.currentStep); + FunkinAssert.areNear(29.0 + 108 / 120, Conductor.instance.currentStepTime); + + step(); // 240 (4 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime); + + step(); // 241 (4 + 1/60 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition); + Assert.areEqual(7, Conductor.instance.currentBeat); + Assert.areEqual(30, Conductor.instance.currentStep); + FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime); + + step(359 - 241); // 359 (5 + 59/60 seconds) + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(41, Conductor.instance.currentStep); + FunkinAssert.areNear(41 + 108 / 120, Conductor.instance.currentStepTime); + + step(); // 360 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); // 41.999 + FunkinAssert.areNear(42.0, Conductor.instance.currentStepTime); + + step(); // 361 + // BPM has switched to 180! + // 180 beats per minute = 3 beat per second + // 3 beat per second = 3/60 beats per frame + // = 12/60 steps per frame + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); + FunkinAssert.areNear(42.0 + 12 / 60, Conductor.instance.currentStepTime); + + step(); // 362 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(42, Conductor.instance.currentStep); + FunkinAssert.areNear(42.0 + 24 / 60, Conductor.instance.currentStepTime); + + step(3); // 365 + + FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.instance.songPosition); + Assert.areEqual(10, Conductor.instance.currentBeat); + Assert.areEqual(43, Conductor.instance.currentStep); // 42.999 => 42 + FunkinAssert.areNear(43.0, Conductor.instance.currentStepTime); + } +} + +class ConductorState extends FlxState +{ + public var beatsHit:Int = 0; + public var stepsHit:Int = 0; + + public function new() + { + super(); + } + + function beatHit():Void + { + beatsHit += 1; + } + + function stepHit():Void + { + stepsHit += 1; + } + + public override function create():Void + { + super.create(); + Conductor.beatHit.add(this.beatHit); + Conductor.stepHit.add(this.stepHit); + } + + public override function destroy():Void + { + super.destroy(); + Conductor.beatHit.remove(this.beatHit); + Conductor.stepHit.remove(this.stepHit); + } + + public override function update(elapsed:Float) + { + super.update(elapsed); + + // On each step, increment the Conductor as though the song was playing. + Conductor.instance.update(Conductor.instance.songPosition + elapsed * Constants.MS_PER_SEC); + } +} diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx new file mode 100644 index 00000000..5f837ba9 --- /dev/null +++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx @@ -0,0 +1,238 @@ +package funkin.data; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.data.BaseRegistry; +import funkin.util.SortUtil; +import funkin.util.VersionUtil; + +@:nullSafety +@:access(funkin.data.BaseRegistry) +class BaseRegistryTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() + { + FunkinAssert.initAssertTrace(); + } + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testMyTypeRegistry() + { + // This shouldn't crash. + MyTypeRegistry.instance.loadEntries(); + + // Ensure all entries were loaded. + var entryList = MyTypeRegistry.instance.listEntryIds(); + entryList.sort(SortUtil.alphabetically); + + Assert.areEqual(entryList, [ + "blablabla", + "fizzbuzz", + "foobar", + // "junk" + ]); + + // Ensure this one is not in the list. + Assert.areEqual(entryList.indexOf("junk"), -1); + + // Ensure blablabla got parsed correctly. + var blablabla = MyTypeRegistry.instance.fetchEntry("blablabla"); + Assert.isNotNull(blablabla); + Assert.areEqual(blablabla.id, "blablabla"); + Assert.areEqual(blablabla._data.version, "1.0.0"); + Assert.areEqual(blablabla._data.name, "blablabla API"); + } +} + +typedef MyTypeData = +{ + /** + * The version number of the data schema. + * When making changes to the note style data format, this should be incremented, + * and a migration function should be added to handle old versions. + */ + @:default(funkin.data.BaseRegistryTest.MyTypeRegistry.DATA_VERSION) + var version:String; + + var id:String; + var name:String; + var data:Array; +} + +typedef MySubTypeData = +{ + var foo:String; + var bar:String; +}; + +typedef MyTypeData_v0_1_x = +{ + var version:String; + var id:Int; + var name:String; +}; + +class MyType implements IRegistryEntry +{ + /** + * The ID of the mytype. + */ + public final id:String; + + /** + * Mytype data as parsed from the JSON file. + */ + public final _data:MyTypeData; + + /** + * @param id The ID of the JSON file to parse. + */ + public function new(id:String) + { + this.id = id; + _data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse mytype data for id: $id'; + } + } + + public function destroy():Void {} + + public function toString():String + { + return 'MyType($id)'; + } + + static function _fetchData(id:String):Null + { + return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id)); + } + + public function getSubData():Array + { + return _data.data; + } +} + +class MyTypeRegistry extends BaseRegistry +{ + /** + * The current version string for the note style data format. + * Handle breaking changes by incrementing this value + * and adding migration to the `migrateMyTypeData()` function. + */ + public static final DATA_VERSION:String = "1.0.0"; + + public static final instance:MyTypeRegistry = new MyTypeRegistry(); + + public function new() + { + super('MYTYPE', 'mytype'); + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData(id:String):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + return parser.value; + } + + /** + * Read, parse, and validate the JSON data and produce the corresponding data object. + */ + public function parseEntryData_v0_1_x(id:String):Null + { + // JsonParser does not take type parameters, + // otherwise this function would be in BaseRegistry. + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + + switch (loadEntryFile(id)) + { + case {fileName: fileName, contents: contents}: + parser.fromJson(contents, fileName); + default: + return null; + } + + if (parser.errors.length > 0) + { + printErrors(parser.errors, id); + return null; + } + + var oldData:MyTypeData_v0_1_x = parser.value; + return migrateData_v0_1_x(oldData); + } + + function migrateData_v0_1_x(input:MyTypeData_v0_1_x):MyTypeData + { + return { + version: DATA_VERSION, + id: '${input.id}', + name: input.name, + data: [] + }; + } + + public override function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null + { + if (VersionUtil.validateVersion(version, "0.1.x")) + { + trace('Migrating mytype data from ${version} to ${DATA_VERSION}'); + return parseEntryData_v0_1_x(id); + } + else + { + trace('Parsing mytype data with version ${version}'); + return super.parseEntryDataWithMigration(id, version); + } + } + + function createScriptedEntry(clsName:String):MyType + { + return null; + } + + function getScriptedClassNames():Array + { + return []; + } +} diff --git a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx new file mode 100644 index 00000000..44cfb909 --- /dev/null +++ b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx @@ -0,0 +1,148 @@ +package funkin.data.story.level; + +import funkin.data.story.level.LevelRegistry; +import funkin.ui.story.Level; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import massive.munit.util.Timer; + +@:nullSafety +@:access(funkin.ui.story.Level) +@:access(funkin.data.story.level.LevelRegistry) +class LevelRegistryTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass():Void + { + LevelRegistry.instance.loadEntries(); + } + + @AfterClass + public function afterClass():Void {} + + @Before + public function setup():Void {} + + @After + public function tearDown():Void {} + + @Test + public function testValid():Void + { + Assert.isNotNull(LevelRegistry.instance); + } + + @Test + public function testParseEntryData():Void + { + var result:Null = LevelRegistry.instance.parseEntryData("test"); + + Assert.isNotNull(result); + + Assert.areEqual("1.0.0", result.version); + Assert.areEqual("TEACHING TIME", result.name); + Assert.areEqual("storymenu/titles/tutorial", result.titleAsset); + + Assert.areEqual(2, result.props.length); + + Assert.areEqual("storymenu/props/gf", result.props[0].assetPath); + Assert.areEqual(1.0, result.props[0].scale); + Assert.areEqual(2, result.props[0].danceEvery); + Assert.areEqual([80, 80], result.props[0].offsets); + var anims = result.props[0].animations; + Assert.isNotNull(anims); + Assert.areEqual(2, anims.length); + + var anim0 = anims[0]; + Assert.isNotNull(anim0); + Assert.areEqual("danceLeft", anim0.name); + Assert.areEqual("idle0", anim0.prefix); + Assert.areEqual([30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], anim0.frameIndices); + + var anim1 = anims[1]; + Assert.isNotNull(anim1); + Assert.areEqual("danceRight", anim1.name); + Assert.areEqual("idle0", anim1.prefix); + Assert.areEqual([15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], anim1.frameIndices); + + Assert.areEqual("storymenu/props/bf", result.props[1].assetPath); + Assert.areEqual(1.0, result.props[1].scale); + Assert.areEqual(2, result.props[1].danceEvery); + Assert.areEqual([150, 80], result.props[1].offsets); + anims = result.props[1].animations; + Assert.isNotNull(anims); + Assert.areEqual(2, anims.length); + + anim0 = anims[0]; + Assert.isNotNull(anim0); + Assert.areEqual("idle", anim0.name); + Assert.areEqual("idle0", anim0.prefix); + Assert.areEqual(24, anim0.frameRate); + + anim1 = anims[1]; + Assert.isNotNull(anim1); + Assert.areEqual("confirm", anim1.name); + Assert.areEqual("confirm0", anim1.prefix); + Assert.areEqual(24, anim1.frameRate); + + Assert.areEqual("#F9CF51", result.background); + Assert.areEqual(["tutorial"], result.songs); + } + + @Test + public function testCreateEntry():Void + { + var result:Null = LevelRegistry.instance.createEntry("test"); + + Assert.isNotNull(result); + + Assert.areEqual("Level(test)", result.toString()); + Assert.areEqual("TEACHING TIME", result.getTitle()); + + Assert.areEqual(true, result.isUnlocked()); + Assert.areEqual(true, result.isVisible()); + } + + @Test + public function testFetchEntry():Void + { + var result:Null = LevelRegistry.instance.fetchEntry("test"); + + Assert.isNotNull(result); + + Assert.areEqual("Level(test)", result.toString()); + Assert.areEqual("TEACHING TIME", result.getTitle()); + + Assert.areEqual(true, result.isUnlocked()); + Assert.areEqual(true, result.isVisible()); + } + + @Test + public function testCreateEntryBlankPath():Void + { + // Using @:jcustomparse, `titleAsset` has a validation function that ensures it is not blank. + // This test makes sure that the validation function is being called, and that the error + // results in the level failing to parse. + + FunkinAssert.validateThrows(function() { + var result:Null = LevelRegistry.instance.createEntry("blankpathtest"); + }, function(err) { + return err == "Could not parse level data for id: blankpathtest"; + }); + } + + @Test + public function testFetchBadEntry():Void + { + var result:Null = LevelRegistry.instance.fetchEntry("blablabla"); + Assert.isNull(result); + + var result2:Null = LevelRegistry.instance.fetchEntry("blankpathtest"); + Assert.isNull(result2); + } +} diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx new file mode 100644 index 00000000..0c8cd2d7 --- /dev/null +++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx @@ -0,0 +1,100 @@ +package funkin.data.notestyle; + +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import massive.munit.util.Timer; + +@:nullSafety +@:access(funkin.play.notes.notestyle.NoteStyle) +@:access(funkin.data.notestyle.NoteStyleRegistry) +class NoteStyleRegistryTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass():Void + { + NoteStyleRegistry.instance.loadEntries(); + } + + @AfterClass + public function afterClass():Void {} + + @Before + public function setup():Void {} + + @After + public function tearDown():Void {} + + @Test + public function testValid():Void + { + Assert.isNotNull(NoteStyleRegistry.instance); + } + + @Test + public function testParseEntryData():Void + { + var result:Null = NoteStyleRegistry.instance.parseEntryData("test2"); + + Assert.isNotNull(result); + + Assert.areEqual(result.version, "1.0.0"); + Assert.areEqual(result.name, "Test2"); + Assert.areEqual(result.author, "Eric"); + Assert.areEqual(result.fallback, "funkin"); + + Assert.isNotNull(result.assets); + + var note:Null> = result.assets.note; + Assert.isNotNull(note); + + Assert.areEqual(note.assetPath, "shared:coolstuff"); + Assert.areEqual(note.scale, 1.8); + Assert.areEqual(note.data.left.prefix, "noteLeft1"); + Assert.areEqual(note.data.down.prefix, "noteDown3"); + Assert.areEqual(note.data.up.prefix, "noteUp2"); + Assert.areEqual(note.data.right.prefix, "noteRight4"); + } + + @Test + public function testFetchEntry():Void + { + var result:Null = NoteStyleRegistry.instance.fetchEntry("test2"); + + Assert.isNotNull(result); + + Assert.areEqual(result.toString(), "NoteStyle(test2)"); + Assert.areEqual(result.getName(), "Test2"); + Assert.areEqual(result.getAuthor(), "Eric"); + Assert.areEqual(result.getFallbackID(), "funkin"); + } + + @Test + public function testFetchBadEntry():Void + { + var result:Null = NoteStyleRegistry.instance.fetchEntry("blablabla"); + + Assert.isNull(result); + } + + @Test + public function testFetchDefault():Void + { + var nsrMock = Mockatoo.mock(NoteStyleRegistry); + + nsrMock.fetchDefault().callsRealMethod(); + + // Perform the call. + nsrMock.fetchDefault(); + + // Verify the underlying call. + + nsrMock.fetchEntry(Constants.DEFAULT_NOTE_STYLE).verify(times(1)); + } +} diff --git a/tests/unit/source/funkin/data/song/SongRegistryTest.hx b/tests/unit/source/funkin/data/song/SongRegistryTest.hx new file mode 100644 index 00000000..c623306a --- /dev/null +++ b/tests/unit/source/funkin/data/song/SongRegistryTest.hx @@ -0,0 +1,133 @@ +package funkin.data.song; + +import funkin.data.song.SongData; +import funkin.data.song.SongRegistry; +import funkin.play.song.Song; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import massive.munit.util.Timer; + +@:nullSafety +@:access(funkin.play.song.Song) +@:access(funkin.data.song.SongRegistry) +class SongRegistryTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass():Void + { + FunkinAssert.initAssertTrace(); + SongRegistry.instance.loadEntries(); + } + + @AfterClass + public function afterClass():Void {} + + @Before + public function setup():Void {} + + @After + public function tearDown():Void {} + + @Test + public function testValid():Void + { + Assert.isNotNull(SongRegistry.instance); + } + + @Test + public function testParseMetadata():Void + { + var result:Null = SongRegistry.instance.parseEntryMetadata("bopeebo"); + + Assert.isNotNull(result); + + var expectedVersion:thx.semver.Version = "2.0.0"; + Assert.areEqual(expectedVersion, result.version); + Assert.areEqual("Bopeebo", result.songName); + Assert.areEqual("Kawai Sprite", result.artist); + Assert.areEqual(SongData.SongTimeFormat.MILLISECONDS, result.timeFormat); + Assert.areEqual("MasterEric (by hand)", result.generatedBy); + } + + @Test + public function testParseChartData():Void + { + var result:Null = SongRegistry.instance.parseEntryChartData("bopeebo"); + + Assert.isNotNull(result); + + var expectedVersion:thx.semver.Version = "2.0.0"; + Assert.areEqual(expectedVersion, result.version); + } + + /** + * A test validating an error is thrown when attempting to parse chart data as metadata. + */ + @Test + public function testParseMetadataSwapped():Void + { + // Arrange + FunkinAssert.clearTraces(); + + // Act + var result:Null = SongRegistry.instance.parseEntryMetadata("bopeebo-swapped"); + + // Assert + Assert.isNull(result); + FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped"); + FunkinAssert.assertTrace(" Unknown variable \"scrollSpeed\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:3"); + FunkinAssert.assertTrace(" Unknown variable \"events\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:7"); + FunkinAssert.assertTrace(" Unknown variable \"notes\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:185"); + FunkinAssert.assertTrace(" Uninitialized variable \"songName\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"timeFormat\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"timeChanges\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"playData\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + FunkinAssert.assertTrace(" Uninitialized variable \"artist\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738"); + } + + /** + * A test validating an error is thrown when attempting to parse metadata as chart data. + */ + @Test + public function testParseChartDataSwapped():Void + { + // Arrange + FunkinAssert.clearTraces(); + + // Act + var result:Null = SongRegistry.instance.parseEntryChartData("bopeebo-swapped"); + + // Assert + Assert.isNull(result); + FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped"); + FunkinAssert.assertTrace(" Unknown variable \"songName\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:3"); + FunkinAssert.assertTrace(" Unknown variable \"artist\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:4"); + FunkinAssert.assertTrace(" Unknown variable \"timeFormat\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:5"); + FunkinAssert.assertTrace(" Unknown variable \"timeChanges\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:6"); + FunkinAssert.assertTrace(" Unknown variable \"playData\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:7"); + FunkinAssert.assertTrace(" Uninitialized variable \"notes\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + FunkinAssert.assertTrace(" Uninitialized variable \"events\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + FunkinAssert.assertTrace(" Uninitialized variable \"scrollSpeed\""); + FunkinAssert.assertTrace(" at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15"); + } +} diff --git a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx new file mode 100644 index 00000000..f2d3e570 --- /dev/null +++ b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx @@ -0,0 +1,210 @@ +package funkin.play.notes.notestyle; + +import flixel.util.FlxSort; +import funkin.util.SortUtil; +import flixel.graphics.FlxGraphic; +import flixel.graphics.frames.FlxFrame; +import flixel.graphics.frames.FlxFramesCollection; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import flixel.animation.FlxAnimationController; +import openfl.utils.Assets; +import flixel.math.FlxPoint; + +@:access(funkin.play.notes.notestyle.NoteStyle) +class NoteStyleTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() + { + NoteStyleRegistry.instance.loadEntries(); + } + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testBuildNoteSprite() + { + var target:Null = NoteStyleRegistry.instance.fetchEntry("funkin"); + + Assert.isNotNull(target); + + // Arrange + var mockNoteSprite = Mockatoo.mock(NoteSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); + var scale = new FlxPoint(1, 1); // handle sprite.scale.x on the mock + + mockNoteSprite.animation = mockAnim; // Tell the mock to forward calls to the animation controller mock. + mockNoteSprite.scale.returns(scale); // Redirect this final variable to a local variable. + mockNoteSprite.antialiasing.callsRealMethod(); // Tell the mock to treat this like a normal property. + mockNoteSprite.frames.callsRealMethod(); // Tell the mock to treat this like a normal property. + + // Act + target.buildNoteSprite(mockNoteSprite); + + var expectedGraphic:FlxGraphic = FlxG.bitmap.add("shared:assets/shared/images/arrows.png"); + + // Assert + Assert.isNotNull(mockNoteSprite.frames); + mockNoteSprite.frames.frames.sort(SortUtil.byFrameName); + var frameCount:Int = mockNoteSprite.frames.frames.length; + Assert.areEqual(24, frameCount); + + // Validate each frame. + for (i in 0...frameCount) + { + var currentFrame:FlxFrame = mockNoteSprite.frames.frames[i]; + switch (i) + { + case 0: + Assert.areEqual("confirmDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 1: + Assert.areEqual("confirmDown0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 2: + Assert.areEqual("confirmLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 3: + Assert.areEqual("confirmLeft0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 4: + Assert.areEqual("confirmRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 5: + Assert.areEqual("confirmRight0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 6: + Assert.areEqual("confirmUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 7: + Assert.areEqual("confirmUp0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 8: + Assert.areEqual("noteDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 9: + Assert.areEqual("noteLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 10: + Assert.areEqual("noteRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 11: + Assert.areEqual("noteUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 12: + Assert.areEqual("pressedDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 13: + Assert.areEqual("pressedDown0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 14: + Assert.areEqual("pressedLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 15: + Assert.areEqual("pressedLeft0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 16: + Assert.areEqual("pressedRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 17: + Assert.areEqual("pressedRight0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 18: + Assert.areEqual("pressedUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 19: + Assert.areEqual("pressedUp0002", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 20: + Assert.areEqual("staticDown0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 21: + Assert.areEqual("staticLeft0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 22: + Assert.areEqual("staticRight0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + case 23: + Assert.areEqual("staticUp0001", currentFrame.name); + Assert.areEqual(expectedGraphic, currentFrame.parent); + default: + Assert.fail('Got unexpected frame number ${i}'); + } + } + + // Verify animations were applied. + @:privateAccess { + mockAnim.addByPrefix('purpleScroll', 'noteLeft', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('blueScroll', 'noteDown', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('greenScroll', 'noteUp', 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix('redScroll', 'noteRight', 24, false, false, false).verify(times(1)); + mockAnim.destroyAnimations().verify(times(1)); + mockAnim.set_frameIndex(0).verify(times(1)); + // Verify there were no other functions called. + mockAnim.verifyZeroInteractions(); + } + + // Verify sprite was initialized. + @:privateAccess { + mockNoteSprite.set_graphic(expectedGraphic).verify(times(1)); + mockNoteSprite.graphicLoaded().verify(times(1)); + mockNoteSprite.set_antialiasing(true).verify(times(1)); + mockNoteSprite.set_frames(mockNoteSprite.frames).verify(times(1)); + mockNoteSprite.set_frame(mockNoteSprite.frames.frames[21]).verify(times(1)); + mockNoteSprite.resetHelpers().verify(times(1)); + + Assert.areEqual(1, mockNoteSprite.scale.x); + Assert.areEqual(1, mockNoteSprite.scale.y); + // Verify there were no other functions called. + mockNoteSprite.verifyZeroInteractions(); + } + } + + @Test + public function testFallbackBehavior() + { + var target1:Null = NoteStyleRegistry.instance.fetchEntry("funkin"); + var target2:Null = NoteStyleRegistry.instance.fetchEntry("test2"); + + Assert.isNotNull(target1); + Assert.isNotNull(target2); + + Assert.areEqual("funkin", target1.id); + Assert.areEqual("test2", target2.id); + + Assert.areEqual("Funkin'", target1.getName()); + Assert.areEqual("Test2", target2.getName()); + + Assert.isNull(target1.getFallbackID()); + Assert.areEqual(target1.id, target2.getFallbackID()); + + // Overridden fields are different. + Assert.areEqual("arrows", target1.getNoteAssetPath(false)); + Assert.areEqual("coolstuff", target2.getNoteAssetPath(false)); + Assert.areEqual("shared:arrows", target1.getNoteAssetPath(true)); + Assert.areEqual("shared:coolstuff", target2.getNoteAssetPath(true)); + + // Unspecified fields use the fallback. + // Should NOT return null! + Assert.areEqual("assets/images/NOTE_hold_assets.png", target1.getHoldNoteAssetPath(false)); + Assert.areEqual("assets/images/NOTE_hold_assets.png", target2.getHoldNoteAssetPath(false)); + + Assert.areEqual("NOTE_hold_assets", target1.getHoldNoteAssetPath(true)); + } +} diff --git a/tests/unit/source/funkin/util/BezierUtilTest.hx b/tests/unit/source/funkin/util/BezierUtilTest.hx new file mode 100644 index 00000000..5ff2ade6 --- /dev/null +++ b/tests/unit/source/funkin/util/BezierUtilTest.hx @@ -0,0 +1,138 @@ +package funkin.util; + +import flixel.math.FlxPoint; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.BezierUtil; + +@:nullSafety +@:access(funkin.util.BezierUtil) +class BezierUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testBezier2() + { + var point1:FlxPoint = FlxPoint.get(1, 1); + var point2:FlxPoint = FlxPoint.get(2, 2); + + var result:FlxPoint = BezierUtil.bezier2(0.5, point1, point2); + + Assert.areEqual(result.x, 1.5); + Assert.areEqual(result.y, 1.5); + + result = BezierUtil.bezier2(0.25, point1, point2); + + Assert.areEqual(result.x, 1.25); + Assert.areEqual(result.y, 1.25); + + result = BezierUtil.bezier2(0.75, point1, point2); + + Assert.areEqual(result.x, 1.75); + Assert.areEqual(result.y, 1.75); + } + + @Test + public function testBezier3() + { + var point1:FlxPoint = FlxPoint.get(1, 1); + var point2:FlxPoint = FlxPoint.get(2, 2); + var point3:FlxPoint = FlxPoint.get(3, 3); + + var result:FlxPoint = BezierUtil.bezier3(0.5, point1, point2, point3); + + Assert.areEqual(result.x, 2); + Assert.areEqual(result.y, 2); + + result = BezierUtil.bezier3(0.25, point1, point2, point3); + + Assert.areEqual(result.x, 1.5); + Assert.areEqual(result.y, 1.5); + + result = BezierUtil.bezier3(0.75, point1, point2, point3); + + Assert.areEqual(result.x, 2.5); + Assert.areEqual(result.y, 2.5); + + result = BezierUtil.bezier3(0.5, point1, point2, point3); + + Assert.areEqual(result.x, 2); + Assert.areEqual(result.y, 2); + + result = BezierUtil.bezier3(0.6, point1, point2, point3); + + Assert.areEqual(result.x, 2.2); + Assert.areEqual(result.y, 2.2); + } + + @Test + public function testBezier4() + { + var point1:FlxPoint = FlxPoint.get(1, 1); + var point2:FlxPoint = FlxPoint.get(2, 2); + var point3:FlxPoint = FlxPoint.get(3, 3); + var point4:FlxPoint = FlxPoint.get(4, 4); + + var result:FlxPoint = BezierUtil.bezier4(0.5, point1, point2, point3, point4); + + Assert.areEqual(result.x, 2.5); + Assert.areEqual(result.y, 2.5); + + result = BezierUtil.bezier4(0.25, point1, point2, point3, point4); + + Assert.areEqual(result.x, 1.75); + Assert.areEqual(result.y, 1.75); + + result = BezierUtil.bezier4(0.75, point1, point2, point3, point4); + + Assert.areEqual(result.x, 3.25); + Assert.areEqual(result.y, 3.25); + } + + @Test + public function testBezier5() + { + var point1:FlxPoint = FlxPoint.get(1, 1); + var point2:FlxPoint = FlxPoint.get(2, 2); + var point3:FlxPoint = FlxPoint.get(3, 3); + var point4:FlxPoint = FlxPoint.get(4, 4); + var point5:FlxPoint = FlxPoint.get(5, 5); + + var result:FlxPoint = BezierUtil.bezier5(0.5, point1, point2, point3, point4, point5); + + Assert.areEqual(result.x, 3); + Assert.areEqual(result.y, 3); + + result = BezierUtil.bezier5(0.25, point1, point2, point3, point4, point5); + + Assert.areEqual(result.x, 2); + Assert.areEqual(result.y, 2); + + result = BezierUtil.bezier5(0.75, point1, point2, point3, point4, point5); + + Assert.areEqual(result.x, 4); + Assert.areEqual(result.y, 4); + + result = BezierUtil.bezier5(0.5, point1, point2, point3, point4, point5); + + Assert.areEqual(result.x, 3); + Assert.areEqual(result.y, 3); + } +} diff --git a/tests/unit/source/funkin/util/ClipboardUtilTest.hx b/tests/unit/source/funkin/util/ClipboardUtilTest.hx new file mode 100644 index 00000000..311160d6 --- /dev/null +++ b/tests/unit/source/funkin/util/ClipboardUtilTest.hx @@ -0,0 +1,72 @@ +package funkin.util; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.ClipboardUtil; + +@:nullSafety +@:access(funkin.util.ClipboardUtil) +class ClipboardUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testGetSetClipboard() + { + var testString = "test string"; + ClipboardUtil.setClipboard(testString); + var clipboardString = ClipboardUtil.getClipboard(); + Assert.areEqual(testString, clipboardString); + } + + @Ignore("This test doesn't work, Lime issue?") + @Test + public function testAddRemoveListener() + { + ClipboardUtil.addListener(onClipboardChange); + + var testString = "test string"; + ClipboardUtil.setClipboard(testString); + + var clipboardString = ClipboardUtil.getClipboard(); + Assert.areEqual(testString, clipboardString); + + step(); + + // TODO: Fix issue where this test fails + Assert.areEqual(1, count); + + ClipboardUtil.removeListener(onClipboardChange); + + var testString2 = "test string 2"; + ClipboardUtil.setClipboard(testString2); + + var clipboardString2 = ClipboardUtil.getClipboard(); + Assert.areEqual(testString2, clipboardString2); + + Assert.areEqual(1, count); + } + + var count:Int = 0; + + function onClipboardChange() + { + count += 1; + } +} diff --git a/tests/unit/source/funkin/util/DateUtilTest.hx b/tests/unit/source/funkin/util/DateUtilTest.hx new file mode 100644 index 00000000..35c7bab9 --- /dev/null +++ b/tests/unit/source/funkin/util/DateUtilTest.hx @@ -0,0 +1,36 @@ +package funkin.util; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.DateUtil; + +@:nullSafety +@:access(funkin.util.DateUtil) +class DateUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testGenerateTimestamp() + { + var date:Date = new Date(2020, 10 - 1, 31, 3, 0, 0); + var timestamp:String = DateUtil.generateTimestamp(date); + Assert.areEqual("2020-10-31-03-00-00", timestamp); + } +} diff --git a/tests/unit/source/funkin/util/SerializerUtilTest.hx b/tests/unit/source/funkin/util/SerializerUtilTest.hx new file mode 100644 index 00000000..6a015237 --- /dev/null +++ b/tests/unit/source/funkin/util/SerializerUtilTest.hx @@ -0,0 +1,88 @@ +package funkin.util; + +import haxe.io.Bytes; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.SerializerUtil; + +typedef FooBar = +{ + a:Int, + b:Int, + c:Int +}; + +@:nullSafety +@:access(funkin.util.SerializerUtil) +class SerializerUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testToJSON() + { + var object = {a: 1, b: 2, c: 3}; + var json = SerializerUtil.toJSON(object); + Assert.areEqual('{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}', json); + } + + @Test + public function testFromJSON() + { + var json = '{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}'; + var object = SerializerUtil.fromJSON(json); + Assert.areEqual(1, object.a); + Assert.areEqual(2, object.b); + Assert.areEqual(3, object.c); + } + + @Test + public function testFromJSONBytes() + { + var json = '{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}'; + var bytes = Bytes.ofString(json); + + var object = SerializerUtil.fromJSONBytes(bytes); + Assert.areEqual(1, object.a); + Assert.areEqual(2, object.b); + Assert.areEqual(3, object.c); + } + + @Test + public function testReplacer() + { + var version:thx.semver.Version = '1.0.0-beta'; + + Assert.areEqual(1, version.major); + Assert.areEqual(0, version.minor); + Assert.areEqual(0, version.patch); + Assert.areEqual(true, version.hasPre); + Assert.areEqual('beta', version.pre); + // Assert.areEqual(false, version.hasBuild); + Assert.areEqual('', version.build); + + var formatted = SerializerUtil.replacer('version', version); + + Assert.areEqual('1.0.0-beta', formatted); + + var test2 = SerializerUtil.toJSON({version: version}); + + Assert.areEqual('{' + '\n\t"version": "1.0.0-beta"' + '\n}', test2); + } +} diff --git a/tests/unit/source/funkin/util/SortUtilTest.hx b/tests/unit/source/funkin/util/SortUtilTest.hx new file mode 100644 index 00000000..5b868b27 --- /dev/null +++ b/tests/unit/source/funkin/util/SortUtilTest.hx @@ -0,0 +1,64 @@ +package funkin.util; + +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.util.FlxSort; +import funkin.data.song.SongData.SongNoteData; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.SortUtil; + +@:nullSafety +@:access(funkin.util.SortUtil) +class SortUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testAlphabetically() + { + var arr:Array = ["b", "a", "c"]; + + arr.sort(SortUtil.alphabetically); + + Assert.areEqual(["a", "b", "c"], arr); + } + + @Test + public function testByZIndex() + { + var arr:Array = [new FlxSprite(), new FlxObject(), new FlxSprite()]; + + arr[0].zIndex = 2000; + arr[1].zIndex = 1000; + arr[2].zIndex = 3000; + + arr.sort(SortUtil.byZIndex.bind(FlxSort.ASCENDING)); + + Assert.areEqual(1000, arr[0].zIndex); + Assert.areEqual(2000, arr[1].zIndex); + Assert.areEqual(3000, arr[2].zIndex); + + arr.sort(SortUtil.byZIndex.bind(FlxSort.DESCENDING)); + + Assert.areEqual(3000, arr[0].zIndex); + Assert.areEqual(2000, arr[1].zIndex); + Assert.areEqual(1000, arr[2].zIndex); + } +} diff --git a/tests/unit/source/funkin/util/VersionUtilTest.hx b/tests/unit/source/funkin/util/VersionUtilTest.hx new file mode 100644 index 00000000..517b37e5 --- /dev/null +++ b/tests/unit/source/funkin/util/VersionUtilTest.hx @@ -0,0 +1,82 @@ +package funkin.util; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.VersionUtil; + +@:access(funkin.util.VersionUtil) +class VersionUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testValidateVersionStr() + { + Assert.areEqual(true, VersionUtil.validateVersionStr("1.0.0", "1.0.0")); + + Assert.areEqual(false, VersionUtil.validateVersionStr("ehe", "test")); + } + + @Test + public function testValidateVersion() + { + var version:thx.semver.Version = "1.0.0"; // implicit cast + var versionRule:thx.semver.VersionRule = "1.0.0"; // implicit cast + + Assert.areEqual(true, VersionUtil.validateVersion(version, versionRule)); + + var versionRule2:thx.semver.VersionRule = ">=3.1.0"; // implicit cast + + var version1:thx.semver.Version = "3.0.0"; + var version2:thx.semver.Version = "3.1.1"; + var version3:thx.semver.Version = "4.2.0"; + + Assert.areEqual(false, VersionUtil.validateVersion(version1, versionRule2)); + Assert.areEqual(true, VersionUtil.validateVersion(version2, versionRule2)); + Assert.areEqual(true, VersionUtil.validateVersion(version3, versionRule2)); + } + + @Test + public function testGetVersionFromJSON() + { + var jsonStr:String = "{ \"version\": \"3.1.0\" }"; + + var version:Null = VersionUtil.getVersionFromJSON(jsonStr); + + Assert.isNotNull(version); + + Assert.areEqual("3.1.0", version.toString()); + } + + @Test + public function testGetVersionFromJSONBad() + { + var jsonStr:String = "{ \"version\": \"bleh\" }"; + + Assert.throws(String, function() { + var version:Null = VersionUtil.getVersionFromJSON(jsonStr); + }); + + var jsonStr2:String = "{ \"blah\": \"3.1.0\" }"; + + var version2:Null = VersionUtil.getVersionFromJSON(jsonStr2); + + Assert.isNull(version2); + } +} diff --git a/tests/unit/source/funkin/util/assets/DataAssetsTest.hx b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx new file mode 100644 index 00000000..b3df036e --- /dev/null +++ b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx @@ -0,0 +1,43 @@ +package funkin.util.assets; + +import openfl.utils.Assets; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.assets.DataAssets; + +@:nullSafety +@:access(funkin.util.assets.DataAssets) +class DataAssetsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testBuildDataPath() + { + Assert.areEqual('assets/data/test.json', DataAssets.buildDataPath('test.json')); + } + + @Test + public function listDataFilesInPath() + { + var expected = ['blablabla', 'test1', 'test2']; + + Assert.areEqual(expected, DataAssets.listDataFilesInPath('test/')); + } +} diff --git a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx new file mode 100644 index 00000000..8adfd356 --- /dev/null +++ b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx @@ -0,0 +1,119 @@ +package funkin.util.assets; + +import funkin.util.assets.FlxAnimationUtil; +import flixel.animation.FlxAnimationController; +import funkin.data.animation.AnimationData; +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.DateUtil; +import flixel.FlxSprite; + +@:nullSafety +@:access(funkin.util.assets.FlxAnimationUtil) +class FlxAnimationUtilTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testAddAtlasAnimation() + { + // Build a mock child class of FlxSprite + var mockSprite = Mockatoo.mock(FlxSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); + mockSprite.animation = mockAnim; + + var animData:AnimationData = + { + name: "testAnim", + prefix: "blablabla" + }; + + FlxAnimationUtil.addAtlasAnimation(mockSprite, animData); + + // Verify that the method was called once. + // If not, a VerificationException will be thrown and the test will fail. + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); + + // Verify there were no other functions called. + mockAnim.verifyZeroInteractions(); + mockSprite.verifyZeroInteractions(); + + var animData2:AnimationData = + { + name: "testAnim2", + prefix: "blablabla2", + frameIndices: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + frameRate: 12, + flipX: true, + flipY: true, + looped: true + }; + + FlxAnimationUtil.addAtlasAnimation(mockSprite, animData2); + + try + { + mockAnim.addByIndices("testAnim2", "blablabla2", cast anyIterator, "", 12, true, true, true).verify(times(1)); + } + catch (e) + { + trace('CAUGHT EXCEPTION'); + trace(e); + } + + mockAnim.verifyZeroInteractions(); + mockSprite.verifyZeroInteractions(); + } + + @Test + public function testAddAtlasAnimations() + { + // Build a mock child class of FlxSprite + var mockSprite = Mockatoo.mock(FlxSprite); + var mockAnim = Mockatoo.mock(FlxAnimationController); + mockSprite.animation = mockAnim; + + var animData:Array = [ + { + name: "testAnim", + prefix: "blablabla" + }, + { + name: "testAnim2", + prefix: "blablabla2" + }, + { + name: "testAnim3", + prefix: "blablabla3" + } + ]; + + FlxAnimationUtil.addAtlasAnimations(mockSprite, animData); + + // Verify that the method was called once. + // If not, a VerificationException will be thrown and the test will fail. + mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix("testAnim2", "blablabla2", 24, false, false, false).verify(times(1)); + mockAnim.addByPrefix("testAnim3", "blablabla3", 24, false, false, false).verify(times(1)); + + // Verify there were no other functions called. + mockAnim.verifyZeroInteractions(); + mockSprite.verifyZeroInteractions(); + } +} diff --git a/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx new file mode 100644 index 00000000..b9518151 --- /dev/null +++ b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx @@ -0,0 +1,189 @@ +package funkin.util.tools; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.tools.ArrayTools; + +@:nullSafety +@:access(funkin.util.tools.ArrayTools) +class ArraySortToolsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testMergeSort() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + ArraySortTools.mergeSort(testArray, compare); + + Assert.areEqual(testArray[0], 1); + Assert.areEqual(testArray[1], 2); + Assert.areEqual(testArray[2], 3); + Assert.areEqual(testArray[3], 4); + Assert.areEqual(testArray[4], 5); + + var testArray2:Array = [9, 6, 3, 12]; + + ArraySortTools.mergeSort(testArray2, compare); + + Assert.areEqual(testArray2[0], 3); + Assert.areEqual(testArray2[1], 6); + Assert.areEqual(testArray2[2], 9); + Assert.areEqual(testArray2[3], 12); + + // Just make sure these don't crash. + ArraySortTools.mergeSort([], compare); + } + + @Test + @:nullSafety(Off) + public function testMergeSortNull() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + // Just make sure these don't crash. + ArraySortTools.mergeSort(null, compare); + ArraySortTools.mergeSort([], null); + ArraySortTools.mergeSort(null, null); + + // Make sure these throw an exception. + FunkinAssert.validateThrows(function() { + ArraySortTools.mergeSort(testArray, null); + }, function(err) { + return err == 'No comparison function provided.'; + }); + } + + @Test + public function testQuickSort() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + ArraySortTools.quickSort(testArray, compare); + + Assert.areEqual(testArray[0], 1); + Assert.areEqual(testArray[1], 2); + Assert.areEqual(testArray[2], 3); + Assert.areEqual(testArray[3], 4); + Assert.areEqual(testArray[4], 5); + + var testArray2:Array = [9, 6, 3, 12]; + + ArraySortTools.quickSort(testArray2, compare); + + Assert.areEqual(testArray2[0], 3); + Assert.areEqual(testArray2[1], 6); + Assert.areEqual(testArray2[2], 9); + Assert.areEqual(testArray2[3], 12); + } + + @Test + @:nullSafety(Off) + public function testQuickSortNull() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + // Just make sure these don't crash. + ArraySortTools.quickSort([], compare); + ArraySortTools.quickSort(null, compare); + ArraySortTools.quickSort([], null); + ArraySortTools.quickSort(null, null); + + // Make sure these throw an exception. + FunkinAssert.validateThrows(function() { + ArraySortTools.quickSort(testArray, null); + }, function(err) { + return err == 'No comparison function provided.'; + }); + } + + @Test + public function testInsertionSort() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + ArraySortTools.insertionSort(testArray, compare); + + Assert.areEqual(testArray[0], 1); + Assert.areEqual(testArray[1], 2); + Assert.areEqual(testArray[2], 3); + Assert.areEqual(testArray[3], 4); + Assert.areEqual(testArray[4], 5); + + var testArray2:Array = [9, 6, 3, 12]; + + ArraySortTools.insertionSort(testArray2, compare); + + Assert.areEqual(testArray2[0], 3); + Assert.areEqual(testArray2[1], 6); + Assert.areEqual(testArray2[2], 9); + Assert.areEqual(testArray2[3], 12); + } + + @Test + @:nullSafety(Off) + public function testInsertionSortNull() + { + var testArray:Array = [5, 4, 3, 2, 1]; + + function compare(a:Int, b:Int) + { + return a - b; + } + + // Just make sure these don't crash. + ArraySortTools.insertionSort([], compare); + ArraySortTools.insertionSort(null, compare); + ArraySortTools.insertionSort([], null); + ArraySortTools.insertionSort(null, null); + + // Make sure these throw an exception. + FunkinAssert.validateThrows(function() { + ArraySortTools.insertionSort(testArray, null); + }, function(err) { + return err == 'No comparison function provided.'; + }); + } +} diff --git a/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx new file mode 100644 index 00000000..df8d364f --- /dev/null +++ b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx @@ -0,0 +1,66 @@ +package funkin.util.tools; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.tools.ArrayTools; + +@:nullSafety +@:access(funkin.util.tools.ArrayTools) +class ArrayToolsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testUnique() + { + var testArray:Array = [1, 2, 3, 4, 5, 6, 10, 7, 8, 9, 3, 4, 5, 6, 11, 7, 8]; + + var uniqueArray:Array = ArrayTools.unique(testArray); + + Assert.areEqual(uniqueArray.length, 11); + + // Array order doesn't change + Assert.areEqual(uniqueArray[0], 1); + Assert.areEqual(uniqueArray[1], 2); + Assert.areEqual(uniqueArray[2], 3); + Assert.areEqual(uniqueArray[3], 4); + Assert.areEqual(uniqueArray[4], 5); + Assert.areEqual(uniqueArray[5], 6); + Assert.areEqual(uniqueArray[6], 10); + Assert.areEqual(uniqueArray[7], 7); + Assert.areEqual(uniqueArray[8], 8); + Assert.areEqual(uniqueArray[9], 9); + Assert.areEqual(uniqueArray[10], 11); + } + + @Test + public function testFind() + { + function predicate(a:String):Bool + { + return a.startsWith("Hello"); + } + + var testArray:Array = ["Foo", "Bar", "HelloWorld", "Baz", "HelloTest"]; + + var result = ArrayTools.find(testArray, predicate); + + Assert.areEqual(result, "HelloWorld"); + } +} diff --git a/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx new file mode 100644 index 00000000..2c5a406c --- /dev/null +++ b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx @@ -0,0 +1,60 @@ +package funkin.util.tools; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.tools.IteratorTools; + +@:nullSafety +@:access(funkin.util.tools.IteratorTools) +class IteratorToolsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testArray() + { + var iter = new MyStringIterator("HelloWorld"); + + var arr = IteratorTools.array(iter); + + Assert.areEqual(["H", "e", "l", "l", "o", "W", "o", "r", "l", "d"], arr); + } +} + +class MyStringIterator +{ + var s:String; + var i:Int; + + public function new(s:String) + { + this.s = s; + i = 0; + } + + public function hasNext() + { + return i < s.length; + } + + public function next() + { + return s.charAt(i++); + } +} diff --git a/tests/unit/source/funkin/util/tools/MapToolsTest.hx b/tests/unit/source/funkin/util/tools/MapToolsTest.hx new file mode 100644 index 00000000..1ada408b --- /dev/null +++ b/tests/unit/source/funkin/util/tools/MapToolsTest.hx @@ -0,0 +1,56 @@ +package funkin.util.tools; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.tools.MapTools; + +@:nullSafety +@:access(funkin.util.tools.MapTools) +class MapToolsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testSize() + { + var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"]; + + Assert.areEqual(3, MapTools.size(testMap)); + } + + @Test + public function testValues() + { + var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"]; + + var result:Array = MapTools.values(testMap); + result.sort(SortUtil.alphabetically); + Assert.areEqual(["value1", "value2", "value3"], result); + } + + @Test + public function testKeyValues() + { + var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"]; + + var result:Array = MapTools.keyValues(testMap); + result.sort(SortUtil.alphabetically); + Assert.areEqual(["key1", "key2", "key3"], result); + } +} diff --git a/tests/unit/source/funkin/util/tools/StringToolsTest.hx b/tests/unit/source/funkin/util/tools/StringToolsTest.hx new file mode 100644 index 00000000..ff2ee2db --- /dev/null +++ b/tests/unit/source/funkin/util/tools/StringToolsTest.hx @@ -0,0 +1,60 @@ +package funkin.util.tools; + +import massive.munit.util.Timer; +import massive.munit.Assert; +import massive.munit.async.AsyncFactory; +import funkin.util.tools.StringTools; + +@:nullSafety +@:access(funkin.util.tools.StringTools) +class StringToolsTest extends FunkinTest +{ + public function new() + { + super(); + } + + @BeforeClass + public function beforeClass() {} + + @AfterClass + public function afterClass() {} + + @Before + public function setup() {} + + @After + public function tearDown() {} + + @Test + public function testToTitleCase() + { + var input = "hello world"; + + Assert.areEqual("Hello World", StringTools.toTitleCase(input)); + } + + @Test + public function testToLowerKebabCase() + { + var input = "hello world"; + + Assert.areEqual("hello-world", StringTools.toLowerKebabCase(input)); + } + + @Test + public function testToUpperKebabCase() + { + var input = "hello world"; + + Assert.areEqual("HELLO-WORLD", StringTools.toUpperKebabCase(input)); + } + + @Test + public function testParseJSON() + { + var input = "{ \"hello\": \"world\" }"; + + Assert.areEqual({hello: "world"}, StringTools.parseJSON(input)); + } +} diff --git a/tests/unit/source/import.hx b/tests/unit/source/import.hx new file mode 100644 index 00000000..89264c0c --- /dev/null +++ b/tests/unit/source/import.hx @@ -0,0 +1,21 @@ +#if !macro +// Only import these when we aren't in a macro. +import funkin.util.Constants; +import funkin.Paths; +import flixel.FlxG; // This one in particular causes a compile error if you're using macros. + +// These are great. +using Lambda; +using StringTools; +using funkin.util.tools.ArrayTools; +using funkin.util.tools.ArraySortTools; +using funkin.util.tools.IteratorTools; +using funkin.util.tools.MapTools; +using funkin.util.tools.StringTools; +#end + +// Testing-specific +// Mocking +import mockatoo.Mockatoo.*; + +using mockatoo.Mockatoo; diff --git a/tests/unit/start-linux-native.sh b/tests/unit/start-linux-native.sh new file mode 100644 index 00000000..5110b410 --- /dev/null +++ b/tests/unit/start-linux-native.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +haxe test-cpp.hxml diff --git a/tests/unit/start-linux-web.sh b/tests/unit/start-linux-web.sh new file mode 100644 index 00000000..68e785b9 --- /dev/null +++ b/tests/unit/start-linux-web.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +haxe test-web.hxml diff --git a/tests/unit/start-mac-native.sh b/tests/unit/start-mac-native.sh new file mode 100644 index 00000000..fb6c812b --- /dev/null +++ b/tests/unit/start-mac-native.sh @@ -0,0 +1,3 @@ +#!/bin/zsh + +haxe test-cpp.hxml diff --git a/tests/unit/start-mac-web.sh b/tests/unit/start-mac-web.sh new file mode 100644 index 00000000..66af469f --- /dev/null +++ b/tests/unit/start-mac-web.sh @@ -0,0 +1,3 @@ +#!/bin/zsh + +haxe test-web.hxml diff --git a/tests/unit/start-win-native.bat b/tests/unit/start-win-native.bat new file mode 100644 index 00000000..7678a86e --- /dev/null +++ b/tests/unit/start-win-native.bat @@ -0,0 +1,3 @@ +REM Launches the unit tests for the native target on Windows. + +haxe test-cpp.hxml diff --git a/tests/unit/start-win-web.bat b/tests/unit/start-win-web.bat new file mode 100644 index 00000000..27664f96 --- /dev/null +++ b/tests/unit/start-win-web.bat @@ -0,0 +1,3 @@ +REM Launches the unit tests for the native target on Windows. + +haxe test-web.hxml diff --git a/tests/unit/test-cpp.hxml b/tests/unit/test-cpp.hxml new file mode 100644 index 00000000..e25d9253 --- /dev/null +++ b/tests/unit/test-cpp.hxml @@ -0,0 +1,6 @@ +# Updates TestSuite.hx to include all tests +-cmd haxelib run munit gen +# Actually performs the tests +# Lime is used for compatibility reasons, and build flags in `project.xml` ensure coverage is enabled +-cmd haxelib run lime test cpp -debug + diff --git a/tests/unit/test-web.hxml b/tests/unit/test-web.hxml new file mode 100644 index 00000000..c7c2ab44 --- /dev/null +++ b/tests/unit/test-web.hxml @@ -0,0 +1,5 @@ +# Update the test runner classes +# TODO: This just opens the browser and doesn't do anything? +-cmd haxelib run munit gen +-cmd haxelib run lime build html5 +-cmd haxelib run lime test html5 diff --git a/tests/unit/test.hxml b/tests/unit/test.hxml new file mode 100644 index 00000000..cece0c43 --- /dev/null +++ b/tests/unit/test.hxml @@ -0,0 +1,32 @@ +## CPP +--next +-main TestMain +-cpp build/cpp_test +# Funkin' deps +-lib lime +-lib openfl +-lib flixel +-lib flixel-addons +-lib flixel-ui +-lib hscript +-lib polymod +-lib haxeui-core +-lib haxeui-flixel +-lib flxanimate +-lib hxCodec +-lib thx.semver +-lib json2object +-lib tink_json +# Test deps +-lib munit +-lib hamcrest +-lib mcover +-lib mockatoo +# Class paths +-cp source +-cp ../../source +# Flixel macros +--remap flash:openfl +--macro flixel.system.macros.FlxDefines.run() +# Funkin' macros +--macro addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic') diff --git a/tests/unit/test.hxml-old b/tests/unit/test.hxml-old new file mode 100644 index 00000000..e8a7d9bd --- /dev/null +++ b/tests/unit/test.hxml-old @@ -0,0 +1,60 @@ +## CPP +--next +--verbose +--debug +-main TestMain +-cpp build/cpp_test +# Funkin' deps +-lib lime +-lib openfl +-lib flixel +-lib flixel-addons +-lib flixel-ui +-lib hscript +-lib polymod +-lib haxeui-core +-lib haxeui-flixel +-lib flxanimate +-lib hxCodec +-lib thx.semver +-lib json2object +-lib tink_json +# Test deps +-lib munit +-lib hamcrest +-lib mcover +-lib mockatoo +# Class paths +-cp source +-cp ../../source +# Flixel macros +--remap flash:openfl +--macro flixel.system.macros.FlxDefines.run() +# Funkin' macros +--macro addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic') + + +## JavaScript HTML5 +--next +-js build/js_test.js +# Funkin' deps +-lib lime +-lib openfl +-lib flixel +-lib flixel-addons +-lib hscript +-lib flixel-ui +-lib haxeui-core +-lib haxeui-flixel +-lib polymod +-lib flxanimate +-lib hxCodec +-lib thx.semver +-lib json2object +-lib tink_json +# Test deps +-lib munit +-lib hamcrest +# Class paths +-cp source +-cp ../../source