diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml new file mode 100644 index 000000000..008830842 --- /dev/null +++ b/.github/actions/setup-haxeshit/action.yml @@ -0,0 +1,18 @@ +name: setup-haxeshit +description: "sets up haxe shit, using HMM!" +runs: + using: "composite" + steps: + - uses: krdlab/setup-haxe@v1.1.6 + with: + haxe-version: 4.2.4 + - name: Config haxelib + run: | + haxelib config + shell: bash + - name: Installing Haxe lol + run: | + haxe -version + haxelib --global install hmm + haxelib --global run hmm install --quiet + shell: bash \ No newline at end of file diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml new file mode 100644 index 000000000..5abc31b16 --- /dev/null +++ b/.github/actions/upload-itch/action.yml @@ -0,0 +1,44 @@ +name: upload-itch +description: "installs Butler, and uploads to itch.io!" +inputs: + butler-key: + description: "Butler API secret key" + required: true + build-dir: + description: "Directory of the game build" + required: true + target: + description: "Target (html5, win, linux, mac)" + required: true +runs: + using: "composite" + steps: + - name: Install butler Windows + if: runner.os == 'Windows' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default + 7z x butler.zip + ./butler -v + shell: bash + - name: Install butler Mac + if: runner.os == 'macOS' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default + unzip butler.zip + ./butler -V + shell: bash + - name: Install butler Linux + if: runner.os == 'Linux' + run: | + curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default + unzip butler.zip + chmod +x butler + ./butler -V + shell: bash + - name: Upload game to itch.io + env: + BUTLER_API_KEY: ${{inputs.butler-key}} + run: | + ./butler login + ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF##*/} + shell: bash \ No newline at end of file diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml new file mode 100644 index 000000000..abbacc7d3 --- /dev/null +++ b/.github/workflows/build-shit.yml @@ -0,0 +1,63 @@ +name: build-upload +on: [push, workflow_dispatch] +jobs: + create-nightly-html5: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/setup-haxeshit + - name: Build game? + run: | + haxelib run lime build html5 -debug + ls + - uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + build-dir: export/debug/html5/bin + target: html5 + create-nightly-win: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/setup-haxeshit + - name: Build game + run: | + haxelib run lime build windows -debug + dir + - uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + build-dir: export/debug/windows/bin + target: win + create-nightly-mac: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/setup-haxeshit + - name: Build game? + run: | + haxelib run lime build mac -debug + ls + - uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + build-dir: export/debug/macos/bin + target: mac + create-nightly-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/setup-haxeshit + - name: Setting up Linux + run: | + haxelib run lime setup linux + - name: Build game? + run: | + haxelib run lime build linux -debug + ls + - uses: ./.github/actions/upload-itch + with: + butler-key: ${{ secrets.BUTLER_API_KEY}} + build-dir: export/debug/linux/bin + target: linux + diff --git a/.github/workflows/learn-github-actions.yml b/.github/workflows/learn-github-actions.yml deleted file mode 100644 index 44702b170..000000000 --- a/.github/workflows/learn-github-actions.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: learn-github-actions -on: [push] -jobs: - create-nightly-linux: - runs-on: ubuntu-latest - steps: - - uses: krdlab/setup-haxe@v1.1.6 - with: - haxe-version: 4.2.4 - - uses: actions/checkout@v2 - - name: Cache Haxe Stuff - run: | - haxelib config - - name: Installing Haxe Stuff - run: | - haxe -version - haxelib install openfl --quiet - haxelib install lime --quiet - haxelib install flixel --quiet - haxelib install flixel-addons --quiet - haxelib install hscript --quiet - haxelib install flixel-ui --quiet - haxelib install firetongue --quiet - haxelib install hxcpp-debug-server --quiet - haxelib install hxcpp --quiet - haxelib git polymod https://github.com/larsiusprime/polymod develop --quiet - haxelib run lime setup linux - - name: Build game? - run: | - haxelib run lime build html5 -debug -clean - ls - - name: Install butler and shit - run: | - curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default - unzip butler.zip - chmod +x butler - ./butler -V - - name: Upload game to itch.io - env: - BUTLER_API_KEY: ${{ secrets.BUTLER_API_KEY}} - run: | - ./butler login - ./butler push export/debug/html5/bin ninja-muffin24/funkin-secret:html5 - - uses: actions/upload-artifact@v1 - with: - name: funkinSecret-${{ runner.os }}-${{ github.sha }} - path: export/debug/html5/bin - diff --git a/.gitignore b/.gitignore index 8a4d131df..492e1d0c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ export/ .vscode/ APIStuff.hx .DS_STORE -RECOVER_*.fla \ No newline at end of file +RECOVER_*.fla +.haxelib/ \ No newline at end of file diff --git a/Project.xml b/Project.xml index 89402c2eb..81f017853 100644 --- a/Project.xml +++ b/Project.xml @@ -128,6 +128,7 @@ + diff --git a/README.md b/README.md index d868c6574..df348d7f2 100644 --- a/README.md +++ b/README.md @@ -39,27 +39,21 @@ First you need to install Haxe and HaxeFlixel. I'm too lazy to write and keep up 1. [Install Haxe 4.1.5](https://haxe.org/download/version/4.1.5/) (Download 4.1.5 instead of 4.2.0 because 4.2.0 is broken and is not working with gits properly...) 2. [Install HaxeFlixel](https://haxeflixel.com/documentation/install-haxeflixel/) after downloading Haxe -Other installations you'd need is the additional libraries, a fully updated list will be in `Project.xml` in the project root. Currently, these are all of the things you need to install: +Other installations you'd need is the additional libraries, a fully updated list will be in `hmm.json` in the project root. Currently, these are all of the things you need to install: ``` -flixel -flixel-addons -flixel-ui -hscript -newgrounds +haxelib --global install hmm +haxelib --global run hmm setup +hmm install ``` -So for each of those type `haxelib install [library]` so shit like `haxelib install newgrounds` -You'll also need to install a couple things that involve Gits. To do this, you need to do a few things first. + You should have everything ready for compiling the game! Follow the guide below to continue! -At the moment, you can optionally fix the transition bug in songs with zoomed out cameras. -- Run `haxelib git flixel-addons https://github.com/HaxeFlixel/flixel-addons` in the terminal/command-prompt. - ### Ignored files I gitignore the API keys for the game, so that no one can nab them and post fake highscores on the leaderboards. But because of that the game diff --git a/example_mods/introMod/_append/data/introText.txt b/example_mods/introMod/_append/data/introText.txt deleted file mode 100644 index 45e0c08ab..000000000 --- a/example_mods/introMod/_append/data/introText.txt +++ /dev/null @@ -1 +0,0 @@ -swagshit--moneymoney \ No newline at end of file diff --git a/example_mods/introMod/images/gfDanceTitle.png b/example_mods/introMod/images/gfDanceTitle.png deleted file mode 100644 index 989f2a68a..000000000 Binary files a/example_mods/introMod/images/gfDanceTitle.png and /dev/null differ diff --git a/hmm.json b/hmm.json new file mode 100644 index 000000000..984f54166 --- /dev/null +++ b/hmm.json @@ -0,0 +1,74 @@ +{ + "dependencies": [ + { + "name": "discord_rpc", + "type": "git", + "dir": null, + "ref": "2d83fa8", + "url": "https://github.com/Aidan63/linc_discord-rpc" + }, + { + "name": "firetongue", + "type": "git", + "dir": null, + "ref": "c5666c8", + "url": "https://github.com/larsiusprime/firetongue" + }, + { + "name": "flixel", + "type": "git", + "dir": null, + "ref": "93a049d6", + "url": "https://github.com/haxeflixel/flixel" + }, + { + "name": "flixel-addons", + "type": "haxelib", + "version": "2.11.0" + }, + { + "name": "flixel-ui", + "type": "haxelib", + "version": "2.4.0" + }, + { + "name": "hscript", + "type": "git", + "dir": null, + "ref": "a1b7f74", + "url": "https://github.com/mastereric/hscript" + }, + { + "name": "hxcpp", + "type": "haxelib", + "version": "4.2.1" + }, + { + "name": "hxcpp-debug-server", + "type": "haxelib", + "version": "1.2.4" + }, + { + "name": "lime", + "type": "haxelib", + "version": "7.9.0" + }, + { + "name": "openfl", + "type": "haxelib", + "version": "9.1.0" + }, + { + "name": "polymod", + "type": "git", + "dir": null, + "ref": "c858b48", + "url": "https://github.com/larsiusprime/polymod" + }, + { + "name": "thx.semver", + "type": "haxelib", + "version": "0.2.2" + } + ] +} \ No newline at end of file diff --git a/source/funkin/Boyfriend.hx b/source/funkin/Boyfriend.hx deleted file mode 100644 index 12bf7553e..000000000 --- a/source/funkin/Boyfriend.hx +++ /dev/null @@ -1,43 +0,0 @@ -package funkin; - -import flixel.FlxSprite; -import flixel.graphics.frames.FlxAtlasFrames; -import flixel.util.FlxTimer; - -using StringTools; - -class Boyfriend extends Character -{ - // public var stunned:Bool = false; - public function new(x:Float, y:Float, ?char:String = 'bf') - { - super(x, y, char, true); - } - - public var startedDeath:Bool = false; - - override function update(elapsed:Float) - { - if (!debugMode) - { - if (animation.curAnim.name.startsWith('sing')) - { - holdTimer += elapsed; - } - else - holdTimer = 0; - - if (animation.curAnim.name.endsWith('miss') && animation.curAnim.finished && !debugMode) - { - playAnim('idle', true, false, 10); - } - - if (animation.curAnim.name == 'firstDeath' && animation.curAnim.finished && startedDeath) - { - playAnim('deathLoop'); - } - } - - super.update(elapsed); - } -} diff --git a/source/funkin/Character.hx b/source/funkin/Character.hx deleted file mode 100644 index a81d599a6..000000000 --- a/source/funkin/Character.hx +++ /dev/null @@ -1,766 +0,0 @@ -package funkin; - -import funkin.util.Constants; -import funkin.Note.NoteData; -import funkin.SongLoad.SwagSong; -import funkin.Section.SwagSection; -import flixel.FlxSprite; -import flixel.animation.FlxBaseAnimation; -import flixel.graphics.frames.FlxAtlasFrames; -import flixel.util.FlxSort; -import haxe.io.Path; -import funkin.play.PlayState; - -using StringTools; - -class Character extends FlxSprite -{ - public var animOffsets:Map>; - public var debugMode:Bool = false; - - public var isPlayer:Bool = false; - public var curCharacter:String = 'bf'; - - public var holdTimer:Float = 0; - - public var animationNotes:Array = []; - - public function new(x:Float, y:Float, ?character:String = "bf", ?isPlayer:Bool = false) - { - super(x, y); - - animOffsets = new Map>(); - curCharacter = character; - this.isPlayer = isPlayer; - - var tex:FlxAtlasFrames; - antialiasing = true; - - switch (curCharacter) - { - case 'gf': - // GIRLFRIEND CODE - tex = Paths.getSparrowAtlas('characters/GF_assets'); - frames = tex; - quickAnimAdd('cheer', 'GF Cheer'); - quickAnimAdd('singLEFT', 'GF left note'); - quickAnimAdd('singRIGHT', 'GF Right Note'); - quickAnimAdd('singUP', 'GF Up Note'); - quickAnimAdd('singDOWN', 'GF Down Note'); - animation.addByIndices('sad', 'gf sad', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "", 24, true); - animation.addByIndices('danceLeft', 'GF Dancing Beat', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'GF Dancing Beat', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); - animation.addByIndices('hairBlow', "GF Dancing Beat Hair blowing", [0, 1, 2, 3], "", 24); - animation.addByIndices('hairFall', "GF Dancing Beat Hair Landing", [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "", 24, false); - animation.addByPrefix('scared', 'GF FEAR', 24, true); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - - case 'gf-christmas': - tex = Paths.getSparrowAtlas('characters/gfChristmas'); - frames = tex; - quickAnimAdd('cheer', 'GF Cheer'); - quickAnimAdd('singLEFT', 'GF left note'); - quickAnimAdd('singRIGHT', 'GF Right Note'); - quickAnimAdd('singUP', 'GF Up Note'); - quickAnimAdd('singDOWN', 'GF Down Note'); - animation.addByIndices('sad', 'gf sad', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "", 24, false); - animation.addByIndices('danceLeft', 'GF Dancing Beat', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'GF Dancing Beat', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); - animation.addByIndices('hairBlow', "GF Dancing Beat Hair blowing", [0, 1, 2, 3], "", 24); - animation.addByIndices('hairFall', "GF Dancing Beat Hair Landing", [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "", 24, false); - animation.addByPrefix('scared', 'GF FEAR', 24, true); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - case 'gf-tankmen': - frames = Paths.getSparrowAtlas('characters/gfTankmen'); - animation.addByIndices('sad', 'GF Crying at Gunpoint', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], "", 24, true); - animation.addByIndices('danceLeft', 'GF Dancing at Gunpoint', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'GF Dancing at Gunpoint', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); - - loadOffsetFile('gf'); - playAnim('danceRight'); - - case 'bf-holding-gf': - frames = Paths.getSparrowAtlas('characters/bfAndGF'); - quickAnimAdd('idle', 'BF idle dance'); - quickAnimAdd('singDOWN', 'BF NOTE DOWN0'); - quickAnimAdd('singLEFT', 'BF NOTE LEFT0'); - quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0'); - quickAnimAdd('singUP', 'BF NOTE UP0'); - - quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS'); - quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS'); - quickAnimAdd('singUPmiss', 'BF NOTE UP MISS'); - quickAnimAdd('bfCatch', 'BF catches GF'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - - case 'gf-car': - tex = Paths.getSparrowAtlas('characters/gfCar'); - frames = tex; - animation.addByIndices('singUP', 'GF Dancing Beat Hair blowing CAR', [0], "", 24, false); - animation.addByIndices('danceLeft', 'GF Dancing Beat Hair blowing CAR', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'GF Dancing Beat Hair blowing CAR', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, - false); - animation.addByIndices('idleHair', 'GF Dancing Beat Hair blowing CAR', [10, 11, 12, 25, 26, 27], "", 24, true); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - - case 'gf-pixel': - tex = Paths.getSparrowAtlas('characters/gfPixel'); - frames = tex; - animation.addByIndices('singUP', 'GF IDLE', [2], "", 24, false); - animation.addByIndices('danceLeft', 'GF IDLE', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'GF IDLE', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - - setGraphicSize(Std.int(width * Constants.PIXEL_ART_SCALE)); - updateHitbox(); - antialiasing = false; - - case 'dad': - // DAD ANIMATION LOADING CODE - tex = Paths.getSparrowAtlas('characters/DADDY_DEAREST'); - frames = tex; - quickAnimAdd('idle', 'Dad idle dance'); - quickAnimAdd('singUP', 'Dad Sing Note UP'); - quickAnimAdd('singRIGHT', 'Dad Sing Note RIGHT'); - quickAnimAdd('singDOWN', 'Dad Sing Note DOWN'); - quickAnimAdd('singLEFT', 'Dad Sing Note LEFT'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - case 'spooky': - tex = Paths.getSparrowAtlas('characters/spooky_kids_assets'); - frames = tex; - quickAnimAdd('singUP', 'spooky UP NOTE'); - quickAnimAdd('singDOWN', 'spooky DOWN note'); - quickAnimAdd('singLEFT', 'note sing left'); - quickAnimAdd('singRIGHT', 'spooky sing right'); - animation.addByIndices('danceLeft', 'spooky dance idle', [0, 2, 6], "", 12, false); - animation.addByIndices('danceRight', 'spooky dance idle', [8, 10, 12, 14], "", 12, false); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - case 'mom': - tex = Paths.getSparrowAtlas('characters/Mom_Assets'); - frames = tex; - - quickAnimAdd('idle', "Mom Idle"); - quickAnimAdd('singUP', "Mom Up Pose"); - quickAnimAdd('singDOWN', "MOM DOWN POSE"); - quickAnimAdd('singLEFT', 'Mom Left Pose'); - // ANIMATION IS CALLED MOM LEFT POSE BUT ITS FOR THE RIGHT - // CUZ DAVE IS DUMB! - quickAnimAdd('singRIGHT', 'Mom Pose Left'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - case 'mom-car': - tex = Paths.getSparrowAtlas('characters/momCar'); - frames = tex; - - quickAnimAdd('idle', "Mom Idle"); - quickAnimAdd('singUP', "Mom Up Pose"); - quickAnimAdd('singDOWN', "MOM DOWN POSE"); - quickAnimAdd('singLEFT', 'Mom Left Pose'); - // ANIMATION IS CALLED MOM LEFT POSE BUT ITS FOR THE RIGHT - // CUZ DAVE IS DUMB! - quickAnimAdd('singRIGHT', 'Mom Pose Left'); - animation.addByIndices('idleHair', "Mom Idle", [10, 11, 12, 13], "", 24, true); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - case 'monster': - tex = Paths.getSparrowAtlas('characters/Monster_Assets'); - frames = tex; - quickAnimAdd('idle', 'monster idle'); - quickAnimAdd('singUP', 'monster up note'); - quickAnimAdd('singDOWN', 'monster down'); - quickAnimAdd('singLEFT', 'Monster left note'); - quickAnimAdd('singRIGHT', 'Monster Right note'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - case 'monster-christmas': - tex = Paths.getSparrowAtlas('characters/monsterChristmas'); - frames = tex; - quickAnimAdd('idle', 'monster idle'); - quickAnimAdd('singUP', 'monster up note'); - quickAnimAdd('singDOWN', 'monster down'); - quickAnimAdd('singLEFT', 'Monster left note'); - quickAnimAdd('singRIGHT', 'Monster Right note'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - case 'pico': - tex = Paths.getSparrowAtlas('characters/Pico_FNF_assetss'); - frames = tex; - quickAnimAdd('idle', "Pico Idle Dance"); - quickAnimAdd('singUP', 'pico Up note0'); - quickAnimAdd('singDOWN', 'Pico Down Note0'); - - // isPlayer = true; - - // Need to be flipped! REDO THIS LATER! - quickAnimAdd('singLEFT', 'Pico Note Right0'); - quickAnimAdd('singRIGHT', 'Pico NOTE LEFT0'); - quickAnimAdd('singRIGHTmiss', 'Pico NOTE LEFT miss'); - quickAnimAdd('singLEFTmiss', 'Pico Note Right Miss'); - - quickAnimAdd('singUPmiss', 'pico Up note miss'); - quickAnimAdd('singDOWNmiss', 'Pico Down Note MISS'); - - // right now it loads a seperate offset file for pico, would be cool if could generalize it! - var playerShit:String = ""; - - if (isPlayer) - playerShit += "Player"; - - loadOffsetFile(curCharacter + playerShit); - - playAnim('idle'); - - flipX = true; - - case 'pico-speaker': - frames = Paths.getSparrowAtlas('characters/picoSpeaker'); - - quickAnimAdd('shoot1', "Pico shoot 1"); - quickAnimAdd('shoot2', "Pico shoot 2"); - quickAnimAdd('shoot3', "Pico shoot 3"); - quickAnimAdd('shoot4', "Pico shoot 4"); - - // here for now, will be replaced later for less copypaste - loadOffsetFile(curCharacter); - playAnim('shoot1'); - - loadMappedAnims(); - - case 'bf': - var tex = Paths.getSparrowAtlas('characters/BOYFRIEND'); - frames = tex; - quickAnimAdd('idle', 'BF idle dance'); - quickAnimAdd('singUP', 'BF NOTE UP0'); - quickAnimAdd('singLEFT', 'BF NOTE LEFT0'); - quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0'); - quickAnimAdd('singDOWN', 'BF NOTE DOWN0'); - quickAnimAdd('singUPmiss', 'BF NOTE UP MISS'); - quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS'); - quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS'); - quickAnimAdd('preAttack', 'bf pre attack'); - quickAnimAdd('attack', 'boyfriend attack'); - quickAnimAdd('hey', 'BF HEY'); - - quickAnimAdd('firstDeath', "BF dies"); - animation.addByPrefix('deathLoop', "BF Dead Loop", 24, true); - quickAnimAdd('deathConfirm', "BF Dead confirm"); - - animation.addByPrefix('scared', 'BF idle shaking', 24, true); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - - loadOffsetFile(curCharacter); - - case 'bf-christmas': - var tex = Paths.getSparrowAtlas('characters/bfChristmas'); - frames = tex; - quickAnimAdd('idle', 'BF idle dance'); - quickAnimAdd('singUP', 'BF NOTE UP0'); - quickAnimAdd('singLEFT', 'BF NOTE LEFT0'); - quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0'); - quickAnimAdd('singDOWN', 'BF NOTE DOWN0'); - quickAnimAdd('singUPmiss', 'BF NOTE UP MISS'); - quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS'); - quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS'); - quickAnimAdd('hey', 'BF HEY'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - case 'bf-car': - var tex = Paths.getSparrowAtlas('characters/bfCar'); - frames = tex; - quickAnimAdd('idle', 'BF idle dance'); - quickAnimAdd('singUP', 'BF NOTE UP0'); - quickAnimAdd('singLEFT', 'BF NOTE LEFT0'); - quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0'); - quickAnimAdd('singDOWN', 'BF NOTE DOWN0'); - quickAnimAdd('singUPmiss', 'BF NOTE UP MISS'); - quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS'); - quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS'); - animation.addByIndices('idleHair', 'BF idle dance', [10, 11, 12, 13], "", 24, true); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - case 'bf-pixel': - frames = Paths.getSparrowAtlas('characters/bfPixel'); - quickAnimAdd('idle', 'BF IDLE'); - quickAnimAdd('singUP', 'BF UP NOTE'); - quickAnimAdd('singLEFT', 'BF LEFT NOTE'); - quickAnimAdd('singRIGHT', 'BF RIGHT NOTE'); - quickAnimAdd('singDOWN', 'BF DOWN NOTE'); - quickAnimAdd('singUPmiss', 'BF UP MISS'); - quickAnimAdd('singLEFTmiss', 'BF LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF RIGHT MISS'); - quickAnimAdd('singDOWNmiss', 'BF DOWN MISS'); - - loadOffsetFile(curCharacter); - - setGraphicSize(Std.int(width * 6)); - updateHitbox(); - - playAnim('idle'); - - width -= 100; - height -= 100; - - antialiasing = false; - - flipX = true; - case 'bf-pixel-dead': - frames = Paths.getSparrowAtlas('characters/bfPixelsDEAD'); - quickAnimAdd('singUP', "BF Dies pixel"); - quickAnimAdd('firstDeath', "BF Dies pixel"); - animation.addByPrefix('deathLoop', "Retry Loop", 24, true); - quickAnimAdd('deathConfirm', "RETRY CONFIRM"); - animation.play('firstDeath'); - - loadOffsetFile(curCharacter); - - playAnim('firstDeath'); - // pixel bullshit - setGraphicSize(Std.int(width * 6)); - updateHitbox(); - antialiasing = false; - flipX = true; - - case 'bf-holding-gf-dead': - frames = Paths.getSparrowAtlas('characters/bfHoldingGF-DEAD'); - quickAnimAdd('singUP', 'BF Dead with GF Loop'); - quickAnimAdd('firstDeath', 'BF Dies with GF'); - animation.addByPrefix('deathLoop', 'BF Dead with GF Loop', 24, true); - quickAnimAdd('deathConfirm', 'RETRY confirm holding gf'); - - loadOffsetFile(curCharacter); - - playAnim('firstDeath'); - - flipX = true; - - case 'senpai': - frames = Paths.getSparrowAtlas('characters/senpai'); - quickAnimAdd('idle', 'Senpai Idle'); - // at framerate 16.8 animation plays over 2 beats at 144bpm, - // but if the game lags or the bpm is > 144 (mods etc.) - // he may miss his next dance - // animation.getByName('idle').frameRate = 16.8; - - quickAnimAdd('singUP', 'SENPAI UP NOTE'); - quickAnimAdd('singLEFT', 'SENPAI LEFT NOTE'); - quickAnimAdd('singRIGHT', 'SENPAI RIGHT NOTE'); - quickAnimAdd('singDOWN', 'SENPAI DOWN NOTE'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - setGraphicSize(Std.int(width * 6)); - updateHitbox(); - - antialiasing = false; - case 'senpai-angry': - frames = Paths.getSparrowAtlas('characters/senpai'); - quickAnimAdd('idle', 'Angry Senpai Idle'); - quickAnimAdd('singUP', 'Angry Senpai UP NOTE'); - quickAnimAdd('singLEFT', 'Angry Senpai LEFT NOTE'); - quickAnimAdd('singRIGHT', 'Angry Senpai RIGHT NOTE'); - quickAnimAdd('singDOWN', 'Angry Senpai DOWN NOTE'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - setGraphicSize(Std.int(width * 6)); - updateHitbox(); - - antialiasing = false; - - case 'spirit': - frames = Paths.getPackerAtlas('characters/spirit'); - quickAnimAdd('idle', "idle spirit_"); - quickAnimAdd('singUP', "up_"); - quickAnimAdd('singRIGHT', "right_"); - quickAnimAdd('singLEFT', "left_"); - quickAnimAdd('singDOWN', "spirit down_"); - - loadOffsetFile(curCharacter); - - setGraphicSize(Std.int(width * 6)); - updateHitbox(); - - playAnim('idle'); - - antialiasing = false; - - case 'parents-christmas': - frames = Paths.getSparrowAtlas('characters/mom_dad_christmas_assets'); - quickAnimAdd('idle', 'Parent Christmas Idle'); - quickAnimAdd('singUP', 'Parent Up Note Dad'); - quickAnimAdd('singDOWN', 'Parent Down Note Dad'); - quickAnimAdd('singLEFT', 'Parent Left Note Dad'); - quickAnimAdd('singRIGHT', 'Parent Right Note Dad'); - - quickAnimAdd('singUP-alt', 'Parent Up Note Mom'); - - quickAnimAdd('singDOWN-alt', 'Parent Down Note Mom'); - quickAnimAdd('singLEFT-alt', 'Parent Left Note Mom'); - quickAnimAdd('singRIGHT-alt', 'Parent Right Note Mom'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - case 'tankman': - frames = Paths.getSparrowAtlas('characters/tankmanCaptain'); - - quickAnimAdd('idle', "Tankman Idle Dance"); - - if (isPlayer) - { - quickAnimAdd('singLEFT', 'Tankman Note Left '); - quickAnimAdd('singRIGHT', 'Tankman Right Note '); - quickAnimAdd('singLEFTmiss', 'Tankman Note Left MISS'); - quickAnimAdd('singRIGHTmiss', 'Tankman Right Note MISS'); - } - else - { - // Need to be flipped! REDO THIS LATER - quickAnimAdd('singLEFT', 'Tankman Right Note '); - quickAnimAdd('singRIGHT', 'Tankman Note Left '); - quickAnimAdd('singLEFTmiss', 'Tankman Right Note MISS'); - quickAnimAdd('singRIGHTmiss', 'Tankman Note Left MISS'); - } - - quickAnimAdd('singUP', 'Tankman UP note '); - quickAnimAdd('singDOWN', 'Tankman DOWN note '); - quickAnimAdd('singUPmiss', 'Tankman UP note MISS'); - quickAnimAdd('singDOWNmiss', 'Tankman DOWN note MISS'); - - // PRETTY GOOD tankman - // TANKMAN UGH instanc - - quickAnimAdd('singDOWN-alt', 'PRETTY GOOD'); - quickAnimAdd('singUP-alt', 'TANKMAN UGH'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - case 'darnell': - frames = Paths.getSparrowAtlas('characters/darnell'); - - quickAnimAdd('idle', 'Darnell Idle'); - quickAnimAdd('singUP', "Darnell pose up"); - quickAnimAdd('singDOWN', 'Darnell Pose Down'); - quickAnimAdd('singRIGHT', 'darnell pose left'); - quickAnimAdd('singLEFT', 'Darnell pose right'); // naming is reversed for left/right for darnell! - quickAnimAdd('laugh', 'darnell laugh'); - - // temp - loadOffsetFile(curCharacter); - - playAnim('idle'); - - animation.finishCallback = function(animShit:String) - { - if (animShit.startsWith('sing')) - { - // loop the anim - // this way is a little verbose, but basically sets it to the same animation, but 8 frames before finish - playAnim(animShit, true, false, animation.getByName(animShit).frames.length - 8); - } - } - case 'darnell-fighter': - frames = Paths.getSparrowAtlas('fightDarnell'); - - quickAnimAdd('idle', "fight idle darnell"); - quickAnimAdd('block', 'block'); - quickAnimAdd('hit high', 'hit high'); - quickAnimAdd('hit low', 'hit low'); - quickAnimAdd('punch low', 'punch low'); - quickAnimAdd('punch high', 'punch high'); - quickAnimAdd('dodge', 'dodge'); - playAnim('idle'); - - addOffset('punch low', -90); - addOffset('punch high', -90); - addOffset('block', 50, 20); - addOffset('dodge', 50, -20); - - case 'pico-fighter': - frames = Paths.getSparrowAtlas('fightPico'); - - quickAnimAdd('idle', 'fight idle pico'); - quickAnimAdd('block', 'block'); - quickAnimAdd('hit high', 'hit high'); - quickAnimAdd('hit low', 'hit low'); - quickAnimAdd('punch low', 'punch low'); - quickAnimAdd('punch high', 'punch high'); - quickAnimAdd('dodge', 'dodge'); - playAnim('idle'); - - addOffset('punch low', 160); - addOffset('punch high', 160); - - case 'nene': - // GIRLFRIEND CODE - tex = Paths.getSparrowAtlas('characters/Nene_assets'); - frames = tex; - - animation.addByIndices('danceLeft', 'nenebeforeyougetawoopin', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false); - animation.addByIndices('danceRight', 'nenebeforeyougetawoopin', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false); - - loadOffsetFile(curCharacter); - - playAnim('danceRight'); - } - - dance(); - animation.finish(); - - if (isPlayer) - { - flipX = !flipX; - - // Doesn't flip for BF, since his are already in the right place??? - if (!curCharacter.startsWith('bf')) - { - // var animArray - var oldRight = animation.getByName('singRIGHT').frames; - animation.getByName('singRIGHT').frames = animation.getByName('singLEFT').frames; - animation.getByName('singLEFT').frames = oldRight; - - // IF THEY HAVE MISS ANIMATIONS?? - if (animation.getByName('singRIGHTmiss') != null) - { - var oldMiss = animation.getByName('singRIGHTmiss').frames; - animation.getByName('singRIGHTmiss').frames = animation.getByName('singLEFTmiss').frames; - animation.getByName('singLEFTmiss').frames = oldMiss; - } - } - } - } - - public function loadMappedAnims() - { - var swagshit:SwagSong = SongLoad.loadFromJson('stress', 'stress'); - - var notes:Array = swagshit.noteMap.get('picospeaker'); - - for (section in notes) - { - for (noteData in section.sectionNotes) - { - animationNotes.push(noteData); - } - } - - trace(animationNotes); - animationNotes.sort(sortAnims); - } - - function sortAnims(val1:NoteData, val2:NoteData):Int - { - return FlxSort.byValues(FlxSort.ASCENDING, val1.strumTime, val2.strumTime); - } - - function quickAnimAdd(name:String, prefix:String) - { - animation.addByPrefix(name, prefix, 24, false); - } - - private function loadOffsetFile(offsetCharacter:String) - { - var daFile:Array = CoolUtil.coolTextFile(Paths.file("images/characters/" + offsetCharacter + "Offsets.txt", TEXT, 'shared')); - - for (i in daFile) - { - var splitWords:Array = i.split(" "); - addOffset(splitWords[0], Std.parseInt(splitWords[1]), Std.parseInt(splitWords[2])); - } - } - - override function update(elapsed:Float) - { - if (!curCharacter.startsWith('bf')) - { - if (animation.curAnim.name.startsWith('sing')) - { - holdTimer += elapsed; - } - - var dadVar:Float = 4; - - if (curCharacter == 'dad') - dadVar = 6.1; - if (holdTimer >= Conductor.stepCrochet * dadVar * 0.001) - { - dance(); - holdTimer = 0; - } - } - - if (curCharacter.endsWith('-car')) - { - // looping hair anims after idle finished - if (!animation.curAnim.name.startsWith('sing') && animation.curAnim.finished) - playAnim('idleHair'); - } - - switch (curCharacter) - { - case 'gf': - if (animation.curAnim.name == 'hairFall' && animation.curAnim.finished) - playAnim('danceRight'); - case "pico-speaker": - // for pico?? - if (animationNotes.length > 0) - { - if (Conductor.songPosition > animationNotes[0].strumTime) - { - trace('played shoot anim' + animationNotes[0].noteData); - - var shootAnim:Int = 1; - - if ((cast animationNotes[0].noteData) >= 2) - shootAnim = 3; - - shootAnim += FlxG.random.int(0, 1); - - playAnim('shoot' + shootAnim, true); - animationNotes.shift(); - } - } - - if (animation.curAnim.finished) - { - playAnim(animation.curAnim.name, false, false, animation.curAnim.numFrames - 3); - } - } - - super.update(elapsed); - } - - private var danced:Bool = false; - - /** - * FOR GF DANCING SHIT - */ - public function dance() - { - if (animation == null) - return; - if (!debugMode) - { - switch (curCharacter) - { - case 'gf' | 'gf-christmas' | 'gf-car' | 'gf-pixel' | 'gf-tankmen' | "nene": - if (!animation.curAnim.name.startsWith('hair')) - { - danced = !danced; - - if (danced) - playAnim('danceRight'); - else - playAnim('danceLeft'); - } - - case 'tankman': - if (!animation.curAnim.name.endsWith('DOWN-alt')) - playAnim('idle'); - - case 'spooky': - danced = !danced; - - if (danced) - playAnim('danceRight'); - else - playAnim('danceLeft'); - default: - playAnim('idle'); - } - } - } - - public function playAnim(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void - { - if (animation == null) - return; - animation.play(AnimName, Force, Reversed, Frame); - - var daOffset = animOffsets.get(AnimName); - if (animOffsets.exists(AnimName)) - { - offset.set(daOffset[0], daOffset[1]); - } - else - offset.set(0, 0); - - if (curCharacter == 'gf') - { - if (AnimName == 'singLEFT') - { - danced = true; - } - else if (AnimName == 'singRIGHT') - { - danced = false; - } - - if (AnimName == 'singUP' || AnimName == 'singDOWN') - { - danced = !danced; - } - } - } - - public function addOffset(name:String, x:Float = 0, y:Float = 0) - { - animOffsets[name] = [x, y]; - } -} diff --git a/source/funkin/CoolUtil.hx b/source/funkin/CoolUtil.hx index 60e12f343..a35364e5f 100644 --- a/source/funkin/CoolUtil.hx +++ b/source/funkin/CoolUtil.hx @@ -10,13 +10,13 @@ import flixel.math.FlxRect; import flixel.system.FlxAssets.FlxGraphicAsset; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; +import funkin.play.PlayState; +import funkin.shaderslmfao.ScreenWipeShader; import haxe.Json; import haxe.format.JsonParser; import lime.math.Rectangle; import lime.utils.Assets; import openfl.filters.ShaderFilter; -import funkin.play.PlayState; -import funkin.shaderslmfao.ScreenWipeShader; using StringTools; @@ -68,18 +68,34 @@ class CoolUtil static var oldCamPos:FlxPoint = new FlxPoint(); static var oldMousePos:FlxPoint = new FlxPoint(); - public static function mouseCamDrag():Void + /** + * 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 (FlxG.mouse.justPressedMiddle) + 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(FlxG.camera.scroll.x, FlxG.camera.scroll.y); + oldCamPos.set(target.x, target.y); oldMousePos.set(FlxG.mouse.screenX, FlxG.mouse.screenY); } - if (FlxG.mouse.pressedMiddle) + if (pressed) { - FlxG.camera.scroll.x = oldCamPos.x - (FlxG.mouse.screenX - oldMousePos.x); - FlxG.camera.scroll.y = oldCamPos.y - (FlxG.mouse.screenY - oldMousePos.y); + target.x = oldCamPos.x - (FlxG.mouse.screenX - oldMousePos.x); + target.y = oldCamPos.y - (FlxG.mouse.screenY - oldMousePos.y); } } @@ -118,6 +134,16 @@ class CoolUtil FlxG.camera.setFilters([new ShaderFilter(screenWipeShit)]); } + /** + * Just saves the json with some default values hehe + * @param json + * @return String + */ + public static inline function jsonStringify(data:Dynamic):String + { + return Json.stringify(data, null, "\t"); + } + /** * Hashlink json encoding fix for some wacky bullshit * https://github.com/HaxeFoundation/haxe/issues/6930#issuecomment-384570392 diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index 3706b9f9f..7f37eff18 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -25,6 +25,7 @@ import funkin.freeplayStuff.BGScrollingText; import funkin.freeplayStuff.DJBoyfriend; import funkin.freeplayStuff.FreeplayScore; import funkin.freeplayStuff.SongMenuItem; +import funkin.play.HealthIcon; import funkin.play.PlayState; import funkin.shaderslmfao.AngleMask; import funkin.shaderslmfao.PureColor; @@ -295,7 +296,7 @@ class FreeplayState extends MusicBeatSubstate // grpSongs.add(songText); var icon:HealthIcon = new HealthIcon(songs[i].songCharacter); - icon.sprTracker = songText; + // icon.sprTracker = songText; // using a FlxGroup is too much fuss! iconArray.push(icon); diff --git a/source/funkin/GameOverSubstate.hx b/source/funkin/GameOverSubstate.hx index 40cf9c0ca..113e64b63 100644 --- a/source/funkin/GameOverSubstate.hx +++ b/source/funkin/GameOverSubstate.hx @@ -4,83 +4,89 @@ import flixel.FlxObject; import flixel.system.FlxSound; import flixel.util.FlxColor; import flixel.util.FlxTimer; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; import funkin.play.PlayState; +import funkin.play.character.BaseCharacter; import funkin.ui.PreferencesMenu; +using StringTools; + +/** + * 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. + */ class GameOverSubstate extends MusicBeatSubstate { - var bf:Boyfriend; - var camFollow:FlxObject; + /** + * The boyfriend character. + */ + var boyfriend:BaseCharacter; - var stageSuffix:String = ""; - var randomGameover:Int = 1; + /** + * The invisible object in the scene which the camera focuses on. + */ + var cameraFollowPoint:FlxObject; - var gameOverMusic:FlxSound; + /** + * The music playing in the background of the state. + */ + var gameOverMusic:FlxSound = new FlxSound(); + + /** + * Whether the player has confirmed and prepared to restart the level. + * This means the animation and transition have already started. + */ + var isEnding:Bool = false; + + /** + * Music variant to use. + * TODO: De-hardcode this somehow. + */ + var musicVariant:String = ""; public function new() { - gameOverMusic = new FlxSound(); - FlxG.sound.list.add(gameOverMusic); - - var daStage = PlayState.instance.currentStageId; - var daBf:String = ''; - switch (daStage) - { - case 'school' | 'schoolEvil': - stageSuffix = '-pixel'; - daBf = 'bf-pixel-dead'; - default: - daBf = 'bf'; - } - - var daSong = PlayState.currentSong.song.toLowerCase(); - - switch (daSong) - { - case 'stress': - daBf = 'bf-holding-gf-dead'; - } - super(); + FlxG.sound.list.add(gameOverMusic); + gameOverMusic.stop(); + Conductor.songPosition = 0; - var bfXPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().x; - var bfYPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().y; - bf = new Boyfriend(bfXPos, bfYPos, daBf); - add(bf); + playBlueBalledSFX(); - camFollow = new FlxObject(bf.getGraphicMidpoint().x, bf.getGraphicMidpoint().y, 1, 1); - add(camFollow); - - FlxG.sound.play(Paths.sound('fnf_loss_sfx' + stageSuffix)); - // Conductor.bpm = 100; - - switch (PlayState.currentSong.player1) + switch (PlayState.instance.currentStageId) { - case 'pico': - stageSuffix = 'Pico'; + case 'school' | 'schoolEvil': + musicVariant = "-pixel"; + default: + if (PlayState.instance.currentStage.getBoyfriend().characterId == 'pico') + { + musicVariant = "Pico"; + } + else + { + musicVariant = ""; + } } - // FlxG.camera.followLerp = 1; - // FlxG.camera.focusOn(FlxPoint.get(FlxG.width / 2, FlxG.height / 2)); + // We have to remove boyfriend from the stage. Then we can add him back at the end. + boyfriend = PlayState.instance.currentStage.getBoyfriend(true); + boyfriend.isDead = true; + boyfriend.playAnimation('firstDeath'); + add(boyfriend); - // commented out for now - FlxG.camera.scroll.set(); + cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1); + add(cameraFollowPoint); + + // FlxG.camera.scroll.set(); FlxG.camera.target = null; - - bf.playAnim('firstDeath'); - - var randomCensor:Array = []; - - if (PreferencesMenu.getPref('censor-naughty')) - randomCensor = [1, 3, 8, 13, 17, 21]; - - randomGameover = FlxG.random.int(1, 25, randomCensor); + FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01); } - var playingDeathSound:Bool = false; - override function update(elapsed:Float) { // makes the lerp non-dependant on the framerate @@ -93,14 +99,14 @@ class GameOverSubstate extends MusicBeatSubstate var touch = FlxG.touches.getFirst(); if (touch != null) { - if (touch.overlaps(bf)) - endBullshit(); + if (touch.overlaps(boyfriend)) + confirmDeath(); } } if (controls.ACCEPT) { - endBullshit(); + confirmDeath(); } if (controls.BACK) @@ -116,74 +122,129 @@ class GameOverSubstate extends MusicBeatSubstate FlxG.switchState(new FreeplayState()); } - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.curFrame == 12) + // Start panning the camera to BF after 12 frames. + // TODO: Should this be de-hardcoded? + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.animation.curAnim.curFrame == 12) { - FlxG.camera.follow(camFollow, LOCKON, 0.01); - } - - switch (PlayState.storyWeek) - { - case 7: - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished && !playingDeathSound) - { - playingDeathSound = true; - - bf.startedDeath = true; - coolStartDeath(0.2); - - FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + randomGameover), 1, false, null, true, function() - { - if (!isEnding) - { - gameOverMusic.fadeIn(4, 0.2, 1); - } - // FlxG.sound.music.fadeIn(4, 0.2, 1); - }); - } - default: - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished) - { - bf.startedDeath = true; - coolStartDeath(); - } + cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x; + cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y; } if (gameOverMusic.playing) { Conductor.songPosition = gameOverMusic.time; } + else + { + switch (PlayState.storyWeek) + { + case 7: + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote) + { + playingJeffQuote = true; + playJeffQuote(); + + startDeathMusic(0.2); + } + default: + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) + { + startDeathMusic(); + } + } + } + + dispatchEvent(new UpdateScriptEvent(elapsed)); } - private function coolStartDeath(?vol:Float = 1):Void + override function dispatchEvent(event:ScriptEvent) + { + super.dispatchEvent(event); + + ScriptEventDispatcher.callEvent(boyfriend, event); + } + + /** + * Starts the death music at the appropriate volume. + * @param startingVolume + */ + function startDeathMusic(?startingVolume:Float = 1):Void { if (!isEnding) { - gameOverMusic.loadEmbedded(Paths.music('gameOver' + stageSuffix)); - gameOverMusic.volume = vol; + gameOverMusic.loadEmbedded(Paths.music('gameOver' + musicVariant)); + gameOverMusic.volume = startingVolume; + gameOverMusic.play(); + } + else + { + gameOverMusic.loadEmbedded(Paths.music('gameOverEnd' + musicVariant)); + gameOverMusic.volume = startingVolume; gameOverMusic.play(); } - // FlxG.sound.playMusic(); } - var isEnding:Bool = false; + /** + * Play the sound effect that occurs when + * boyfriend's testicles get utterly annihilated. + */ + function playBlueBalledSFX() + { + FlxG.sound.play(Paths.sound('fnf_loss_sfx' + musicVariant)); + } - function endBullshit():Void + var playingJeffQuote:Bool = false; + + /** + * Week 7-specific hardcoded behavior, to play a custom death quote. + * TODO: Make this a module somehow. + */ + function playJeffQuote() + { + var randomCensor:Array = []; + + if (PreferencesMenu.getPref('censor-naughty')) + randomCensor = [1, 3, 8, 13, 17, 21]; + + FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, 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; - bf.playAnim('deathConfirm', true); - gameOverMusic.stop(); - // FlxG.sound.music.stop(); - FlxG.sound.play(Paths.music('gameOverEnd' + stageSuffix)); + startDeathMusic(); // isEnding changes this function's behavior. + + boyfriend.playAnimation('deathConfirm', true); + + // After the animation finishes... new FlxTimer().start(0.7, function(tmr:FlxTimer) { + // ...fade out the graphics. Then after that happens... FlxG.camera.fade(FlxColor.BLACK, 2, false, function() { + // ...close the GameOverSubstate. FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true); PlayState.needsReset = true; + + // Readd Boyfriend to the stage. + boyfriend.isDead = false; + remove(boyfriend); + PlayState.instance.currentStage.addCharacter(boyfriend, BF); + + // Close the substate. close(); - // LoadingState.loadAndSwitchState(new PlayState()); }); }); } diff --git a/source/funkin/HealthIcon.hx b/source/funkin/HealthIcon.hx deleted file mode 100644 index 617af2b74..000000000 --- a/source/funkin/HealthIcon.hx +++ /dev/null @@ -1,88 +0,0 @@ -package funkin; - -import flixel.FlxSprite; -import openfl.utils.Assets; -import funkin.play.PlayState; - -using StringTools; - -class HealthIcon extends FlxSprite -{ - /** - * Used for FreeplayState! If you use it elsewhere, prob gonna annoying - */ - public var sprTracker:FlxSprite; - - public var char:String = ''; - - var isPlayer:Bool = false; - - public function new(char:String = 'bf', isPlayer:Bool = false) - { - super(); - - this.isPlayer = isPlayer; - - antialiasing = true; - changeIcon(char); - scrollFactor.set(); - } - - public var isOldIcon:Bool = false; - - public function swapOldIcon():Void - { - isOldIcon = !isOldIcon; - - if (isOldIcon) - changeIcon('bf-old'); - else - changeIcon(PlayState.currentSong.player1); - } - - var pixelArrayFunny:Array = CoolUtil.coolTextFile(Paths.file('images/icons/pixelIcons.txt')); - - public function changeIcon(newChar:String):Void - { - if (newChar != 'bf-pixel' && newChar != 'bf-old') - newChar = newChar.split('-')[0].trim(); - - if (!Assets.exists(Paths.image('icons/icon-' + newChar))) - { - FlxG.log.warn('No icon with data: $newChar : using default placeholder face instead!'); - newChar = "face"; - } - - if (newChar != char) - { - if (animation.getByName(newChar) == null) - { - var imgSize:Int = 150; - - if (newChar.endsWith('pixel') || pixelArrayFunny.contains(newChar)) - imgSize = 32; - - loadGraphic(Paths.image('icons/icon-' + newChar), true, imgSize, imgSize); - - animation.add(newChar, [0, 1], 0, false, isPlayer); - } - animation.play(newChar); - char = newChar; - - if (newChar.endsWith('pixel') || pixelArrayFunny.contains(newChar)) - antialiasing = false; - else - antialiasing = true; - setGraphicSize(150); - updateHitbox(); - } - } - - override function update(elapsed:Float) - { - super.update(elapsed); - - if (sprTracker != null) - setPosition(sprTracker.x + sprTracker.width + 10, sprTracker.y - 30); - } -} diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx index cb860a2ff..bfde8c327 100644 --- a/source/funkin/InitState.hx +++ b/source/funkin/InitState.hx @@ -8,11 +8,10 @@ import flixel.math.FlxPoint; import flixel.math.FlxRect; import flixel.util.FlxColor; import funkin.charting.ChartingState; -import funkin.charting.ChartingState; import funkin.modding.module.ModuleHandler; import funkin.play.PicoFight; import funkin.play.PlayState; -import funkin.play.stage.StageData; +import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.stage.StageData; import funkin.ui.PreferencesMenu; import funkin.ui.animDebugShit.DebugBoundingState; @@ -125,7 +124,8 @@ class InitState extends FlxTransitionableState FlxTransitionableState.skipNextTransIn = true; StageDataParser.loadStageCache(); - + CharacterDataParser.loadCharacterCache(); + ModuleHandler.buildModuleCallbacks(); ModuleHandler.loadModuleCache(); #if song @@ -179,7 +179,11 @@ class InitState extends FlxTransitionableState #elseif FIGHT FlxG.switchState(new PicoFight()); #elseif ANIMDEBUG +<<<<<<< HEAD + FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); +======= FlxG.switchState(new DebugBoundingState()); +>>>>>>> origin/feature/scripted-modules #elseif NETTEST FlxG.switchState(new netTest.NetTest()); #else diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx index 8155caf3d..ee84e14c8 100644 --- a/source/funkin/MainMenuState.hx +++ b/source/funkin/MainMenuState.hx @@ -19,11 +19,13 @@ import funkin.modding.events.ScriptEvent.UpdateScriptEvent; import funkin.modding.module.ModuleHandler; import funkin.shaderslmfao.ScreenWipeShader; import funkin.ui.AtlasMenuList; +import funkin.ui.MenuList.MenuItem; import funkin.ui.MenuList; import funkin.ui.OptionsState; import funkin.ui.PreferencesMenu; import funkin.ui.Prompt; import funkin.util.Constants; +import funkin.util.WindowUtil; import lime.app.Application; import openfl.filters.ShaderFilter; @@ -39,7 +41,7 @@ import io.newgrounds.NG; class MainMenuState extends MusicBeatState { - var menuItems:MainMenuList; + var menuItems:MenuTypedList; var magenta:FlxSprite; var camFollow:FlxObject; @@ -87,7 +89,7 @@ class MainMenuState extends MusicBeatState add(magenta); // magenta.scrollFactor.set(); - menuItems = new MainMenuList(); + menuItems = new MenuTypedList(); add(menuItems); menuItems.onChange.add(onMenuItemChange); menuItems.onAcceptPress.add(function(_) @@ -103,31 +105,32 @@ class MainMenuState extends MusicBeatState }); menuItems.enabled = true; // can move on intro - menuItems.createItem('story mode', function() startExitState(new StoryMenuState())); - menuItems.createItem('freeplay', function() + createMenuItem('storymode', 'mainmenu/storymode', function() startExitState(new StoryMenuState())); + createMenuItem('freeplay', 'mainmenu/freeplay', function() { persistentDraw = true; persistentUpdate = false; openSubState(new FreeplayState()); }); - // addMenuItem('options', function () startExitState(new OptionMenu())); #if CAN_OPEN_LINKS var hasPopupBlocker = #if web true #else false #end; if (VideoState.seenVideo) - menuItems.createItem('kickstarter', selectDonate, hasPopupBlocker); + { + createMenuItem('kickstarter', 'mainmenu/kickstarter', selectDonate, hasPopupBlocker); + } else - menuItems.createItem('donate', selectDonate, hasPopupBlocker); + { + createMenuItem('donate', 'mainmenu/donate', selectDonate, hasPopupBlocker); + } #end - menuItems.createItem('options', function() startExitState(new OptionsState())); - // #if newgrounds - // if (NGio.isLoggedIn) - // menuItems.createItem("logout", selectLogout); - // else - // menuItems.createItem("login", selectLogin); - // #end - // center vertically + createMenuItem('options', 'mainmenu/options', function() + { + startExitState(new OptionsState()); + }); + + // Reset position of menu items. var spacing = 160; var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2; for (i in 0...menuItems.length) @@ -145,19 +148,26 @@ class MainMenuState extends MusicBeatState // This has to come AFTER! this.leftWatermarkText.text = Constants.VERSION; - this.rightWatermarkText.text = "blablabla test"; - - // var versionStr = 'v${Application.current.meta.get('version')}'; - // versionStr += ' (secret week 8 build do not leak)'; - // - // var versionShit:FlxText = new FlxText(5, FlxG.height - 18, 0, versionStr, 12); - // versionShit.scrollFactor.set(); - // versionShit.setFormat("VCR OSD Mono", 16, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); - // add(versionShit); + // this.rightWatermarkText.text = "blablabla test"; // NG.core.calls.event.logEvent('swag').send(); } + 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() { magenta.visible = false; @@ -185,17 +195,7 @@ class MainMenuState extends MusicBeatState #if CAN_OPEN_LINKS function selectDonate() { - #if linux - // Sys.command('/usr/bin/xdg-open', ["https://ninja-muffin24.itch.io/funkin", "&"]); - Sys.command('/usr/bin/xdg-open', [ - "https://www.kickstarter.com/projects/funkin/friday-night-funkin-the-full-ass-game/", - "&" - ]); - #else - // FlxG.openURL('https://ninja-muffin24.itch.io/funkin'); - - FlxG.openURL('https://www.kickstarter.com/projects/funkin/friday-night-funkin-the-full-ass-game/'); - #end + WindowUtil.openURL(Constants.URL_KICKSTARTER); } #end @@ -317,46 +317,3 @@ class MainMenuState extends MusicBeatState } } } - -private class MainMenuList extends MenuTypedList -{ - public var atlas:FlxAtlasFrames; - - public function new() - { - atlas = Paths.getSparrowAtlas('main_menu'); - super(Vertical); - } - - public function createItem(x = 0.0, y = 0.0, name:String, callback, fireInstantly = false) - { - var item = new MainMenuItem(x, y, name, atlas, callback); - item.fireInstantly = fireInstantly; - item.ID = length; - - return addItem(name, item); - } - - override function destroy() - { - super.destroy(); - atlas = null; - } -} - -private class MainMenuItem extends AtlasMenuItem -{ - public function new(x = 0.0, y = 0.0, name, atlas, callback) - { - super(x, y, name, atlas, callback); - scrollFactor.set(); - } - - override function changeAnim(anim:String) - { - super.changeAnim(anim); - // position by center - centerOrigin(); - offset.copyFrom(origin); - } -} diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index 0fca6fb84..5e86ea022 100644 --- a/source/funkin/MusicBeatState.hx +++ b/source/funkin/MusicBeatState.hx @@ -10,6 +10,8 @@ import funkin.Conductor.BPMChangeEvent; import funkin.modding.PolymodHandler; import funkin.modding.events.ScriptEvent; import funkin.modding.module.ModuleHandler; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.stage.StageData.StageDataParser; import funkin.util.SortUtil; /** diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx index 98fdc2165..566b4ea49 100644 --- a/source/funkin/Note.hx +++ b/source/funkin/Note.hx @@ -1,11 +1,12 @@ package funkin; -import funkin.util.Constants; import flixel.FlxSprite; import flixel.math.FlxMath; +import funkin.play.PlayState; +import funkin.play.Strumline.StrumlineStyle; import funkin.shaderslmfao.ColorSwap; import funkin.ui.PreferencesMenu; -import funkin.play.PlayState; +import funkin.util.Constants; using StringTools; @@ -93,7 +94,10 @@ class Note extends FlxSprite // anything below sick threshold is sick public static var arrowColors:Array = [1, 1, 1, 1]; - public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false) + // Which note asset to load? + public var style:StrumlineStyle = NORMAL; + + public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false, ?style:StrumlineStyle = NORMAL) { super(); @@ -110,10 +114,15 @@ class Note extends FlxSprite data.noteData = noteData; + this.style = style; + + if (this.style == null) + this.style = StrumlineStyle.NORMAL; + // TODO: Make this logic more generic - switch (PlayState.instance.currentStageId) + switch (this.style) { - case 'school' | 'schoolEvil': + case PIXEL: loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17); animation.add('greenScroll', [6]); @@ -227,6 +236,7 @@ class Note extends FlxSprite { super.update(elapsed); + // mustPress indicates the player is the one pressing the key if (mustPress) { // miss on the NEXT frame so lag doesnt make u miss notes @@ -244,7 +254,8 @@ class Note extends FlxSprite } if (data.strumTime > Conductor.songPosition - HIT_WINDOW) - { // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru! + { + // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru! if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1))) canBeHit = true; } @@ -281,14 +292,14 @@ typedef RawNoteData = var strumTime:Float; var noteData:NoteType; var sustainLength:Float; - var altNote:Bool; + var altNote:String; var noteKind:NoteKind; } @:forward abstract NoteData(RawNoteData) { - public function new(strumTime = 0.0, noteData:NoteType = 0, sustainLength = 0.0, altNote = false, noteKind = NORMAL) + public function new(strumTime = 0.0, noteData:NoteType = 0, sustainLength = 0.0, altNote = "", noteKind = NORMAL) { this = { strumTime: strumTime, @@ -455,7 +466,12 @@ enum abstract NoteColor(NoteType) from Int to Int from NoteType enum abstract NoteKind(String) from String to String { + /** + * The default note type. + */ var NORMAL = "normal"; + + // Testing shiz var PYRO_LIGHT = "pyro_light"; var PYRO_KICK = "pyro_kick"; var PYRO_TOSS = "pyro_toss"; diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx index 03a90ff9a..cf127df90 100644 --- a/source/funkin/Paths.hx +++ b/source/funkin/Paths.hx @@ -107,7 +107,7 @@ class Paths return 'assets/fonts/$key'; } - inline static public function getSparrowAtlas(key:String, ?library:String) + static public function getSparrowAtlas(key:String, ?library:String) { return FlxAtlasFrames.fromSparrow(image(key, library), file('images/$key.xml', library)); } diff --git a/source/funkin/SongLoad.hx b/source/funkin/SongLoad.hx index 770795602..f786d96f5 100644 --- a/source/funkin/SongLoad.hx +++ b/source/funkin/SongLoad.hx @@ -189,8 +189,11 @@ class SongLoad noteStuff[sectionIndex].sectionNotes[noteIndex].strumTime = arrayDipshit[0]; noteStuff[sectionIndex].sectionNotes[noteIndex].noteData = arrayDipshit[1]; noteStuff[sectionIndex].sectionNotes[noteIndex].sustainLength = arrayDipshit[2]; - noteStuff[sectionIndex].sectionNotes[noteIndex].altNote = arrayDipshit[3]; - if (arrayDipshit.length >= 5) + if (arrayDipshit.length > 3) + { + noteStuff[sectionIndex].sectionNotes[noteIndex].altNote = arrayDipshit[3]; + } + if (arrayDipshit.length > 4) { noteStuff[sectionIndex].sectionNotes[noteIndex].noteKind = arrayDipshit[4]; } diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx index dff3fce14..868e62d40 100644 --- a/source/funkin/StoryMenuState.hx +++ b/source/funkin/StoryMenuState.hx @@ -34,6 +34,8 @@ class StoryMenuState extends MusicBeatState ]; var curDifficulty:Int = 1; + // TODO: This info is just hardcoded right now. + // We should probably make it so that weeks must be completed in order to unlock the next week. public static var weekUnlocked:Array = [true, true, true, true, true, true, true, true]; var weekCharacters:Array = [ @@ -44,7 +46,7 @@ class StoryMenuState extends MusicBeatState ['mom', 'bf', 'gf'], ['parents-christmas', 'bf', 'gf'], ['senpai', 'bf', 'gf'], - ['tankman', 'bf', 'gf'] + ['tankman', 'bf', 'gf'], ]; var weekNames:Array = [ @@ -114,8 +116,6 @@ class StoryMenuState extends MusicBeatState grpLocks = new FlxTypedGroup(); add(grpLocks); - trace("Line 70"); - #if discord_rpc // Updating Discord Rich Presence DiscordClient.changePresence("In the Menus", null); @@ -145,8 +145,6 @@ class StoryMenuState extends MusicBeatState } } - trace("Line 96"); - for (char in 0...3) { var weekCharacterThing:MenuCharacter = new MenuCharacter((FlxG.width * 0.25) * (1 + char) - 150, weekCharacters[curWeek][char]); @@ -178,8 +176,6 @@ class StoryMenuState extends MusicBeatState difficultySelectors = new FlxGroup(); add(difficultySelectors); - trace("Line 124"); - leftArrow = new FlxSprite(grpWeekText.members[0].x + grpWeekText.members[0].width + 10, grpWeekText.members[0].y + 10); leftArrow.frames = ui_tex; leftArrow.animation.addByPrefix('idle', "arrow left"); @@ -204,8 +200,6 @@ class StoryMenuState extends MusicBeatState rightArrow.animation.play('idle'); difficultySelectors.add(rightArrow); - trace("Line 150"); - add(yellowBG); add(grpWeekCharacters); @@ -220,8 +214,6 @@ class StoryMenuState extends MusicBeatState updateText(); - trace("Line 165"); - super.create(); } diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx index 4df29e1f3..cf8c6ea80 100644 --- a/source/funkin/TitleState.hx +++ b/source/funkin/TitleState.hx @@ -515,7 +515,7 @@ class TitleState extends MusicBeatState lime.ui.Haptic.vibrate(100, 100); - var coolText:AtlasText = new AtlasText(0, 0, text, AtlasFont.BOLD); + var coolText:AtlasText = new AtlasText(0, 0, text.trim(), AtlasFont.BOLD); coolText.screenCenter(X); coolText.y += (textGroup.length * 60) + 200; textGroup.add(coolText); @@ -554,7 +554,7 @@ class TitleState extends MusicBeatState switch (i + 1) { case 1: - createCoolText(['ninjamuffin99', 'phantomArcade', 'kawaisprite', 'evilsk8er']); + createCoolText(['ninjamuffin99', 'phantomArcade', 'kawaisprite', 'evilsk8r']); case 3: addMoreText('present'); case 4: diff --git a/source/funkin/api/newgrounds/NGUnsafe.hx b/source/funkin/api/newgrounds/NGUnsafe.hx new file mode 100644 index 000000000..2995988a9 --- /dev/null +++ b/source/funkin/api/newgrounds/NGUnsafe.hx @@ -0,0 +1,78 @@ +package funkin.api.newgrounds; + +import flixel.util.FlxSignal; +import flixel.util.FlxTimer; +import lime.app.Application; +import openfl.display.Stage; +#if newgrounds +import io.newgrounds.NG; +import io.newgrounds.NGLite; +import io.newgrounds.components.ScoreBoardComponent.Period; +import io.newgrounds.objects.Error; +import io.newgrounds.objects.Medal; +import io.newgrounds.objects.Score; +import io.newgrounds.objects.ScoreBoard; +import io.newgrounds.objects.events.Response; +import io.newgrounds.objects.events.Result.GetCurrentVersionResult; +import io.newgrounds.objects.events.Result.GetVersionResult; +#end + +using StringTools; + +/** + * Contains any script functions which should be BLOCKED from use by modded scripts. + */ +class NGUnsafe +{ + static public function logEvent(event:String) + { + #if newgrounds + NG.core.calls.event.logEvent(event).send(); + trace('should have logged: ' + event); + #else + #if debug + trace('event:$event - not logged, missing NG.io lib'); + #end + #end + } + + static public function unlockMedal(id:Int) + { + #if newgrounds + if (isLoggedIn) + { + var medal = NG.core.medals.get(id); + if (!medal.unlocked) + medal.sendUnlock(); + } + #else + #if debug + trace('medal:$id - not unlocked, missing NG.io lib'); + #end + #end + } + + static public function postScore(score:Int = 0, song:String) + { + #if newgrounds + if (isLoggedIn) + { + for (id in NG.core.scoreBoards.keys()) + { + var board = NG.core.scoreBoards.get(id); + + if (song == board.name) + { + board.postScore(score, "Uhh meow?"); + } + + // trace('loaded scoreboard id:$id, name:${board.name}'); + } + } + #else + #if debug + trace('Song:$song, Score:$score - not posted, missing NG.io lib'); + #end + #end + } +} diff --git a/source/funkin/api/newgrounds/NGUtil.hx b/source/funkin/api/newgrounds/NGUtil.hx new file mode 100644 index 000000000..8ec06b27f --- /dev/null +++ b/source/funkin/api/newgrounds/NGUtil.hx @@ -0,0 +1,260 @@ +package funkin.api.newgrounds; + +import flixel.util.FlxSignal; +import flixel.util.FlxTimer; +import lime.app.Application; +import openfl.display.Stage; +#if newgrounds +import io.newgrounds.NG; +import io.newgrounds.NGLite; +import io.newgrounds.components.ScoreBoardComponent.Period; +import io.newgrounds.objects.Error; +import io.newgrounds.objects.Medal; +import io.newgrounds.objects.Score; +import io.newgrounds.objects.ScoreBoard; +import io.newgrounds.objects.events.Response; +import io.newgrounds.objects.events.Result.GetCurrentVersionResult; +import io.newgrounds.objects.events.Result.GetVersionResult; +#end + +using StringTools; + +/** + * Contains any script functions which should be ALLOWD for use by modded scripts. + */ +class NGUtil +{ + #if newgrounds + /** + * True, if the saved sessionId was used in the initial login, and failed to connect. + * Used in MainMenuState to show a popup to establish a new connection + */ + public static var savedSessionFailed(default, null):Bool = false; + + public static var scoreboardsLoaded:Bool = false; + public static var isLoggedIn(get, never):Bool; + + inline static function get_isLoggedIn() + { + return NG.core != null && NG.core.loggedIn; + } + + public static var scoreboardArray:Array = []; + + public static var ngDataLoaded(default, null):FlxSignal = new FlxSignal(); + public static var ngScoresLoaded(default, null):FlxSignal = new FlxSignal(); + + public static var GAME_VER:String = ""; + + static public function checkVersion(callback:String->Void) + { + trace('checking NG.io version'); + GAME_VER = "v" + Application.current.meta.get('version'); + + NG.core.calls.app.getCurrentVersion(GAME_VER).addDataHandler(function(response) + { + GAME_VER = response.result.data.currentVersion; + trace('CURRENT NG VERSION: ' + GAME_VER); + callback(GAME_VER); + }).send(); + } + + static public function init() + { + var api = APIStuff.API; + if (api == null || api.length == 0) + { + trace("Missing Newgrounds API key, aborting connection"); + return; + } + trace("connecting to newgrounds"); + + #if NG_FORCE_EXPIRED_SESSION + var sessionId:String = "fake_session_id"; + function onSessionFail(error:Error) + { + trace("Forcing an expired saved session. " + "To disable, comment out NG_FORCE_EXPIRED_SESSION in Project.xml"); + savedSessionFailed = true; + } + #else + var sessionId:String = NGLite.getSessionId(); + if (sessionId != null) + trace("found web session id"); + + #if (debug) + if (sessionId == null && APIStuff.SESSION != null) + { + trace("using debug session id"); + sessionId = APIStuff.SESSION; + } + #end + + var onSessionFail:Error->Void = null; + if (sessionId == null && FlxG.save.data.sessionId != null) + { + trace("using stored session id"); + sessionId = FlxG.save.data.sessionId; + onSessionFail = function(error) savedSessionFailed = true; + } + #end + + NG.create(api, sessionId, #if NG_DEBUG true #else false #end, onSessionFail); + + #if NG_VERBOSE + NG.core.verbose = true; + #end + // Set the encryption cipher/format to RC4/Base64. AES128 and Hex are not implemented yet + NG.core.initEncryption(APIStuff.EncKey); // Found in you NG project view + + if (NG.core.attemptingLogin) + { + /* a session_id was found in the loadervars, this means the user is playing on newgrounds.com + * and we should login shortly. lets wait for that to happen + */ + trace("attempting login"); + NG.core.onLogin.add(onNGLogin); + } + // GK: taking out auto login, adding a login button to the main menu + // else + // { + // /* They are NOT playing on newgrounds.com, no session id was found. We must start one manually, if we want to. + // * Note: This will cause a new browser window to pop up where they can log in to newgrounds + // */ + // NG.core.requestLogin(onNGLogin); + // } + } + + /** + * Attempts to log in to newgrounds by requesting a new session ID, only call if no session ID was found automatically + * @param popupLauncher The function to call to open the login url, must be inside + * a user input event or the popup blocker will block it. + * @param onComplete A callback with the result of the connection. + */ + static public function login(?popupLauncher:(Void->Void)->Void, onComplete:ConnectionResult->Void) + { + trace("Logging in manually"); + var onPending:Void->Void = null; + if (popupLauncher != null) + { + onPending = function() popupLauncher(NG.core.openPassportUrl); + } + + var onSuccess:Void->Void = onNGLogin; + var onFail:Error->Void = null; + var onCancel:Void->Void = null; + if (onComplete != null) + { + onSuccess = function() + { + onNGLogin(); + onComplete(Success); + } + onFail = function(e) onComplete(Fail(e.message)); + onCancel = function() onComplete(Cancelled); + } + + NG.core.requestLogin(onSuccess, onPending, onFail, onCancel); + } + + inline static public function cancelLogin():Void + { + NG.core.cancelLoginRequest(); + } + + static function onNGLogin():Void + { + trace('logged in! user:${NG.core.user.name}'); + FlxG.save.data.sessionId = NG.core.sessionId; + FlxG.save.flush(); + // Load medals then call onNGMedalFetch() + NG.core.requestMedals(onNGMedalFetch); + + // Load Scoreboards hten call onNGBoardsFetch() + NG.core.requestScoreBoards(onNGBoardsFetch); + + ngDataLoaded.dispatch(); + } + + static public function logout() + { + NG.core.logOut(); + + FlxG.save.data.sessionId = null; + FlxG.save.flush(); + } + + // --- MEDALS + static function onNGMedalFetch():Void + { + /* + // Reading medal info + for (id in NG.core.medals.keys()) + { + var medal = NG.core.medals.get(id); + trace('loaded medal id:$id, name:${medal.name}, description:${medal.description}'); + } + + // Unlocking medals + var unlockingMedal = NG.core.medals.get(54352);// medal ids are listed in your NG project viewer + if (!unlockingMedal.unlocked) + unlockingMedal.sendUnlock(); + */ + } + + // --- SCOREBOARDS + static function onNGBoardsFetch():Void + { + /* + // Reading medal info + for (id in NG.core.scoreBoards.keys()) + { + var board = NG.core.scoreBoards.get(id); + trace('loaded scoreboard id:$id, name:${board.name}'); + } + */ + // var board = NG.core.scoreBoards.get(8004);// ID found in NG project view + + // Posting a score thats OVER 9000! + // board.postScore(FlxG.random.int(0, 1000)); + + // --- To view the scores you first need to select the range of scores you want to see --- + + // add an update listener so we know when we get the new scores + // board.onUpdate.add(onNGScoresFetch); + trace("shoulda got score by NOW!"); + // board.requestScores(20);// get the best 10 scores ever logged + // more info on scores --- http://www.newgrounds.io/help/components/#scoreboard-getscores + } + + static function onNGScoresFetch():Void + { + scoreboardsLoaded = true; + + ngScoresLoaded.dispatch(); + /* + for (score in NG.core.scoreBoards.get(8737).scores) + { + trace('score loaded user:${score.user.name}, score:${score.formatted_value}'); + + } + */ + + // var board = NG.core.scoreBoards.get(8004);// ID found in NG project view + // board.postScore(HighScore.score); + + // NGUtil.scoreboardArray = NG.core.scoreBoards.get(8004).scores; + } + #end +} + +enum ConnectionResult +{ + /** Log in successful */ + Success; + + /** Could not login */ + Fail(msg:String); + + /** User cancelled the login */ + Cancelled; +} diff --git a/source/funkin/api/newgrounds/NgPrompt.hx b/source/funkin/api/newgrounds/NgPrompt.hx new file mode 100644 index 000000000..5f08bf443 --- /dev/null +++ b/source/funkin/api/newgrounds/NgPrompt.hx @@ -0,0 +1,104 @@ +package funkin.api.newgrounds; + +#if newgrounds +import funkin.NGio; +import funkin.ui.Prompt; + +class NgPrompt extends Prompt +{ + public function new(text:String, style:ButtonStyle = Yes_No) + { + super(text, style); + } + + static public function showLogin() + { + return showLoginPrompt(true); + } + + static public function showSavedSessionFailed() + { + return showLoginPrompt(false); + } + + static function showLoginPrompt(fromUi:Bool) + { + var prompt = new NgPrompt("Talking to server...", None); + prompt.openCallback = NGUtil.login.bind(function popupLauncher(openPassportUrl) + { + var choiceMsg = fromUi ? #if web "Log in to Newgrounds?" #else null #end // User-input needed to allow popups + : "Your session has expired.\n Please login again."; + + if (choiceMsg != null) + { + prompt.setText(choiceMsg); + prompt.setButtons(Yes_No); + #if web + prompt.buttons.getItem("yes").fireInstantly = true; + #end + prompt.onYes = function() + { + prompt.setText("Connecting..." #if web + "\n(check your popup blocker)" #end); + prompt.setButtons(None); + openPassportUrl(); + }; + prompt.onNo = function() + { + prompt.close(); + prompt = null; + NGio.cancelLogin(); + }; + } + else + { + prompt.setText("Connecting..."); + openPassportUrl(); + } + }, function onLoginComplete(result:ConnectionResult) + { + switch (result) + { + case Success: + { + prompt.setText("Login Successful"); + prompt.setButtons(Ok); + prompt.onYes = prompt.close; + } + case Fail(msg): + { + trace("Login Error:" + msg); + prompt.setText("Login failed"); + prompt.setButtons(Ok); + prompt.onYes = prompt.close; + } + case Cancelled: + { + if (prompt != null) + { + prompt.setText("Login cancelled by user"); + prompt.setButtons(Ok); + prompt.onYes = prompt.close; + } + else + trace("Login cancelled via prompt"); + } + } + }); + + return prompt; + } + + static public function showLogout() + { + var user = io.newgrounds.NG.core.user.name; + var prompt = new NgPrompt('Log out of $user?', Yes_No); + prompt.onYes = function() + { + NGio.logout(); + prompt.close(); + }; + prompt.onNo = prompt.close; + return prompt; + } +} +#end diff --git a/source/funkin/api/newgrounds/README.md b/source/funkin/api/newgrounds/README.md new file mode 100644 index 000000000..f61e1b0fd --- /dev/null +++ b/source/funkin/api/newgrounds/README.md @@ -0,0 +1,9 @@ +# funkin.api.newgrounds + +This package contains two main classes: +- `NGUtil` contains utility functions for interacting with the Newgrounds API. + - This includes any functions which scripts should be able to use, + such as retrieving achievement status. +- `NGUnsafe` contains sensitive utility functions for interacting with the Newgrounds API. + - This includes any functions which scripts should not be able to use, + such as writing high scores or posting achievements. \ No newline at end of file diff --git a/source/funkin/charting/ChartingState.hx b/source/funkin/charting/ChartingState.hx index eb4b13663..3f19edc07 100644 --- a/source/funkin/charting/ChartingState.hx +++ b/source/funkin/charting/ChartingState.hx @@ -23,6 +23,7 @@ import funkin.SongLoad.SwagSong; import funkin.audiovis.ABotVis; import funkin.audiovis.PolygonSpectogram; import funkin.audiovis.SpectogramSprite; +import funkin.play.HealthIcon; import funkin.play.PlayState; import funkin.rendering.MeshRender; import haxe.Json; @@ -110,6 +111,9 @@ class ChartingState extends MusicBeatState leftIcon.setGraphicSize(0, 45); rightIcon.setGraphicSize(0, 45); + leftIcon.autoUpdate = false; + rightIcon.autoUpdate = false; + add(leftIcon); add(rightIcon); @@ -705,7 +709,7 @@ class ChartingState extends MusicBeatState { if (FlxG.mouse.overlaps(leftIcon)) { - if (leftIcon.char == _song.player1) + if (leftIcon.characterId == _song.player1) { p1Muted = !p1Muted; leftIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; @@ -727,7 +731,7 @@ class ChartingState extends MusicBeatState // sloppy copypaste lol deal with it! if (FlxG.mouse.overlaps(rightIcon)) { - if (rightIcon.char == _song.player1) + if (rightIcon.characterId == _song.player1) { p1Muted = !p1Muted; rightIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; @@ -1012,7 +1016,7 @@ class ChartingState extends MusicBeatState if (curSelectedNote != null) { trace('ALT NOTE SHIT'); - curSelectedNote.altNote = !curSelectedNote.altNote; + curSelectedNote.altNote = (curSelectedNote.altNote == "alt") ? "" : "alt"; trace(curSelectedNote.altNote); } } @@ -1129,19 +1133,25 @@ class ChartingState extends MusicBeatState { if (check_mustHitSection.checked) { - leftIcon.changeIcon(_song.player1); - rightIcon.changeIcon(_song.player2); + leftIcon.characterId = (_song.player1); + rightIcon.characterId = (_song.player2); - leftIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; - rightIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0; + // leftIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; + // rightIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0; + + leftIcon.playAnimation(p1Muted ? LOSING : IDLE); + rightIcon.playAnimation(p2Muted ? LOSING : IDLE); } else { - leftIcon.changeIcon(_song.player2); - rightIcon.changeIcon(_song.player1); + leftIcon.characterId = (_song.player2); + rightIcon.characterId = (_song.player1); - leftIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0; - rightIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; + leftIcon.playAnimation(p2Muted ? LOSING : IDLE); + rightIcon.playAnimation(p1Muted ? LOSING : IDLE); + + // leftIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0; + // rightIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0; } leftIcon.setGraphicSize(0, 45); rightIcon.setGraphicSize(0, 45); @@ -1348,7 +1358,7 @@ class ChartingState extends MusicBeatState var noteStrum = getStrumTime(dummyArrow.y) + sectionStartTime(); var noteData = Math.floor(FlxG.mouse.x / GRID_SIZE); var noteSus = 0; - var noteAlt = false; + var noteAlt = ""; justPlacedNote = true; diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/freeplayStuff/DJBoyfriend.hx index 8ea52370d..20449c25e 100644 --- a/source/funkin/freeplayStuff/DJBoyfriend.hx +++ b/source/funkin/freeplayStuff/DJBoyfriend.hx @@ -23,14 +23,14 @@ class DJBoyfriend extends FlxSprite addOffset('intro', 0, 0); addOffset('idle', -4, -426); - playAnim('intro'); + playAnimation('intro'); animation.finishCallback = function(anim) { switch (anim) { case "intro": animHITsignal.dispatch(); - playAnim('idle'); // plays idle anim after playing intro + playAnimation('idle'); // plays idle anim after playing intro } }; } @@ -38,7 +38,7 @@ class DJBoyfriend extends FlxSprite // playAnim stolen from Character.hx, cuz im lazy lol! public var animOffsets:Map>; - public function playAnim(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void + public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void { animation.play(AnimName, Force, Reversed, Frame); diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 407f3ec85..1fe2b0682 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -144,6 +144,8 @@ class PolymodHandler // 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) diff --git a/source/funkin/modding/base/ScriptedFlxSprite.hx b/source/funkin/modding/base/ScriptedFlxSprite.hx index 9bccdc778..d13d968a8 100644 --- a/source/funkin/modding/base/ScriptedFlxSprite.hx +++ b/source/funkin/modding/base/ScriptedFlxSprite.hx @@ -4,7 +4,4 @@ import flixel.FlxSprite; import funkin.modding.IHook; @:hscriptClass -class ScriptedFlxSprite extends FlxSprite implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedFlxSprite extends FlxSprite implements IHook {} diff --git a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx index a60dfdad3..5db61b81e 100644 --- a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx +++ b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx @@ -4,7 +4,4 @@ import flixel.group.FlxSpriteGroup; import funkin.modding.IHook; @:hscriptClass -class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements IHook {} diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx index 908b5c428..e533a39fd 100644 --- a/source/funkin/modding/module/ModuleHandler.hx +++ b/source/funkin/modding/module/ModuleHandler.hx @@ -47,6 +47,16 @@ class ModuleHandler trace("[MODULEHANDLER] Module cache loaded."); } + public static function buildModuleCallbacks():Void + { + FlxG.signals.postStateSwitch.add(onStateSwitchComplete); + } + + static function onStateSwitchComplete():Void + { + callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true)); + } + static function addToModuleCache(module:Module):Void { moduleCache.set(module.moduleId, module); diff --git a/source/funkin/modding/module/ScriptedModule.hx b/source/funkin/modding/module/ScriptedModule.hx index 31c79addb..7369707e3 100644 --- a/source/funkin/modding/module/ScriptedModule.hx +++ b/source/funkin/modding/module/ScriptedModule.hx @@ -3,7 +3,4 @@ package funkin.modding.module; import funkin.modding.IHook; @:hscriptClass -class ScriptedModule extends Module implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedModule extends Module implements IHook {} diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index 8413110ca..235db587e 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -28,19 +28,23 @@ class Countdown * 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(isPixelStyle:Bool):Void + public static function performCountdown(isPixelStyle:Bool):Bool { + countdownStep = BEFORE; + var cancelled:Bool = propagateCountdownEvent(countdownStep); + if (cancelled) + return false; + // Stop any existing countdown. stopCountdown(); PlayState.isInCountdown = true; Conductor.songPosition = Conductor.crochet * -5; - countdownStep = BEFORE; - - var cancelled:Bool = propagateCountdownEvent(countdownStep); - if (cancelled) - return; + // Handle onBeatHit events manually + @:privateAccess + PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); // The timer function gets called based on the beat of the song. countdownTimer = new FlxTimer(); @@ -49,9 +53,9 @@ class Countdown { countdownStep = decrement(countdownStep); - // Play the dance animations manually. + // Handle onBeatHit events manually @:privateAccess - PlayState.instance.danceOnBeat(); + PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); // Countdown graphic. showCountdownGraphic(countdownStep, isPixelStyle); @@ -69,7 +73,9 @@ class Countdown { stopCountdown(); } - }, 6); // Before, 3, 2, 1, GO!, After + }, 5); // Before, 3, 2, 1, GO!, After + + return true; } /** @@ -91,11 +97,9 @@ class Countdown return true; } - // Stage - ScriptEventDispatcher.callEvent(PlayState.instance.currentStage, event); - - // Modules - ModuleHandler.callEvent(event); + // Modules, stages, characters. + @:privateAccess + PlayState.instance.dispatchEvent(event); return event.eventCanceled; } diff --git a/source/funkin/play/Fighter.hx b/source/funkin/play/Fighter.hx index fa17b6bd7..6a25283ba 100644 --- a/source/funkin/play/Fighter.hx +++ b/source/funkin/play/Fighter.hx @@ -1,19 +1,22 @@ package funkin.play; +import funkin.play.character.BaseCharacter; import flixel.FlxSprite; -class Fighter extends Character +class Fighter extends BaseCharacter { public function new(?x:Float = 0, ?y:Float = 0, ?char:String = "pico-fighter") { - super(x, y, char); + super(char); + this.x = x; + this.y = y; animation.finishCallback = function(anim:String) { switch anim { case "punch low" | "punch high" | "block" | 'dodge': - dance(); + dance(true); } }; } @@ -42,20 +45,20 @@ class Fighter extends Character function dodge() { - playAnim('dodge'); + playAnimation('dodge'); curAction = DODGE; } public function block() { - playAnim('block'); + playAnimation('block'); curAction = BLOCK; } public function punch() { curAction = PUNCH; - playAnim('punch ' + (FlxG.random.bool() ? "low" : "high")); + playAnimation('punch ' + (FlxG.random.bool() ? "low" : "high")); } } diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/HealthIcon.hx new file mode 100644 index 000000000..083d26783 --- /dev/null +++ b/source/funkin/play/HealthIcon.hx @@ -0,0 +1,441 @@ +package funkin.play; + +import flixel.FlxSprite; +import flixel.math.FlxMath; +import flixel.math.FlxPoint; +import funkin.play.character.CharacterData.CharacterDataParser; +import openfl.utils.Assets; + +/** + * 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 MasterEric + */ +class HealthIcon extends FlxSprite +{ + /** + * The character this icon is representing. + * Setting this variable will automatically update the graphic. + */ + public var characterId(default, set):String; + + /** + * 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: + * - Bumping 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 + */ + public var size:FlxPoint = new FlxPoint(1, 1); + + /** + * Apply the "bump" animation once every X steps. + */ + public var bumpEvery:Int = 4; + + /** + * The player the health icon is attached to. + */ + var playerId:Int = 0; + + /** + * Whether the sprite is pixel art or not. + * Calculated when loading an icon. + */ + var isPixel: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 = 0.8 * 2; + + /** + * At this amount of health, play the Losing animation instead of the idle. + */ + static final LOSING_THRESHOLD = 0.2 * 2; + + /** + * The maximum health of the player. + */ + static final MAXIMUM_HEALTH = 2; + + /** + * The size of a non-pixel icon when using the legacy format. + * Remember, modern icons can be any size. + */ + static final LEGACY_ICON_SIZE = 150; + + /** + * The size of a pixel icon when using the legacy format. + * Remember, modern icons can be any size. + */ + static final LEGACY_PIXEL_SIZE = 32; + + /** + * shitty hardcoded value for a specific positioning!!! + */ + static final POSITION_OFFSET = 26; + + public function new(char:String = 'bf', playerId:Int = 0) + { + super(0, 0); + this.playerId = playerId; + this.scrollFactor.set(); + + this.characterId = char; + + this.antialiasing = !isPixel; + + this.flipX = playerId == 0; + + initTargetSize(); + } + + function set_characterId(value:String):String + { + if (value == characterId) + return value; + + characterId = value; + loadCharacter(characterId); + return value; + } + + /** + * Easter egg; press 9 in the PlayState to use the old player icon. + */ + public function toggleOldIcon():Void + { + if (characterId == 'bf-old') + { + characterId = PlayState.currentSong.player1; + } + else + { + characterId = 'bf-old'; + } + } + + /** + * Called by Flixel every frame. Includes logic to manage the currently playing animation. + */ + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (PlayState.instance == null) + return; + + // Auto-update the state of the icon based on the player's health. + if (autoUpdate) + { + 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); + } + + // 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 = Std.int(CoolUtil.coolLerp(this.width, 150 * this.size.x, 0.15)); + + setGraphicSize(targetSize, 0); + } + else + { + var targetSize = Std.int(CoolUtil.coolLerp(this.height, 150 * this.size.y, 0.15)); + + setGraphicSize(0, targetSize); + } + this.updateHitbox(); + } + } + + public function onStepHit(curStep:Int) + { + if (curStep % bumpEvery == 0 && isLegacyStyle) + { + // Make the health icons bump (the update function causes them to lerp back down). + if (this.width > this.height) + { + var targetSize = Std.int(CoolUtil.coolLerp(this.width + 30, 150, 0.15)); + + setGraphicSize(targetSize, 0); + } + else + { + var targetSize = Std.int(CoolUtil.coolLerp(this.height + 30, 150, 0.15)); + + setGraphicSize(0, targetSize); + } + this.updateHitbox(); + } + } + + inline function initTargetSize() + { + setGraphicSize(150); + updateHitbox(); + } + + function updateHealthIcon(health:Float) + { + // 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(TO_LOSING, LOSING); + else if (health > WINNING_THRESHOLD) + playAnimation(TO_WINNING, WINNING); + else + playAnimation(IDLE); + case WINNING: + if (health < WINNING_THRESHOLD) + playAnimation(FROM_WINNING, IDLE); + else + playAnimation(WINNING, IDLE); + case LOSING: + if (health > LOSING_THRESHOLD) + playAnimation(FROM_LOSING, IDLE); + else + playAnimation(LOSING, IDLE); + case TO_LOSING: + if (isAnimationFinished()) + playAnimation(LOSING, IDLE); + case TO_WINNING: + if (isAnimationFinished()) + playAnimation(WINNING, IDLE); + case FROM_LOSING | FROM_WINNING: + if (isAnimationFinished()) + playAnimation(IDLE); + case "": + playAnimation(IDLE); + default: + playAnimation(IDLE); + } + } + + /** + * Load + * @param charId + */ + function loadAnimationNew(charId:String):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(TO_WINNING, TO_WINNING, 24, true); + this.animation.addByPrefix(TO_LOSING, TO_LOSING, 24, true); + this.animation.addByPrefix(FROM_WINNING, FROM_WINNING, 24, true); + this.animation.addByPrefix(FROM_LOSING, FROM_LOSING, 24, true); + } + + /** + * Load health icon animations using the legacy format. + * Simply assumes two icons, one on + * @param charId + */ + function loadAnimationOld(charId:String):Void + { + this.animation.add(IDLE, [0], 0, false, this.playerId == 0); + this.animation.add(LOSING, [1], 0, false, this.playerId == 0); + } + + function correctCharacterId(charId:String):String + { + if (!Assets.exists(Paths.image('icons/icon-$charId'))) + { + FlxG.log.warn('No icon for character: $charId : using default placeholder face instead!'); + return "face"; + } + + return charId; + } + + function isNewSpritesheet(charId:String):Bool + { + return Assets.exists(Paths.file('images/icons/icon-$characterId.xml')); + } + + function fetchIsPixel(charId:String):Bool + { + var charData = CharacterDataParser.fetchCharacterData(charId); + if (charData == null) + { + FlxG.log.warn('No character data found for character: $charId'); + return false; + } + return charData.isPixel; + } + + function loadCharacter(charId:String):Void + { + if (correctCharacterId(charId) != charId) + { + characterId = correctCharacterId(charId); + return; + } + + isPixel = fetchIsPixel(charId); + + isLegacyStyle = !isNewSpritesheet(charId); + + if (!isLegacyStyle) + { + frames = Paths.getSparrowAtlas('icons/icon-$charId'); + + loadAnimationNew(charId); + } + else + { + loadGraphic(Paths.image('icons/icon-$charId'), true, isPixel ? LEGACY_PIXEL_SIZE : LEGACY_ICON_SIZE, + isPixel ? LEGACY_PIXEL_SIZE : LEGACY_ICON_SIZE); + + loadAnimationOld(charId); + } + } + + /** + * @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; + } + + /** + * @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. + } +} + +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%. + */ + var IDLE = "idle"; + + /** + * Indicates the health icon is playing the Winning animation. + * Plays as long as health is above 80%. + */ + var WINNING = "winning"; + + /** + * Indicates the health icon is playing the Losing animation. + * Plays as long as health is below 20%. + */ + var LOSING = "losing"; + + /** + * Indicates that the health icon is transitioning between `idle` and `winning`. + * The next animation will play once the current animation finishes. + */ + var TO_WINNING = "toWinning"; + + /** + * Indicates that the health icon is transitioning between `idle` and `losing`. + * The next animation will play once the current animation finishes. + */ + var TO_LOSING = "toLosing"; + + /** + * Indicates that the health icon is transitioning between `winning` and `idle`. + * The next animation will play once the current animation finishes. + */ + var FROM_WINNING = "fromWinning"; + + /** + * Indicates that the health icon is transitioning between `losing` and `idle`. + * The next animation will play once the current animation finishes. + */ + var FROM_LOSING = "fromLosing"; +} diff --git a/source/funkin/play/PicoFight.hx b/source/funkin/play/PicoFight.hx index f37bcb08f..a5dddac3a 100644 --- a/source/funkin/play/PicoFight.hx +++ b/source/funkin/play/PicoFight.hx @@ -178,7 +178,7 @@ class PicoFight extends MusicBeatState pico.punch(); } if (controls.NOTE_LEFT_R) - pico.playAnim('idle'); + pico.playAnimation('idle'); super.update(elapsed); } @@ -190,8 +190,10 @@ class PicoFight extends MusicBeatState override function beatHit():Bool { + // super.beatHit() returns false if a module cancelled the event. if (!super.beatHit()) return false; + funnyWave.thickness = 10; funnyWave.waveAmplitude = 300; funnyWave.realtimeVisLenght = 0.1; diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index a127e70b2..1b4e652ec 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -6,6 +6,7 @@ import flixel.FlxSprite; import flixel.FlxState; import flixel.FlxSubState; import flixel.addons.transition.FlxTransitionableState; +import flixel.addons.transition.FlxTransitionableState; import flixel.group.FlxGroup; import flixel.math.FlxMath; import flixel.math.FlxPoint; @@ -18,18 +19,29 @@ import flixel.util.FlxColor; import flixel.util.FlxSort; import flixel.util.FlxTimer; import funkin.Note; +import funkin.Note; import funkin.Section.SwagSection; +import funkin.Section.SwagSection; +import funkin.SongLoad.SwagSong; import funkin.SongLoad.SwagSong; import funkin.charting.ChartingState; import funkin.modding.IHook; +import funkin.modding.IHook; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; +import funkin.modding.module.ModuleHandler; +import funkin.play.HealthIcon; +import funkin.play.Strumline.StrumlineArrow; import funkin.play.Strumline.StrumlineArrow; import funkin.play.Strumline.StrumlineStyle; +import funkin.play.Strumline.StrumlineStyle; +import funkin.play.character.BaseCharacter; +import funkin.play.character.CharacterData; import funkin.play.stage.Stage; import funkin.play.stage.StageData; import funkin.ui.PopUpStuff; import funkin.ui.PreferencesMenu; +import funkin.ui.stageBuildShit.StageOffsetSubstate; import funkin.util.Constants; import funkin.util.SortUtil; import lime.ui.Haptic; @@ -80,6 +92,12 @@ class PlayState extends MusicBeatState implements IHook */ public static var isInCountdown:Bool = false; + /** + * Gets set to true when the PlayState needs to reset (player opted to restart or died). + * Gets disabled once resetting happens. + */ + public static 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. @@ -119,11 +137,20 @@ class PlayState extends MusicBeatState implements IHook */ public var health:Float = 1; + /** + * The player's current score. + */ + public var songScore:Int = 0; + /** * An empty FlxObject contained in the scene. * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly. + * + * This is an FlxSprite for two reasons: + * 1. It needs to be an object in the scene for the camera to be configured to follow it. + * 2. It needs to be an FlxSprite to allow a graphic (optionally, for debug purposes) to be drawn on it. */ - public var cameraFollowPoint:FlxObject; + public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0); /** * PRIVATE INSTANCE VARIABLES @@ -165,7 +192,7 @@ class PlayState extends MusicBeatState implements IHook * The bar which displays the player's health. * Dynamically updated based on the value of `healthLerp` (which is based on `health`). */ - private var healthBar:FlxBar; + public var healthBar:FlxBar; /** * The background image used for the health bar. @@ -173,6 +200,16 @@ class PlayState extends MusicBeatState implements IHook */ public var healthBarBG:FlxSprite; + /** + * 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. */ @@ -216,7 +253,6 @@ class PlayState extends MusicBeatState implements IHook public static var storyWeek:Int = 0; public static var storyPlaylist:Array = []; public static var storyDifficulty:Int = 1; - public static var needsReset:Bool = false; public static var seenCutscene:Bool = false; public static var campaignScore:Int = 0; @@ -228,15 +264,11 @@ class PlayState extends MusicBeatState implements IHook private var combo:Int = 0; private var generatedMusic:Bool = false; private var startingSong:Bool = false; - private var iconP1:HealthIcon; - private var iconP2:HealthIcon; var dialogue:Array; var talking:Bool = true; - var songScore:Int = 0; var doof:DialogueBox; var grpNoteSplashes:FlxTypedGroup; - var camPos:FlxPoint; var comboPopUps:PopUpStuff; var perfectMode:Bool = false; var previousFrameTime:Int = 0; @@ -258,6 +290,12 @@ class PlayState extends MusicBeatState implements IHook instance = this; + // Displays the camera follow point as a sprite for debug purposes. + // TODO: Put this on a toggle? + cameraFollowPoint.makeGraphic(8, 8, 0xFF00FF00); + cameraFollowPoint.visible = false; + cameraFollowPoint.zIndex = 1000000; + // Reduce physics accuracy (who cares!!!) to improve animation quality. FlxG.fixedTimestep = false; @@ -305,12 +343,32 @@ class PlayState extends MusicBeatState implements IHook // Once the song is loaded, we can continue and initialize the stage. + var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9; + healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar')); + healthBarBG.screenCenter(X); + healthBarBG.scrollFactor.set(0, 0); + 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.HEALTH_BAR_RED, Constants.HEALTH_BAR_GREEN); + add(healthBar); + initStage(); initCharacters(); #if discord_rpc initDiscord(); #end + // Configure camera follow point. + if (previousCameraFollowPoint != null) + { + cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y); + previousCameraFollowPoint = null; + } + add(cameraFollowPoint); + comboPopUps = new PopUpStuff(); add(comboPopUps); @@ -324,45 +382,15 @@ class PlayState extends MusicBeatState implements IHook generateSong(); - cameraFollowPoint = new FlxObject(0, 0, 1, 1); - cameraFollowPoint.setPosition(camPos.x, camPos.y); - - if (previousCameraFollowPoint != null) - { - cameraFollowPoint = previousCameraFollowPoint; - previousCameraFollowPoint = null; - } - - add(cameraFollowPoint); resetCamera(); FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height); - var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9; - healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar')); - healthBarBG.screenCenter(X); - healthBarBG.scrollFactor.set(0, 0); - 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.HEALTH_BAR_RED, Constants.HEALTH_BAR_GREEN); - add(healthBar); - 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(); add(scoreText); - iconP1 = new HealthIcon(currentSong.player1, true); - iconP1.y = healthBar.y - (iconP1.height / 2); - add(iconP1); - - iconP2 = new HealthIcon(currentSong.player2, false); - iconP2.y = healthBar.y - (iconP2.height / 2); - add(iconP2); - // Attach the groups to the HUD camera so they are rendered independent of the stage. grpNoteSplashes.cameras = [camHUD]; activeNotes.cameras = [camHUD]; @@ -371,6 +399,8 @@ class PlayState extends MusicBeatState implements IHook iconP1.cameras = [camHUD]; iconP2.cameras = [camHUD]; scoreText.cameras = [camHUD]; + leftWatermarkText.cameras = [camHUD]; + rightWatermarkText.cameras = [camHUD]; // if (SONG.song == 'South') // FlxG.camera.alpha = 0.7; @@ -454,6 +484,9 @@ class PlayState extends MusicBeatState implements IHook currentStageId = 'schoolEvil'; case 'guns' | 'stress' | 'ugh': currentStageId = 'tankmanBattlefield'; + case 'experimental-phase' | 'perfection': + // SERIOUSLY REVAMP THE CHART FORMAT ALREADY + currentStageId = "breakout"; default: currentStageId = "mainStage"; } @@ -463,7 +496,19 @@ class PlayState extends MusicBeatState implements IHook function initCharacters() { - // all dis is shitty, redo later for stage shit + iconP1 = new HealthIcon(currentSong.player1, 0); + iconP1.y = healthBar.y - (iconP1.height / 2); + add(iconP1); + + iconP2 = new HealthIcon(currentSong.player2, 1); + iconP2.y = healthBar.y - (iconP2.height / 2); + add(iconP2); + + // + // GIRLFRIEND + // + + // TODO: Tie the GF version to the song data, not the stage ID or the current player. var gfVersion:String = 'gf'; switch (currentStageId) @@ -476,102 +521,84 @@ class PlayState extends MusicBeatState implements IHook gfVersion = 'gf-pixel'; case 'tankmanBattlefield': gfVersion = 'gf-tankmen'; + case 'breakout': + // SERIOUSLY PUT THIS SHIT IN THE CHART + gfVersion = ''; } if (currentSong.player1 == "pico") - { gfVersion = "nene"; - } if (currentSong.song.toLowerCase() == 'stress') gfVersion = 'pico-speaker'; - var gf = new Character(400, 130, gfVersion); - gf.scrollFactor.set(0.95, 0.95); + if (currentSong.song.toLowerCase() == 'tutorial') + gfVersion = ''; - switch (gfVersion) + // + // GIRLFRIEND + // + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion); + + if (girlfriend != null) { - case 'pico-speaker': - gf.x -= 50; - gf.y -= 200; + girlfriend.characterType = CharacterType.GF; + girlfriend.scrollFactor.set(0.95, 0.95); + if (gfVersion == 'pico-speaker') + { + girlfriend.x -= 50; + girlfriend.y -= 200; + } + } + else if (gfVersion != '') + { + trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...'); } - var dad = new Character(100, 100, currentSong.player2); + // + // DAD + // + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2); - camPos = new FlxPoint(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y); + if (dad != null) + { + dad.characterType = CharacterType.DAD; + } switch (currentSong.player2) { case 'gf': - dad.setPosition(gf.x, gf.y); - gf.visible = false; if (isStoryMode) { - camPos.x += 600; + cameraFollowPoint.x += 600; tweenCamIn(); } - case "spooky": - dad.y += 200; - case "monster": - dad.y += 100; - case 'monster-christmas': - dad.y += 130; - case 'dad': - camPos.x += 400; - case 'pico': - camPos.x += 600; - dad.y += 300; - case 'parents-christmas': - dad.x -= 500; - case 'senpai' | 'senpai-angry': - dad.x += 150; - dad.y += 360; - camPos.set(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); - case 'spirit': - dad.x -= 150; - dad.y += 100; - camPos.set(dad.getGraphicMidpoint().x + 300, dad.getGraphicMidpoint().y); - case 'tankman': - dad.y += 180; } - var boyfriend = new Boyfriend(770, 450, currentSong.player1); + // + // BOYFRIEND + // + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1); - // REPOSITIONING PER STAGE - switch (currentStageId) + if (boyfriend != null) { - case "tank": - gf.y += 10; - gf.x -= 30; - boyfriend.x += 40; - boyfriend.y += 0; - dad.y += 60; - dad.x -= 80; - - if (gfVersion != 'pico-speaker') - { - gf.x -= 170; - gf.y -= 75; - } + boyfriend.characterType = CharacterType.BF; } if (currentStage != null) { // We're using Eric's stage handler. // Characters get added to the stage, not the main scene. - currentStage.addCharacter(gf, GF); + currentStage.addCharacter(girlfriend, GF); currentStage.addCharacter(boyfriend, BF); currentStage.addCharacter(dad, DAD); + // Camera starts at dad. + cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y); + // Redo z-indexes. currentStage.refresh(); } - else - { - add(gf); - add(dad); - add(boyfriend); - } } /** @@ -598,6 +625,15 @@ class PlayState extends MusicBeatState implements IHook super.debug_refreshModules(); } + /** + * Pauses music and vocals easily. + */ + public function pauseMusic() + { + FlxG.sound.music.pause(); + vocals.pause(); + } + /** * Loads stage data from cache, assembles the props, * and adds it to the state. @@ -614,7 +650,7 @@ class PlayState extends MusicBeatState implements IHook ScriptEventDispatcher.callEvent(currentStage, event); // Apply camera zoom. - defaultCameraZoom *= currentStage.camZoom; + defaultCameraZoom = currentStage.camZoom; // Add the stage to the scene. this.add(currentStage); @@ -665,8 +701,6 @@ class PlayState extends MusicBeatState implements IHook senpaiEvil.screenCenter(); senpaiEvil.x += senpaiEvil.width / 5; - cameraFollowPoint.setPosition(camPos.x, camPos.y); - if (currentSong.song.toLowerCase() == 'roses' || currentSong.song.toLowerCase() == 'thorns') { remove(black); @@ -828,7 +862,18 @@ class PlayState extends MusicBeatState implements IHook else oldNote = null; - var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote); + var strumlineStyle:StrumlineStyle = NORMAL; + + // TODO: Put this in the chart or something? + switch (currentStageId) + { + case 'school': + strumlineStyle = PIXEL; + case 'schoolEvil': + strumlineStyle = PIXEL; + } + + var swagNote:Note = new Note(daStrumTime, daNoteData, oldNote, false, strumlineStyle); // swagNote.data = songNotes; swagNote.data.sustainLength = songNotes.sustainLength; swagNote.data.altNote = songNotes.altNote; @@ -843,7 +888,8 @@ class PlayState extends MusicBeatState implements IHook { oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; - var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true); + var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, + strumlineStyle); sustainNote.scrollFactor.set(); inactiveNotes.push(sustainNote); @@ -915,6 +961,11 @@ class PlayState extends MusicBeatState implements IHook { super.update(elapsed); + if (FlxG.keys.justPressed.U) + { + openSubState(new StageOffsetSubstate()); + } + updateHealthBar(); updateScoreText(); @@ -1032,32 +1083,11 @@ class PlayState extends MusicBeatState implements IHook FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); if (FlxG.keys.justPressed.NINE) - iconP1.swapOldIcon(); - - iconP1.setGraphicSize(Std.int(CoolUtil.coolLerp(iconP1.width, 150, 0.15))); - iconP2.setGraphicSize(Std.int(CoolUtil.coolLerp(iconP2.width, 150, 0.15))); - - iconP1.updateHitbox(); - iconP2.updateHitbox(); - - var iconOffset:Int = 26; - - iconP1.x = healthBar.x + (healthBar.width * (FlxMath.remapToRange(healthBar.value, 0, 2, 100, 0) * 0.01) - iconOffset); - iconP2.x = healthBar.x + (healthBar.width * (FlxMath.remapToRange(healthBar.value, 0, 2, 100, 0) * 0.01)) - (iconP2.width - iconOffset); + iconP1.toggleOldIcon(); if (health > 2) health = 2; - if (healthBar.percent < 20) - iconP1.animation.curAnim.curFrame = 1; - else - iconP1.animation.curAnim.curFrame = 0; - - if (healthBar.percent > 80) - iconP2.animation.curAnim.curFrame = 1; - else - iconP2.animation.curAnim.curFrame = 0; - #if debug if (FlxG.keys.justPressed.ONE) endSong(); @@ -1068,14 +1098,7 @@ class PlayState extends MusicBeatState implements IHook changeSection(-1); #end - if (generatedMusic && SongLoad.getSong()[Std.int(curStep / 16)] != null) - { - cameraRightSide = SongLoad.getSong()[Std.int(curStep / 16)].mustHitSection; - - cameraMovement(); - } - - if (camZooming) + if (camZooming && subState == null) { FlxG.camera.zoom = FlxMath.lerp(defaultCameraZoom, FlxG.camera.zoom, 0.95); camHUD.zoom = FlxMath.lerp(1 * FlxCamera.defaultZoom, camHUD.zoom, 0.95); @@ -1083,6 +1106,7 @@ class PlayState extends MusicBeatState implements IHook FlxG.watch.addQuick("beatShit", curBeat); FlxG.watch.addQuick("stepShit", curStep); + FlxG.watch.addQuick("songPos", Conductor.songPosition); if (currentSong.song == 'Fresh') { @@ -1127,6 +1151,8 @@ class PlayState extends MusicBeatState implements IHook deathCounter += 1; + dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER)); + openSubState(new GameOverSubstate()); #if discord_rpc @@ -1144,7 +1170,7 @@ class PlayState extends MusicBeatState implements IHook inactiveNotes.shift(); } - if (generatedMusic) + if (generatedMusic && playerStrumline != null) { activeNotes.forEachAlive(function(daNote:Note) { @@ -1195,35 +1221,28 @@ class PlayState extends MusicBeatState implements IHook } } - if (!daNote.mustPress && daNote.wasGoodHit) + if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate) { if (currentSong.song != 'Tutorial') camZooming = true; - var altAnim:String = ""; + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true); + dispatchEvent(event); - if (SongLoad.getSong()[Math.floor(curStep / 16)] != null) + // Calling event.cancelEvent() in a module should force the CPU to miss the note. + // This is useful for cool shit, including but not limited to: + // - Making the AI ignore notes which are hazardous. + // - Making the AI miss notes on purpose for aesthetic reasons. + if (event.eventCanceled) { - if (SongLoad.getSong()[Math.floor(curStep / 16)].altAnim) - altAnim = '-alt'; + daNote.tooLate = true; } - - if (daNote.data.altNote) - altAnim = '-alt'; - - if (!daNote.isSustainNote) + else { - currentStage.getDad().playAnim('sing' + daNote.dirNameUpper + altAnim, true); + // Volume of DAD. + if (currentSong.needsVoices) + vocals.volume = 1; } - - currentStage.getDad().holdTimer = 0; - - if (currentSong.needsVoices) - vocals.volume = 1; - - daNote.kill(); - activeNotes.remove(daNote, true); - daNote.destroy(); } // WIP interpolation shit? Need to fix the pause issue @@ -1248,20 +1267,8 @@ class PlayState extends MusicBeatState implements IHook daNote.destroy(); } } - else if (daNote.tooLate || daNote.wasGoodHit) + if (daNote.wasGoodHit) { - // TODO: Why the hell is the noteMiss logic in two different places? - if (daNote.tooLate) - { - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, daNote, true); - dispatchEvent(event); - - // lose less health on sustain notes! - health -= 0.0775 * (daNote.isSustainNote ? 0.2 : 1); // if it's sustain, multiply it by 0.2 (not checked for balence yet), else keep it same (multiply by 1) - vocals.volume = 0; - killCombo(); - } - daNote.active = false; daNote.visible = false; @@ -1269,6 +1276,11 @@ class PlayState extends MusicBeatState implements IHook activeNotes.remove(daNote, true); daNote.destroy(); } + + if (daNote.tooLate) + { + noteMiss(daNote); + } }); } @@ -1300,8 +1312,10 @@ class PlayState extends MusicBeatState implements IHook function killCombo():Void { - if (combo > 5 && currentStage.getGirlfriend().animOffsets.exists('sad')) - currentStage.getGirlfriend().playAnim('sad'); + // Girlfriend gets sad if you combo break after hitting 5 notes. + if (currentStage != null && currentStage.getGirlfriend() != null) + if (combo > 5 && currentStage.getGirlfriend().hasAnimation('sad')) + currentStage.getGirlfriend().playAnimation('sad'); if (combo != 0) { @@ -1432,7 +1446,7 @@ class PlayState extends MusicBeatState implements IHook private function popUpScore(strumtime:Float, daNote:Note):Void { var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition); - // boyfriend.playAnim('hey'); + // boyfriend.playAnimation('hey'); vocals.volume = 1; var score:Int = 350; @@ -1465,15 +1479,6 @@ class PlayState extends MusicBeatState implements IHook health += healthMulti; - // TODO: Redo note hit logic to make sure this always gets called - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true); - dispatchEvent(event); - - if (event.eventCanceled) - { - // TODO: Do a thing! - } - if (isSick) { var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash); @@ -1492,61 +1497,44 @@ class PlayState extends MusicBeatState implements IHook comboPopUps.displayCombo(combo); } - function cameraMovement() + function controlCamera() { if (currentStage == null) return; - if (cameraFollowPoint.x != currentStage.getDad().getMidpoint().x + 150 && !cameraRightSide) + var isFocusedOnDad = cameraFollowPoint.x == currentStage.getDad().cameraFocusPoint.x; + var isFocusedOnBF = cameraFollowPoint.x == currentStage.getBoyfriend().cameraFocusPoint.x; + + if (cameraRightSide && !isFocusedOnBF) { - cameraFollowPoint.setPosition(currentStage.getDad().getMidpoint().x + 150, currentStage.getDad().getMidpoint().y - 100); - // camFollow.setPosition(lucky.getMidpoint().x - 120, lucky.getMidpoint().y + 210); + // Focus the camera on the player. + cameraFollowPoint.setPosition(currentStage.getBoyfriend().cameraFocusPoint.x, currentStage.getBoyfriend().cameraFocusPoint.y); - switch (currentStage.getDad().curCharacter) + // TODO: Un-hardcode this. + if (currentSong.song.toLowerCase() == 'tutorial') + FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); + } + else if (!cameraRightSide && !isFocusedOnDad) + { + // Focus the camera on the opponent. + cameraFollowPoint.setPosition(currentStage.getDad().cameraFocusPoint.x, currentStage.getDad().cameraFocusPoint.y); + + // TODO: Un-hardcode this stuff. + if (currentStage.getDad().characterId == 'mom') { - case 'mom': - cameraFollowPoint.y = currentStage.getDad().getMidpoint().y; - case 'senpai' | 'senpai-angry': - cameraFollowPoint.y = currentStage.getDad().getMidpoint().y - 430; - cameraFollowPoint.x = currentStage.getDad().getMidpoint().x - 100; - } - - if (currentStage.getDad().curCharacter == 'mom') vocals.volume = 1; + } if (currentSong.song.toLowerCase() == 'tutorial') tweenCamIn(); } - - if (cameraRightSide && cameraFollowPoint.x != currentStage.getBoyfriend().getMidpoint().x - 100) - { - cameraFollowPoint.setPosition(currentStage.getBoyfriend().getMidpoint().x - 100, currentStage.getBoyfriend().getMidpoint().y - 100); - - switch (currentStageId) - { - case 'limo': - cameraFollowPoint.x = currentStage.getBoyfriend().getMidpoint().x - 300; - case 'mall': - cameraFollowPoint.y = currentStage.getBoyfriend().getMidpoint().y - 200; - case 'school' | 'schoolEvil': - cameraFollowPoint.x = currentStage.getBoyfriend().getMidpoint().x - 200; - cameraFollowPoint.y = currentStage.getBoyfriend().getMidpoint().y - 200; - } - - if (currentSong.song.toLowerCase() == 'tutorial') - FlxTween.tween(FlxG.camera, {zoom: 1 * FlxCamera.defaultZoom}, (Conductor.stepCrochet * 4 / 1000), {ease: FlxEase.elasticInOut}); - } } - public var test:(PlayState) -> Void = function(instance:PlayState) - { - trace('test'); - trace(instance.currentStageId); - }; - - @:hookable public function keyShit(test:Bool):Void { + if (PlayState.instance == null) + return; + // control arrays, order L D R U var holdArray:Array = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; var pressArray:Array = [ @@ -1630,7 +1618,7 @@ class PlayState extends MusicBeatState implements IHook for (shit in 0...pressArray.length) { // if a direction is hit that shouldn't be if (pressArray[shit] && !directionList.contains(shit)) - PlayState.instance.noteMiss(shit); + PlayState.instance.ghostNoteMiss(shit); } for (coolNote in possibleNotes) { @@ -1640,26 +1628,20 @@ class PlayState extends MusicBeatState implements IHook } else { + // HNGGG I really want to add an option for ghost tapping for (shit in 0...pressArray.length) if (pressArray[shit]) - PlayState.instance.noteMiss(shit); + PlayState.instance.ghostNoteMiss(shit, false); } } if (PlayState.instance == null || PlayState.instance.currentStage == null) return; - if (PlayState.instance.currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true)) - { - if (PlayState.instance.currentStage.getBoyfriend().animation != null - && PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing') - && !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss')) - { - PlayState.instance.currentStage.getBoyfriend().playAnim('idle'); - } - } for (keyId => isPressed in pressArray) { + if (playerStrumline == null) + continue; var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId); if (isPressed && arrow.animation.curAnim.name != 'confirm') @@ -1673,33 +1655,78 @@ class PlayState extends MusicBeatState implements IHook } } - function noteMiss(direction:NoteDir = 1):Void + /** + * 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:NoteType = 1, hasPossibleNotes:Bool = true):Void { - // whole function used to be encased in if (!boyfriend.stunned) - health -= 0.07; - killCombo(); + var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in. + hasPossibleNotes, // Whether there was a note you could have hit. + - 0.035 * 2, // 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; + if (!isPracticeMode) + songScore += event.scoreChange; + + if (event.playSound) + { + vocals.volume = 0; + FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + } + } + + function noteMiss(note:Note):Void + { + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, true); + dispatchEvent(event); + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) + return; + + health -= 0.0775; if (!isPracticeMode) songScore -= 10; - vocals.volume = 0; - FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + killCombo(); - currentStage.getBoyfriend().playAnim('sing' + direction.nameUpper + 'miss', true); + note.active = false; + note.visible = false; + + note.kill(); + activeNotes.remove(note, true); + note.destroy(); } function goodNoteHit(note:Note):Void { if (!note.wasGoodHit) { + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) + return; + if (!note.isSustainNote) { combo += 1; popUpScore(note.data.strumTime, note); } - currentStage.getBoyfriend().playAnim('sing' + note.dirNameUpper, true); - playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true); note.wasGoodHit = true; @@ -1726,7 +1753,8 @@ class PlayState extends MusicBeatState implements IHook resyncVocals(); } - dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_STEP_HIT, curBeat, curStep)); + iconP1.onStepHit(curStep); + iconP2.onStepHit(curStep); return true; } @@ -1742,6 +1770,13 @@ class PlayState extends MusicBeatState implements IHook activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING); } + // Moving this code into the `beatHit` function allows for scripts and modules to control the camera better. + if (generatedMusic && SongLoad.getSong()[Std.int(curStep / 16)] != null) + { + cameraRightSide = SongLoad.getSong()[Std.int(curStep / 16)].mustHitSection; + controlCamera(); + } + if (SongLoad.getSong()[Math.floor(curStep / 16)] != null) { if (SongLoad.getSong()[Math.floor(curStep / 16)].changeBPM) @@ -1768,11 +1803,31 @@ class PlayState extends MusicBeatState implements IHook } } - // Make the health icons bump (the update function causes them to lerp back down). - iconP1.setGraphicSize(Std.int(iconP1.width + 30)); - iconP2.setGraphicSize(Std.int(iconP2.width + 30)); - iconP1.updateHitbox(); - iconP2.updateHitbox(); + // That combo counter 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 + + var shouldShowComboText:Bool = (curBeat % 8 == 7) // End of measure. TODO: Is this always the correct time? + && (SongLoad.getSong()[Std.int(curStep / 16)].mustHitSection) // Current section is BF's. + && (combo > 5) // Don't want to show on small combos. + && ((SongLoad.getSong().length < Std.int(curStep / 16)) // Show at the end of the song. + || (!SongLoad.getSong()[Std.int(curStep / 16) + 1].mustHitSection) // Or when the next section is Dad's. + ); + + if (shouldShowComboText) + { + var animShit:ComboCounter = new ComboCounter(-100, 300, combo); + animShit.scrollFactor.set(0.6, 0.6); + add(animShit); + + var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation + + new FlxTimer().start(((Conductor.crochet / 1000) * 1.25) - frameShit, function(tmr) + { + animShit.forceFinish(); + }); + } // Make the characters dance on the beat danceOnBeat(); @@ -1790,35 +1845,19 @@ class PlayState extends MusicBeatState implements IHook if (currentStage == null) return; - if (curBeat % gfSpeed == 0) - currentStage.getGirlfriend().dance(); - - if (curBeat % 2 == 0) - { - if (currentStage.getBoyfriend().animation != null && !currentStage.getBoyfriend().animation.curAnim.name.startsWith("sing")) - currentStage.getBoyfriend().playAnim('idle'); - if (currentStage.getDad().animation != null && !currentStage.getDad().animation.curAnim.name.startsWith("sing")) - currentStage.getDad().dance(); - } - else if (currentStage.getDad().curCharacter == 'spooky') - { - if (!currentStage.getDad().animation.curAnim.name.startsWith("sing")) - currentStage.getDad().dance(); - } - if (curBeat % 8 == 7 && currentSong.song == 'Bopeebo') { - currentStage.getBoyfriend().playAnim('hey', true); + currentStage.getBoyfriend().playAnimation('hey', true); } if (curBeat % 16 == 15 && currentSong.song == 'Tutorial' - && currentStage.getDad().curCharacter == 'gf' + && currentStage.getDad().characterId == 'gf' && curBeat > 16 && curBeat < 48) { - currentStage.getBoyfriend().playAnim('hey', true); - currentStage.getDad().playAnim('cheer', true); + currentStage.getBoyfriend().playAnimation('hey', true); + currentStage.getDad().playAnimation('cheer', true); } } @@ -1911,7 +1950,7 @@ class PlayState extends MusicBeatState implements IHook if (event.eventCanceled) return; - if (FlxG.sound.music != null && !startingSong) + if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); // Resume the countdown. @@ -1935,13 +1974,15 @@ class PlayState extends MusicBeatState implements IHook */ function startCountdown():Void { + var result = Countdown.performCountdown(currentStageId.startsWith('school')); + if (!result) + return; + isInCutscene = false; camHUD.visible = true; talking = false; buildStrumlines(); - - Countdown.performCountdown(currentStageId.startsWith('school')); } override function dispatchEvent(event:ScriptEvent):Void @@ -1955,6 +1996,10 @@ class PlayState extends MusicBeatState implements IHook // Dispatch event to stage script. ScriptEventDispatcher.callEvent(currentStage, event); + // Dispatch event to character script(s). + if (currentStage != null) + currentStage.dispatchToCharacters(event); + // TODO: Dispatch event to song script } @@ -1978,9 +2023,10 @@ class PlayState extends MusicBeatState implements IHook /** * Resets the camera's zoom level and focus point. */ - function resetCamera():Void + public function resetCamera():Void { FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04); + FlxG.camera.targetOffset.set(); FlxG.camera.zoom = defaultCameraZoom; FlxG.camera.focusOn(cameraFollowPoint.getPosition()); } @@ -2009,11 +2055,17 @@ class PlayState extends MusicBeatState implements IHook /** * This function is called whenever Flixel switches switching to a new FlxState. + * @return Whether to actually switch to the new state. */ override function switchTo(nextState:FlxState):Bool { - performCleanup(); + var result = super.switchTo(nextState); - return super.switchTo(nextState); + if (result) + { + performCleanup(); + } + + return result; } } diff --git a/source/funkin/play/VanillaCutscenes.hx b/source/funkin/play/VanillaCutscenes.hx index 6985f90b4..71587eeb9 100644 --- a/source/funkin/play/VanillaCutscenes.hx +++ b/source/funkin/play/VanillaCutscenes.hx @@ -2,6 +2,7 @@ package funkin.play; import flixel.FlxSprite; import flixel.tweens.FlxEase; +import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.tweens.FlxTween; import flixel.util.FlxColor; @@ -9,7 +10,7 @@ import flixel.util.FlxTimer; /** * Static methods for playing cutscenes in the PlayState. - * TODO: Softcode this shit!!!!!1! + * TODO: Un-hardcode this shit!!!!!1! */ class VanillaCutscenes { @@ -64,7 +65,7 @@ class VanillaCutscenes @:privateAccess PlayState.instance.startCountdown(); @:privateAccess - PlayState.instance.cameraMovement(); + PlayState.instance.controlCamera(); } public static function playHorrorStartCutscene() diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx new file mode 100644 index 000000000..22b95bc06 --- /dev/null +++ b/source/funkin/play/character/BaseCharacter.hx @@ -0,0 +1,446 @@ +package funkin.play.character; + +import flixel.math.FlxPoint; +import funkin.Note.NoteDir; +import funkin.modding.events.ScriptEvent; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.stage.Bopper; + +using StringTools; + +/** + * 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:CharacterType = OTHER; + + /** + * 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; + + public var isDead:Bool = false; + public var debugMode:Bool = false; + + final _data:CharacterData; + final singTimeCrochet: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, null):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, null):FlxPoint; + + function get_cornerPosition():FlxPoint + { + return new FlxPoint(x, y); + } + + /** + * The absolute position of the character's feet, at the bottom-center of the sprite. + */ + public var feetPosition(get, null):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); + + override function set_animOffsets(value:Array) + { + if (animOffsets == null) + animOffsets = [0, 0]; + if (animOffsets == value) + return value; + + var xDiff = animOffsets[0] - value[0]; + var yDiff = animOffsets[1] - value[1]; + + // Call the super function so that camera focus point is not affected. + super.set_x(this.x + xDiff); + super.set_y(this.y + yDiff); + + return animOffsets = value; + } + + /** + * 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) + { + super(); + this.characterId = id; + + _data = CharacterDataParser.fetchCharacterData(this.characterId); + if (_data == null) + { + throw 'Could not find character data for characterId: $characterId'; + } + else + { + this.characterName = _data.name; + this.singTimeCrochet = _data.singTime; + this.globalOffsets = _data.offsets; + this.flipX = _data.flipX; + } + + shouldBop = false; + } + + /** + * Set the sprite scale to the appropriate value. + * @param scale + */ + 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, null):Array; + + function get_characterCameraOffsets():Array + { + return _data.cameraOffsets; + } + + override function onCreate(event:ScriptEvent):Void + { + // 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]); + super.onCreate(event); + } + + public function initHealthIcon(isOpponent:Bool):Void + { + if (!isOpponent) + { + PlayState.instance.iconP1.characterId = _data.healthIcon.id; + PlayState.instance.iconP1.size.set(_data.healthIcon.scale, _data.healthIcon.scale); + PlayState.instance.iconP1.offset.x = _data.healthIcon.offsets[0]; + PlayState.instance.iconP1.offset.y = _data.healthIcon.offsets[1]; + } + else + { + PlayState.instance.iconP2.characterId = _data.healthIcon.id; + PlayState.instance.iconP2.size.set(_data.healthIcon.scale, _data.healthIcon.scale); + PlayState.instance.iconP2.offset.x = _data.healthIcon.offsets[0]; + PlayState.instance.iconP2.offset.y = _data.healthIcon.offsets[1]; + } + } + + 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(); + } + + if (hasAnimation('idle-end') && getCurrentAnimation() == "idle" && isAnimationFinished()) + playAnimation('idle-end'); + if (hasAnimation('singLEFT-end') && getCurrentAnimation() == "singLEFT" && isAnimationFinished()) + playAnimation('singLEFT-end'); + if (hasAnimation('singDOWN-end') && getCurrentAnimation() == "singDOWN" && isAnimationFinished()) + playAnimation('singDOWN-end'); + if (hasAnimation('singUP-end') && getCurrentAnimation() == "singUP" && isAnimationFinished()) + playAnimation('singUP-end'); + if (hasAnimation('singRIGHT-end') && getCurrentAnimation() == "singRIGHT" && isAnimationFinished()) + playAnimation('singRIGHT-end'); + + // Handle character note hold time. + if (getCurrentAnimation().startsWith("sing")) + { + holdTimer += event.elapsed; + var singTimeMs:Float = singTimeCrochet * (Conductor.crochet * 0.001); // x beats, to ms. + + if (getCurrentAnimation().endsWith("miss")) + singTimeMs *= 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('singTimeMs-${characterId}', singTimeMs); + if (holdTimer > singTimeMs && shouldStopSinging) + { + trace(getCurrentAnimation()); + // trace('holdTimer reached ${holdTimer}sec (> ${singTimeMs}), stopping sing animation'); + holdTimer = 0; + dance(true); + } + } + else + { + holdTimer = 0; + // super.onBeatHit handles the regular `dance()` calls. + } + FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer); + } + + /** + * Since no `onBeatHit` or `dance` calls happen in GameOverSubstate, + * this regularly gets called instead. + */ + public function playDeathAnimation(force:Bool = false):Void + { + if (force || (getCurrentAnimation().startsWith("firstDeath") && isAnimationFinished())) + { + playAnimation("deathLoop"); + } + } + + override function dance(force:Bool = false) + { + // Prevent default dancing behavior. + if (debugMode) + return; + + if (!force) + { + if (getCurrentAnimation().startsWith("sing")) + { + return; + } + if (["hey", "cheer"].contains(getCurrentAnimation()) && !isAnimationFinished()) + { + return; + } + } + + // Prevent dancing while another animation is playing. + if (!force && getCurrentAnimation().startsWith("sing")) + { + 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, + ].contains(true); + 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, + ].contains(true); + } + 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, + ].contains(true); + case 2: + return [ + PlayerSettings.player2.controls.NOTE_LEFT, + PlayerSettings.player2.controls.NOTE_DOWN, + PlayerSettings.player2.controls.NOTE_UP, + PlayerSettings.player2.controls.NOTE_RIGHT, + ].contains(true); + } + 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:NoteScriptEvent) + { + super.onNoteHit(event); + + if (event.note.mustPress && characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote); + holdTimer = 0; + } + else if (!event.note.mustPress && characterType == DAD) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote); + holdTimer = 0; + } + } + + /** + * 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 (event.note.mustPress && characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote); + } + else if (!event.note.mustPress && characterType == DAD) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote); + } + } + + /** + * Every time a wrong key is pressed, play the miss animation if we are Boyfriend. + */ + public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent) + { + super.onNoteGhostMiss(event); + + if (event.eventCanceled || !event.playAnim) + { + // Skipping... + return; + } + + if (characterType == BF) + { + trace('Playing ghost miss animation...'); + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.dir, true, null); + } + } + + 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:NoteDir, 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. + playAnimation(anim, true); + } +} + +enum CharacterType +{ + BF; + DAD; + GF; + OTHER; +} diff --git a/source/funkin/play/character/Character.hx b/source/funkin/play/character/Character.hx deleted file mode 100644 index c5ae1014a..000000000 --- a/source/funkin/play/character/Character.hx +++ /dev/null @@ -1,9 +0,0 @@ -package funkin.play.character; - -enum CharacterType -{ - BF; - GF; - DAD; - OTHER; -} diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx new file mode 100644 index 000000000..5fa55ada4 --- /dev/null +++ b/source/funkin/play/character/CharacterData.hx @@ -0,0 +1,591 @@ +package funkin.play.character; + +import flixel.util.typeLimit.OneOfTwo; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.BaseCharacter; +import funkin.play.character.MultiSparrowCharacter; +import funkin.play.character.PackerCharacter; +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.play.character.SparrowCharacter; +import funkin.util.VersionUtil; +import funkin.util.assets.DataAssets; +import haxe.Json; +import openfl.utils.Assets; + +using StringTools; + +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. + */ + public static final CHARACTER_DATA_VERSION:String = "1.0.0"; + + /** + * 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("[CHARDATA] Loading character cache..."); + + // + // 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) + { + var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } + + var scriptedCharClassNames2:Array = ScriptedPackerCharacter.listScriptClasses(); + if (scriptedCharClassNames2.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...'); + for (charCls in scriptedCharClassNames2) + { + var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } + + var scriptedCharClassNames3:Array = ScriptedMultiSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames3.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames3) + { + var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + characterScriptedClass.set(character.characterId, charCls); + } + } + + // 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)); + }); + + if (scriptedCharClassNames.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...'); + for (charCls in scriptedCharClassNames) + { + var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); + 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.'); + } + + public static function fetchCharacter(charId:String):Null + { + if (charId == null || charId == '') + { + // Gracefully handle songs that don't use this character. + return null; + } + + if (characterCache.exists(charId)) + { + var charData:CharacterData = characterCache.get(charId); + var charScriptClass:String = characterScriptedClass.get(charId); + + var char:BaseCharacter; + + if (charScriptClass != null) + { + switch (charData.renderType) + { + 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); + } + } + else + { + switch (charData.renderType) + { + 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); + } + } + + trace('[CHARDATA] Successfully instantiated character: ${charId}'); + + // Call onCreate only in the fetchCharacter() function, not at application initialization. + ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE)); + + return char; + } + else + { + trace('[CHARDATA] Failed to build character, not found in cache: ${charId}'); + return null; + } + } + + public static function fetchCharacterData(charId:String):Null + { + if (characterCache.exists(charId)) + { + return characterCache.get(charId); + } + else + { + return null; + } + } + + static function clearCharacterCache():Void + { + if (characterCache != null) + { + characterCache.clear(); + } + if (characterScriptedClass != null) + { + characterScriptedClass.clear(); + } + } + + /** + * Load a character's JSON file, parse its data, and return it. + * + * @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 = StringTools.trim(Assets.getText(charFilePath)); + + while (!StringTools.endsWith(rawJson, "}")) + { + rawJson = rawJson.substr(0, rawJson.length - 1); + } + + return rawJson; + } + + static function migrateCharacterData(rawJson:String, charId:String) + { + // 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 beats. + * Values that are too low will cause the character to stop singing between notes. + * Originally, this value was set to 1, but it was changed to 2 because that became + * too low after some other code changes. + */ + static final DEFAULT_SINGTIME:Float = 2.0; + + static final DEFAULT_DANCEEVERY:Int = 1; + static final DEFAULT_FLIPX:Bool = false; + static final DEFAULT_FLIPY:Bool = false; + static final DEFAULT_FRAMERATE:Int = 24; + static final DEFAULT_ISPIXEL:Bool = false; + static final DEFAULT_LOOP:Bool = false; + static final DEFAULT_NAME:String = "Untitled Character"; + static final DEFAULT_OFFSETS:Array = [0, 0]; + static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25]; + static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; + static final DEFAULT_SCALE:Float = 1; + static final DEFAULT_SCROLL:Array = [0, 0]; + 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('[CHARDATA] ERROR: Could not parse character data for "${id}".'); + return null; + } + + if (input.version == null) + { + trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); + input.version = CHARACTER_DATA_VERSION; + } + + if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE)) + { + trace('[CHARDATA] 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('[CHARDATA] WARN: Character data for "$id" missing name'); + input.name = DEFAULT_NAME; + } + + if (input.renderType == null) + { + input.renderType = DEFAULT_RENDERTYPE; + } + + if (input.assetPath == null) + { + trace('[CHARDATA] 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, + offsets: null + }; + } + + if (input.healthIcon.id == null) + { + input.healthIcon.id = id; + } + + if (input.healthIcon.scale == null) + { + input.healthIcon.scale = DEFAULT_SCALE; + } + + 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.danceEvery == null) + { + input.danceEvery = DEFAULT_DANCEEVERY; + } + + if (input.singTime == null) + { + input.singTime = DEFAULT_SINGTIME; + } + + if (input.animations == null || input.animations.length == 0) + { + trace('[CHARDATA] 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('[CHARDATA] 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; + } +} + +enum abstract CharacterRenderType(String) from String to String +{ + var SPARROW = 'sparrow'; + var PACKER = 'packer'; + var MULTISPARROW = 'multisparrow'; + // TODO: FlxSpine? + // https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html + // TODO: Aseprite? + // https://lib.haxe.org/p/openfl-aseprite/ + // TODO: Animate? + // https://lib.haxe.org/p/flxanimate + // TODO: REDACTED +} + +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; + + /** + * 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. + * + * @default 1 + */ + 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; +}; + +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; + + /** + * The offset of the health icon, in pixels. + * @default [0, 25] + */ + var offsets:Null>; +} diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx new file mode 100644 index 000000000..160bc1c5d --- /dev/null +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -0,0 +1,212 @@ +package funkin.play.character; + +import flixel.graphics.frames.FlxFramesCollection; +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; + +/** + * For some characters which use Sparrow atlases, the spritesheets need to be split + * into multiple files. This character renderer handles by showing the appropriate sprite. + * + * Examples in base game include BF Holding GF (most of the sprites are in one file + * but the death animation is in a separate file). + * Only example I can think of in mods is Tricky (which has a separate file for each animation). + * + * BaseCharacter has game logic, SparrowCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class MultiSparrowCharacter extends BaseCharacter +{ + /** + * The actual group which holds all spritesheets this character uses. + */ + private var members:Map = new Map(); + + /** + * A map between animation names and what frame collection the animation should use. + */ + private var animAssetPath:Map = new Map(); + + /** + * The current frame collection being used. + */ + private var activeMember:String; + + public function new(id:String) + { + super(id); + } + + override function onCreate(event:ScriptEvent):Void + { + trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId); + + buildSprites(); + super.onCreate(event); + } + + function buildSprites() + { + buildSpritesheets(); + buildAnimations(); + + playAnimation(_data.startingAnimation); + + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + } + + function buildSpritesheets() + { + // Build the list of asset paths to use. + // Ignore nulls and duplicates. + var assetList = [_data.assetPath]; + for (anim in _data.animations) + { + if (anim.assetPath != null && !assetList.contains(anim.assetPath)) + { + assetList.push(anim.assetPath); + } + animAssetPath.set(anim.name, anim.assetPath); + } + + // Load the Sparrow atlas for each path and store them in the members map. + for (asset in assetList) + { + var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared'); + // If we don't do this, the unused textures will be removed as soon as they're loaded. + + if (texture == null) + { + trace('Multi-Sparrow atlas could not load texture: ${asset}'); + } + else + { + trace('Adding multi-sparrow atlas: ${asset}'); + texture.parent.destroyOnNoUse = false; + members.set(asset, texture); + } + } + + // Use the default frame collection to start. + loadFramesByAssetPath(_data.assetPath); + } + + /** + * Replace this sprite's animation frames with the ones at this asset path. + */ + function loadFramesByAssetPath(assetPath:String):Void + { + if (_data.assetPath == null) + { + trace('[ERROR] Multi-Sparrow character has no default asset path!'); + return; + } + if (assetPath == null) + { + // trace('Asset path is null, falling back to default. This is normal!'); + loadFramesByAssetPath(_data.assetPath); + return; + } + + if (this.activeMember == assetPath) + { + // trace('Already using this asset path: ${assetPath}'); + return; + } + + if (members.exists(assetPath)) + { + // Switch to a new set of sprites. + trace('Loading frames from asset path: ${assetPath}'); + this.frames = members.get(assetPath); + this.activeMember = assetPath; + this.setScale(_data.scale); + } + else + { + trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}'); + } + } + + /** + * Replace this sprite's animation frames with the ones needed to play this animation. + */ + function loadFramesByAnimName(animName) + { + if (animAssetPath.exists(animName)) + { + loadFramesByAssetPath(animAssetPath.get(animName)); + } + else + { + trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}'); + } + } + + 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) + { + loadFramesByAnimName(anim.name); + 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):Void + { + loadFramesByAnimName(name); + super.playAnimation(name, restart, ignoreOther); + } + + override function set_frames(value:FlxFramesCollection):FlxFramesCollection + { + // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK + // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM + // if (animation != null) + // { + // animation.destroyAnimations(); + // } + + if (value != null) + { + graphic = value.parent; + this.frames = value; + this.frame = value.getByIndex(0); + this.numFrames = value.numFrames; + resetHelpers(); + this.bakedRotationAngle = 0; + this.animation.frameIndex = 0; + graphicLoaded(); + } + else + { + this.frames = null; + this.frame = null; + this.graphic = null; + } + + return this.frames; + } +} diff --git a/source/funkin/play/character/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx new file mode 100644 index 000000000..b7282423e --- /dev/null +++ b/source/funkin/play/character/PackerCharacter.hx @@ -0,0 +1,77 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import flixel.graphics.frames.FlxFramesCollection; +import funkin.util.assets.FlxAnimationUtil; +import funkin.play.character.BaseCharacter.CharacterType; + +/** + * 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); + } + + override function onCreate(event:ScriptEvent):Void + { + trace('Creating PACKER CHARACTER: ' + this.characterId); + + loadSpritesheet(); + loadAnimations(); + + playAnimation(_data.startingAnimation); + + super.onCreate(event); + } + + function loadSpritesheet() + { + trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); + + var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared'); + if (tex == null) + { + trace('Could not load Packer sprite: ${_data.assetPath}'); + return; + } + + this.frames = tex; + + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + + this.setScale(_data.scale); + } + + function loadAnimations() + { + 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 000000000..1ce8f7f93 --- /dev/null +++ b/source/funkin/play/character/ScriptedCharacter.hx @@ -0,0 +1,23 @@ +package funkin.play.character; + +import funkin.play.character.PackerCharacter; +import funkin.play.character.SparrowCharacter; +import funkin.play.character.MultiSparrowCharacter; +import funkin.modding.IHook; + +/** + * 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 IHook {} + +@:hscriptClass +class ScriptedSparrowCharacter extends SparrowCharacter implements IHook {} + +@:hscriptClass +class ScriptedMultiSparrowCharacter extends MultiSparrowCharacter implements IHook {} + +@:hscriptClass +class ScriptedPackerCharacter extends PackerCharacter implements IHook {} diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx new file mode 100644 index 000000000..e8191940c --- /dev/null +++ b/source/funkin/play/character/SparrowCharacter.hx @@ -0,0 +1,79 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; +import flixel.graphics.frames.FlxFramesCollection; + +/** + * 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); + } + + override function onCreate(event:ScriptEvent):Void + { + trace('Creating SPARROW CHARACTER: ' + this.characterId); + + loadSpritesheet(); + loadAnimations(); + + playAnimation(_data.startingAnimation); + + super.onCreate(event); + } + + function loadSpritesheet() + { + trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); + + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared'); + 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.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/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 1c8fdce5d..525ab0f91 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -1,5 +1,6 @@ package funkin.play.stage; +import flixel.FlxG; import flixel.FlxSprite; import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.events.ScriptEvent; @@ -36,6 +37,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass */ public var idleSuffix(default, set):String = ""; + /** + * 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; + + public var finishCallbackMap:MapVoid> = new MapVoid>(); + function set_idleSuffix(value:String):String { this.idleSuffix = value; @@ -76,6 +85,11 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass { super(); this.danceEvery = danceEvery; + this.animation.finishCallback = function(name) + { + if (finishCallbackMap[name] != null) + finishCallbackMap[name](); + }; } function update_shouldAlternate():Void @@ -93,14 +107,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass { if (danceEvery > 0 && event.beat % danceEvery == 0) { - dance(true); + dance(shouldBop); } } /** * Called every `danceEvery` beats of the song. */ - public function dance(force:Bool = false):Void + public function dance(forceRestart:Bool = false):Void { if (this.animation == null) { @@ -116,17 +130,17 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass { if (hasDanced) { - playAnimation('danceRight$idleSuffix', true); + playAnimation('danceRight$idleSuffix', forceRestart); } else { - playAnimation('danceLeft$idleSuffix', true); + playAnimation('danceLeft$idleSuffix', forceRestart); } hasDanced = !hasDanced; } else { - playAnimation('idle$idleSuffix', true); + playAnimation('idle$idleSuffix', forceRestart); } } @@ -173,18 +187,39 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass } } + public var canPlayOtherAnims:Bool = true; + /** * @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 */ - public function playAnimation(name:String, restart:Bool = false):Void + public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void { + if (ignoreOther == null) + ignoreOther = false; + + if (!canPlayOtherAnims) + return; + var correctName = correctAnimationName(name); if (correctName == null) return; this.animation.play(correctName, restart, false, 0); + if (ignoreOther) + { + canPlayOtherAnims = false; + + // doing it with this funny map, since overriding the animation.finishCallback is a bit messier IMO + finishCallbackMap[name] = function() + { + canPlayOtherAnims = true; + finishCallbackMap[name] = null; + }; + } + applyAnimationOffsets(correctName); } diff --git a/source/funkin/play/stage/ScriptedBopper.hx b/source/funkin/play/stage/ScriptedBopper.hx index a344b0428..405ef57f6 100644 --- a/source/funkin/play/stage/ScriptedBopper.hx +++ b/source/funkin/play/stage/ScriptedBopper.hx @@ -3,8 +3,4 @@ package funkin.play.stage; import funkin.modding.IHook; @:hscriptClass -@:keep -class ScriptedBopper extends Bopper implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedBopper extends Bopper implements IHook {} diff --git a/source/funkin/play/stage/ScriptedStage.hx b/source/funkin/play/stage/ScriptedStage.hx index 114afb1d5..e6a92d8b8 100644 --- a/source/funkin/play/stage/ScriptedStage.hx +++ b/source/funkin/play/stage/ScriptedStage.hx @@ -3,7 +3,4 @@ package funkin.play.stage; import funkin.modding.IHook; @:hscriptClass -class ScriptedStage extends Stage implements IHook -{ - // No body needed for this class, it's magic ;) -} +class ScriptedStage extends Stage implements IHook {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index 6a0cce769..768938608 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -2,12 +2,14 @@ package funkin.play.stage; import flixel.FlxSprite; import flixel.group.FlxSpriteGroup; +import flixel.math.FlxPoint; import flixel.util.FlxSort; import funkin.modding.IHook; import funkin.modding.IScriptedClass; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.play.character.Character.CharacterType; +import funkin.play.character.BaseCharacter; +import funkin.play.stage.StageData.StageDataCharacter; import funkin.play.stage.StageData.StageDataParser; import funkin.util.SortUtil; import funkin.util.assets.FlxAnimationUtil; @@ -27,7 +29,7 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public var camZoom:Float = 1.0; var namedProps:Map = new Map(); - var characters:Map = new Map(); + var characters:Map = new Map(); var boppers:Array = new Array(); /** @@ -158,6 +160,14 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte } } + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } + } + if (dataProp.startingAnimation != null) { propSprite.animation.play(dataProp.startingAnimation); @@ -206,7 +216,6 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public function refresh() { sort(SortUtil.byZIndex, FlxSort.ASCENDING); - trace('Stage sorted by z-index'); } /** @@ -232,53 +241,116 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte /** * Used by the PlayState to add a character to the stage. */ - public function addCharacter(character:Character, charType:CharacterType) + public function addCharacter(character:BaseCharacter, charType:CharacterType) { + if (character == null) + return; + + #if debug + // 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); + debugIcon.makeGraphic(8, 8, 0xffff00ff); + debugIcon.visible = false; + debugIcon.zIndex = 1000000; + #end + // Apply position and z-index. + var charData:StageDataCharacter = null; switch (charType) { case BF: this.characters.set("bf", character); - character.zIndex = _data.characters.bf.zIndex; - character.x = _data.characters.bf.position[0]; - character.y = _data.characters.bf.position[1]; + charData = _data.characters.bf; + character.flipX = !character.flipX; + // flip offsets if flipX + character.initHealthIcon(false); case GF: this.characters.set("gf", character); - character.zIndex = _data.characters.gf.zIndex; - character.x = _data.characters.gf.position[0]; - character.y = _data.characters.gf.position[1]; + charData = _data.characters.gf; case DAD: this.characters.set("dad", character); - character.zIndex = _data.characters.dad.zIndex; - character.x = _data.characters.dad.position[0]; - character.y = _data.characters.dad.position[1]; + charData = _data.characters.dad; + // flip offsets if flipX + character.initHealthIcon(true); default: - this.characters.set(character.curCharacter, character); + this.characters.set(character.characterId, character); + } + if (charData != null) + { + character.zIndex = charData.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. + character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; + character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + + character.cameraFocusPoint.x += charData.cameraOffsets[0]; + character.cameraFocusPoint.y += charData.cameraOffsets[1]; + + // Draw the debug icon at the character's feet. + debugIcon.x = charData.position[0]; + debugIcon.y = charData.position[1]; } // Add the character to the scene. this.add(character); + this.add(debugIcon); + } + + public inline function getGirlfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]); + } + + public inline function getBoyfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]); + } + + 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):Character + public function getCharacter(id:String):BaseCharacter { return this.characters.get(id); } - public function getBoyfriend():Character + /** + * Retrieve the Boyfriend character. + * @param pop If true, the character will be removed from the stage as well. + */ + public function getBoyfriend(?pop:Bool = false):BaseCharacter { - return getCharacter('bf'); + 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'); + } } - public function getGirlfriend():Character + public function getGirlfriend():BaseCharacter { return getCharacter('gf'); } - public function getDad():Character + public function getDad():BaseCharacter { return getCharacter('dad'); } @@ -309,6 +381,32 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte 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 + { + for (characterId in characters.keys()) + { + 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. diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx index dc5538bac..e4d1904fd 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -175,6 +175,8 @@ class StageDataParser static final DEFAULT_ISPIXEL:Bool = false; static final DEFAULT_NAME:String = "Untitled Stage"; static final DEFAULT_OFFSETS:Array = [0, 0]; + static final DEFAULT_CAMERA_OFFSETS_BF:Array = [-100, -100]; + static final DEFAULT_CAMERA_OFFSETS_DAD:Array = [150, -100]; static final DEFAULT_POSITION:Array = [0, 0]; static final DEFAULT_SCALE:Float = 1.0; static final DEFAULT_SCROLL:Array = [0, 0]; @@ -339,10 +341,12 @@ class StageDataParser if (input.characters.bf == null) { input.characters.bf = DEFAULT_CHARACTER_DATA; + input.characters.bf.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; } if (input.characters.dad == null) { input.characters.dad = DEFAULT_CHARACTER_DATA; + input.characters.dad.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; } if (input.characters.gf == null) { @@ -361,7 +365,14 @@ class StageDataParser } if (inputCharacter.cameraOffsets == null || inputCharacter.cameraOffsets.length != 2) { - inputCharacter.cameraOffsets = [0, 0]; + if (inputCharacter == input.characters.bf) + inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_BF; + else if (inputCharacter == input.characters.dad) + inputCharacter.cameraOffsets = DEFAULT_CAMERA_OFFSETS_DAD; + else + { + inputCharacter.cameraOffsets = [0, 0]; + } } } @@ -484,7 +495,7 @@ typedef StageDataCharacter = /** * The camera offsets to apply when focusing on the character on this stage. - * @default [0, 0] + * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF */ cameraOffsets:Array, }; diff --git a/source/funkin/ui/AtlasMenuList.hx b/source/funkin/ui/AtlasMenuList.hx index 7a217a44c..922ef9369 100644 --- a/source/funkin/ui/AtlasMenuList.hx +++ b/source/funkin/ui/AtlasMenuList.hx @@ -5,6 +5,9 @@ import flixel.graphics.frames.FlxAtlasFrames; typedef AtlasAsset = flixel.util.typeLimit.OneOfTwo; +/** + * A menulist whose items share a single texture atlas. + */ class AtlasMenuList extends MenuTypedList { public var atlas:FlxAtlasFrames; @@ -33,11 +36,16 @@ class AtlasMenuList extends MenuTypedList } } +/** + * A menu list item which uses single texture atlas. + */ class AtlasMenuItem extends MenuItem { var atlas:FlxAtlasFrames; - public function new(x = 0.0, y = 0.0, name:String, atlas:FlxAtlasFrames, callback) + 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); @@ -52,10 +60,17 @@ class AtlasMenuItem extends MenuItem super.setData(name, callback); } - function changeAnim(animName:String) + public function changeAnim(animName:String) { animation.play(animName); updateHitbox(); + + if (centered) + { + // position by center + centerOrigin(); + offset.copyFrom(origin); + } } override function idle() diff --git a/source/funkin/ui/animDebugShit/DebugBoundingState.hx b/source/funkin/ui/animDebugShit/DebugBoundingState.hx index 9f49f5cc0..19012a500 100644 --- a/source/funkin/ui/animDebugShit/DebugBoundingState.hx +++ b/source/funkin/ui/animDebugShit/DebugBoundingState.hx @@ -1,11 +1,13 @@ package funkin.ui.animDebugShit; -import flixel.FlxCamera; -import flixel.FlxSprite; -import flixel.FlxState; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.character.SparrowCharacter; import flixel.addons.display.FlxGridOverlay; import flixel.addons.ui.FlxInputText; import flixel.addons.ui.FlxUIDropDownMenu; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.FlxState; import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFrame; import flixel.group.FlxGroup; @@ -15,6 +17,7 @@ import flixel.text.FlxText; import flixel.util.FlxColor; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; +import funkin.play.character.BaseCharacter; import lime.utils.Assets as LimeAssets; import openfl.Assets; import openfl.events.Event; @@ -249,7 +252,7 @@ class DebugBoundingState extends FlxState swagChar.offset.x = (FlxG.mouse.x - mouseOffset.x) * -1; swagChar.offset.y = (FlxG.mouse.y - mouseOffset.y) * -1; - swagChar.animOffsets.set(animDropDownMenu.selectedLabel, [swagChar.offset.x, swagChar.offset.y]); + swagChar.animationOffsets.set(animDropDownMenu.selectedLabel, [Std.int(swagChar.offset.x), Std.int(swagChar.offset.y)]); txtOffsetShit.text = 'Offset: ' + swagChar.offset; } @@ -391,9 +394,9 @@ class DebugBoundingState extends FlxState if (FlxG.keys.justPressed.RIGHT || FlxG.keys.justPressed.LEFT || FlxG.keys.justPressed.UP || FlxG.keys.justPressed.DOWN) { var animName = animDropDownMenu.selectedLabel; - var coolValues:Array = swagChar.animOffsets.get(animName); + var coolValues:Array = swagChar.animationOffsets.get(animName); - var multiplier:Float = 5; + var multiplier:Int = 5; if (FlxG.keys.pressed.CONTROL) multiplier = 1; @@ -410,8 +413,8 @@ class DebugBoundingState extends FlxState else if (FlxG.keys.justPressed.DOWN) coolValues[1] -= 1 * multiplier; - swagChar.animOffsets.set(animDropDownMenu.selectedLabel, coolValues); - swagChar.playAnim(animName); + swagChar.animationOffsets.set(animDropDownMenu.selectedLabel, coolValues); + swagChar.playAnimation(animName); txtOffsetShit.text = 'Offset: ' + coolValues; @@ -422,9 +425,9 @@ class DebugBoundingState extends FlxState { var outputString:String = ""; - for (i in swagChar.animOffsets.keys()) + for (i in swagChar.animationOffsets.keys()) { - outputString += i + " " + swagChar.animOffsets.get(i)[0] + " " + swagChar.animOffsets.get(i)[1] + "\n"; + outputString += i + " " + swagChar.animationOffsets.get(i)[0] + " " + swagChar.animationOffsets.get(i)[1] + "\n"; } outputString.trim(); @@ -432,7 +435,7 @@ class DebugBoundingState extends FlxState } } - var swagChar:Character; + var swagChar:BaseCharacter; function loadAnimShit(char:String) { @@ -442,8 +445,10 @@ class DebugBoundingState extends FlxState swagChar.destroy(); } - swagChar = new Character(100, 100, char); - swagChar.debugMode = true; + swagChar = CharacterDataParser.fetchCharacter(char); + swagChar.x = 100; + swagChar.y = 100; + // swagChar.debugMode = true; offsetView.add(swagChar); generateOutlines(swagChar.frames.frames); @@ -451,11 +456,11 @@ class DebugBoundingState extends FlxState var animThing:Array = []; - for (i in swagChar.animOffsets.keys()) + for (i in swagChar.animationOffsets.keys()) { animThing.push(i); trace(i); - trace(swagChar.animOffsets[i]); + trace(swagChar.animationOffsets[i]); } animDropDownMenu.setData(FlxUIDropDownMenu.makeStrIdLabelArray(animThing, true)); @@ -468,8 +473,8 @@ class DebugBoundingState extends FlxState onionSkinChar.alpha = 0.6; var animName = animThing[Std.parseInt(str)]; - swagChar.playAnim(animName, true); // trace(); - trace(swagChar.animOffsets.get(animName)); + swagChar.playAnimation(animName, true); // trace(); + trace(swagChar.animationOffsets.get(animName)); txtOffsetShit.text = 'Offset: ' + swagChar.offset; }; @@ -486,7 +491,7 @@ class DebugBoundingState extends FlxState _file.addEventListener(Event.COMPLETE, onSaveComplete); _file.addEventListener(Event.CANCEL, onSaveCancel); _file.addEventListener(IOErrorEvent.IO_ERROR, onSaveError); - _file.save(saveString, swagChar.curCharacter + "Offsets.txt"); + _file.save(saveString, swagChar.characterId + "Offsets.txt"); } } diff --git a/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx new file mode 100644 index 000000000..1196e8cf1 --- /dev/null +++ b/source/funkin/ui/stageBuildShit/StageOffsetSubstate.hx @@ -0,0 +1,103 @@ +package funkin.ui.stageBuildShit; + +import flixel.math.FlxPoint; +import flixel.ui.FlxButton; +import funkin.play.PlayState; +import funkin.play.character.BaseCharacter; +import funkin.play.stage.StageData; +import haxe.Json; +import openfl.Assets; + +class StageOffsetSubstate extends MusicBeatSubstate +{ + public function new() + { + super(); + FlxG.mouse.visible = true; + PlayState.instance.pauseMusic(); + FlxG.camera.target = null; + + var btn:FlxButton = new FlxButton(200, 10, "SAVE COMPILE", function() + { + var stageLol:StageData = StageDataParser.parseStageData(PlayState.instance.currentStageId); + + var bfPos = PlayState.instance.currentStage.getBoyfriend().feetPosition; + stageLol.characters.bf.position[0] = Std.int(bfPos.x); + stageLol.characters.bf.position[1] = Std.int(bfPos.y); + + var dadPos = PlayState.instance.currentStage.getDad().feetPosition; + + stageLol.characters.dad.position[0] = Std.int(dadPos.x); + stageLol.characters.dad.position[1] = Std.int(dadPos.y); + + var GF_FEET_SNIIIIIIIIIIIIIFFFF = PlayState.instance.currentStage.getGirlfriend().feetPosition; + stageLol.characters.gf.position[0] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.x); + stageLol.characters.gf.position[1] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.y); + + var outputJson = CoolUtil.jsonStringify(stageLol); + + #if sys + // save "local" to the current export. + sys.io.File.saveContent('./assets/data/stages/' + PlayState.instance.currentStageId + '.json', outputJson); + + // save to the dev version + sys.io.File.saveContent('../../../../assets/preload/data/stages/' + PlayState.instance.currentStageId + '.json', outputJson); + #end + // trace(dipshitJson); + + // put character position data to a file of some sort + }); + btn.scrollFactor.set(); + add(btn); + btn.cameras = [PlayState.instance.camHUD]; + } + + var mosPosOld:FlxPoint = new FlxPoint(); + var sprOld:FlxPoint = new FlxPoint(); + + var char:BaseCharacter = null; + + override function update(elapsed:Float) + { + super.update(elapsed); + + CoolUtil.mouseCamDrag(); + + if (FlxG.keys.pressed.CONTROL) + CoolUtil.mouseWheelZoom(); + + if (FlxG.mouse.pressed) + { + if (FlxG.mouse.justPressed) + { + for (thing in PlayState.instance.currentStage) + { + if (FlxG.mouse.overlaps(thing) && Std.isOfType(thing, BaseCharacter)) + char = cast thing; + } + + if (char != null) + { + sprOld.x = char.x; + sprOld.y = char.y; + } + + mosPosOld.x = FlxG.mouse.x; + mosPosOld.y = FlxG.mouse.y; + } + + if (char != null) + { + char.x = sprOld.x - (mosPosOld.x - FlxG.mouse.x); + char.y = sprOld.y - (mosPosOld.y - FlxG.mouse.y); + } + } + + if (FlxG.keys.justPressed.Y) + { + PlayState.instance.resetCamera(); + FlxG.mouse.visible = false; + close(); + } + } +} diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx index b0bd798dc..6e92d6b68 100644 --- a/source/funkin/util/SortUtil.hx +++ b/source/funkin/util/SortUtil.hx @@ -13,6 +13,8 @@ class SortUtil */ public static inline function byZIndex(Order:Int, Obj1:FlxBasic, Obj2:FlxBasic):Int { + if (Obj1 == null || Obj2 == null) + return 0; return FlxSort.byValues(Order, Obj1.zIndex, Obj2.zIndex); } diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx index d00703068..ed4805276 100644 --- a/source/funkin/util/assets/DataAssets.hx +++ b/source/funkin/util/assets/DataAssets.hx @@ -21,7 +21,10 @@ class DataAssets { var pathNoSuffix = textPath.substring(0, textPath.length - ext.length); var pathNoPrefix = pathNoSuffix.substring(queryPath.length); - results.push(pathNoPrefix); + + // No duplicates! Why does this happen? + if (!results.contains(pathNoPrefix)) + results.push(pathNoPrefix); } }