diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index e217d1f18..4d674f025 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -3,6 +3,10 @@ on: workflow_dispatch: push: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: create-nightly-html5: runs-on: [self-hosted, linux] diff --git a/.github/workflows/cancel-merged-branches.yml b/.github/workflows/cancel-merged-branches.yml new file mode 100644 index 000000000..84e3bedc9 --- /dev/null +++ b/.github/workflows/cancel-merged-branches.yml @@ -0,0 +1,35 @@ +name: cancel-merged-branches +on: + pull_request: + types: + - closed + +jobs: + cancel_stuff: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: actions/github-script@v7 + id: cancel-runs + with: + result-encoding: string + retries: 3 + script: | + let branch_workflows = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: "build-shit.yml", + status: "queued", + branch: "${{ github.event.pull_request.head.ref }}" + }); + let runs = branch_workflows.data.workflow_runs; + runs.forEach((run) => { + github.rest.actions.cancelWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id + }); + }); + console.log(runs); diff --git a/Project.xml b/Project.xml index c0da3c89a..4b81fd07b 100644 --- a/Project.xml +++ b/Project.xml @@ -183,6 +183,7 @@ +
diff --git a/art b/art index 00463685f..03e7c2a23 160000 --- a/art +++ b/art @@ -1 +1 @@ -Subproject commit 00463685fa570f0c853d08e250b46ef80f30bc48 +Subproject commit 03e7c2a2353b184e45955c96d763b7cdf1acbc34 diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx index 996e2367e..94f41cea4 100644 --- a/source/funkin/Highscore.hx +++ b/source/funkin/Highscore.hx @@ -59,6 +59,7 @@ abstract Tallies(RawTallies) totalNotes: 0, totalNotesHit: 0, maxCombo: 0, + score: 0, isNewHighscore: false } } @@ -81,6 +82,9 @@ typedef RawTallies = var good:Int; var sick:Int; var maxCombo:Int; + + var score:Int; + var isNewHighscore:Bool; /** diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx index 0df290feb..3bea9cca2 100644 --- a/source/funkin/audio/FunkinSound.hx +++ b/source/funkin/audio/FunkinSound.hx @@ -402,10 +402,16 @@ class FunkinSound extends FlxSound implements ICloneable return sound; } + @:nullSafety(Off) public override function destroy():Void { // trace('[FunkinSound] Destroying sound "${this._label}"'); super.destroy(); + if (fadeTween != null) + { + fadeTween.cancel(); + fadeTween = null; + } FlxTween.cancelTweensOf(this); this._label = 'unknown'; } diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx index 7419d9425..118516bec 100644 --- a/source/funkin/data/BaseRegistry.hx +++ b/source/funkin/data/BaseRegistry.hx @@ -325,12 +325,3 @@ abstract class BaseRegistry & Constructible return ScriptedSong.listScriptClasses(); } - function loadEntryMetadataFile(id:String, ?variation:String):Null + function loadEntryMetadataFile(id:String, ?variation:String):Null { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); @@ -442,7 +442,7 @@ class SongRegistry extends BaseRegistry return {fileName: entryFilePath, contents: rawJson}; } - function loadMusicDataFile(id:String, ?variation:String):Null + function loadMusicDataFile(id:String, ?variation:String):Null { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json'); @@ -460,7 +460,7 @@ class SongRegistry extends BaseRegistry return openfl.Assets.exists(entryFilePath); } - function loadEntryChartFile(id:String, ?variation:String):Null + function loadEntryChartFile(id:String, ?variation:String):Null { variation = variation == null ? Constants.DEFAULT_VARIATION : variation; var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}'); diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx index 471f8cf02..9c2356bba 100644 --- a/source/funkin/play/PauseSubState.hx +++ b/source/funkin/play/PauseSubState.hx @@ -231,7 +231,7 @@ class PauseSubState extends MusicBeatSubState */ function startPauseMusic():Void { - var pauseMusicPath:String = Paths.music('breakfast$musicSuffix'); + var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix'); pauseMusic = FunkinSound.load(pauseMusicPath, true, true); if (pauseMusic == null) @@ -568,6 +568,8 @@ class PauseSubState extends MusicBeatSubState PlayStatePlaylist.campaignDifficulty = difficulty; PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; + FreeplayState.rememberedDifficulty = difficulty; + PlayState.instance.needsReset = true; state.close(); diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 678f2430e..ebc25bf7d 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -728,6 +728,10 @@ class PlayState extends MusicBeatSubState #end initialized = true; + + // This step ensures z-indexes are applied properly, + // and it's important to call it last so all elements get affected. + refresh(); } public override function draw():Void @@ -1720,8 +1724,6 @@ class PlayState extends MusicBeatSubState playerStrumline.fadeInArrows(); opponentStrumline.fadeInArrows(); } - - this.refresh(); } /** @@ -2441,7 +2443,8 @@ class PlayState extends MusicBeatSubState if (Highscore.tallies.combo != 0) { // Break the combo. - Highscore.tallies.combo = comboPopUps.displayCombo(0); + if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0); + Highscore.tallies.combo = 0; } if (playSound) @@ -2568,32 +2571,38 @@ class PlayState extends MusicBeatSubState */ function popUpScore(daNote:NoteSprite, score:Int, daRating:String, healthChange:Float):Void { - vocals.playerVolume = 1; - if (daRating == 'miss') { // If daRating is 'miss', that means we made a mistake and should not continue. - trace('[WARNING] popUpScore judged a note as a miss!'); + FlxG.log.warn('popUpScore judged a note as a miss!'); // TODO: Remove this. comboPopUps.displayRating('miss'); return; } + vocals.playerVolume = 1; + var isComboBreak = false; switch (daRating) { case 'sick': Highscore.tallies.sick += 1; + Highscore.tallies.totalNotesHit++; isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK; case 'good': Highscore.tallies.good += 1; + Highscore.tallies.totalNotesHit++; isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK; case 'bad': Highscore.tallies.bad += 1; + Highscore.tallies.totalNotesHit++; isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK; case 'shit': Highscore.tallies.shit += 1; + Highscore.tallies.totalNotesHit++; isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK; + default: + FlxG.log.error('Wuh? Buh? Guh? Note hit judgement was $daRating!'); } health += healthChange; @@ -2601,18 +2610,18 @@ class PlayState extends MusicBeatSubState if (isComboBreak) { // Break the combo, but don't increment tallies.misses. - Highscore.tallies.combo = comboPopUps.displayCombo(0); + if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0); + Highscore.tallies.combo = 0; } else { Highscore.tallies.combo++; - Highscore.tallies.totalNotesHit++; if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo; } playerStrumline.hitNote(daNote, !isComboBreak); - if (daRating == "sick") + if (daRating == 'sick') { playerStrumline.playNoteSplash(daNote.noteData.getDirection()); } @@ -2724,7 +2733,7 @@ class PlayState extends MusicBeatSubState */ public function endSong(rightGoddamnNow:Bool = false):Void { - FlxG.sound.music.volume = 0; + if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; vocals.volume = 0; mayPauseGame = false; @@ -2742,6 +2751,8 @@ class PlayState extends MusicBeatSubState deathCounter = 0; + var isNewHighscore = false; + if (currentSong != null && currentSong.validScore) { // crackhead double thingie, sets whether was new highscore, AND saves the song! @@ -2772,11 +2783,14 @@ class PlayState extends MusicBeatSubState #if newgrounds NGio.postScore(score, currentSong.id); #end + isNewHighscore = true; } } if (PlayStatePlaylist.isStoryMode) { + isNewHighscore = false; + PlayStatePlaylist.campaignScore += songScore; // Pop the next song ID from the list. @@ -2814,6 +2828,7 @@ class PlayState extends MusicBeatSubState #if newgrounds NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}'); #end + isNewHighscore = true; } } @@ -2825,11 +2840,11 @@ class PlayState extends MusicBeatSubState { if (rightGoddamnNow) { - moveToResultsScreen(); + moveToResultsScreen(isNewHighscore); } else { - zoomIntoResultsScreen(); + zoomIntoResultsScreen(isNewHighscore); } } } @@ -2890,11 +2905,11 @@ class PlayState extends MusicBeatSubState { if (rightGoddamnNow) { - moveToResultsScreen(); + moveToResultsScreen(isNewHighscore); } else { - zoomIntoResultsScreen(); + zoomIntoResultsScreen(isNewHighscore); } } } @@ -2968,7 +2983,7 @@ class PlayState extends MusicBeatSubState /** * Play the camera zoom animation and then move to the results screen once it's done. */ - function zoomIntoResultsScreen():Void + function zoomIntoResultsScreen(isNewHighscore:Bool):Void { trace('WENT TO RESULTS SCREEN!'); @@ -3025,7 +3040,7 @@ class PlayState extends MusicBeatSubState { ease: FlxEase.expoIn, onComplete: function(_) { - moveToResultsScreen(); + moveToResultsScreen(isNewHighscore); } }); }); @@ -3034,7 +3049,7 @@ class PlayState extends MusicBeatSubState /** * Move to the results screen right goddamn now. */ - function moveToResultsScreen():Void + function moveToResultsScreen(isNewHighscore:Bool):Void { persistentUpdate = false; vocals.stop(); @@ -3046,7 +3061,24 @@ class PlayState extends MusicBeatSubState { storyMode: PlayStatePlaylist.isStoryMode, title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'), - tallies: talliesToUse, + scoreData: + { + score: songScore, + tallies: + { + sick: Highscore.tallies.sick, + good: Highscore.tallies.good, + bad: Highscore.tallies.bad, + shit: Highscore.tallies.shit, + missed: Highscore.tallies.missed, + combo: Highscore.tallies.combo, + maxCombo: Highscore.tallies.maxCombo, + totalNotesHit: Highscore.tallies.totalNotesHit, + totalNotes: Highscore.tallies.totalNotes, + }, + accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, + }, + isNewHighscore: isNewHighscore }); res.camera = camHUD; openSubState(res); diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx index 12f395d0f..3ae8ad138 100644 --- a/source/funkin/play/ResultState.hx +++ b/source/funkin/play/ResultState.hx @@ -1,5 +1,6 @@ package funkin.play; +import funkin.util.MathUtil; import funkin.ui.story.StoryMenuState; import funkin.graphics.adobeanimate.FlxAtlasSprite; import flixel.FlxSprite; @@ -16,6 +17,8 @@ import flixel.tweens.FlxTween; import funkin.audio.FunkinSound; import flixel.util.FlxGradient; import flixel.util.FlxTimer; +import funkin.save.Save; +import funkin.save.Save.SaveScoreData; import funkin.graphics.shaders.LeftMaskShader; import funkin.play.components.TallyCounter; @@ -42,12 +45,15 @@ class ResultState extends MusicBeatSubState override function create():Void { - if (params.tallies.sick == params.tallies.totalNotesHit - && params.tallies.maxCombo == params.tallies.totalNotesHit) resultsVariation = PERFECT; - else if (params.tallies.missed + params.tallies.bad + params.tallies.shit >= params.tallies.totalNotes * 0.50) - resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending! - else - resultsVariation = NORMAL; + /* + if (params.scoreData.sick == params.scoreData.totalNotesHit + && params.scoreData.maxCombo == params.scoreData.totalNotesHit) resultsVariation = PERFECT; + else if (params.scoreData.missed + params.scoreData.bad + params.scoreData.shit >= params.scoreData.totalNotes * 0.50) + resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending! + else + resultsVariation = NORMAL; + */ + resultsVariation = NORMAL; FunkinSound.playMusic('results$resultsVariation', { @@ -130,12 +136,16 @@ class ResultState extends MusicBeatSubState var diffSpr:String = switch (PlayState.instance.currentDifficulty) { - case 'EASY': + case 'easy': 'difEasy'; - case 'NORMAL': + case 'normal': 'difNormal'; - case 'HARD': + case 'hard': 'difHard'; + case 'erect': + 'difErect'; + case 'nightmare': + 'difNightmare'; case _: 'difNormal'; } @@ -195,29 +205,33 @@ class ResultState extends MusicBeatSubState * NOTE: We display how many notes were HIT, not how many notes there were in total. * */ - var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit); + var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.scoreData.tallies.totalNotesHit); ratingGrp.add(totalHit); - var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.tallies.maxCombo); + var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.scoreData.tallies.maxCombo); ratingGrp.add(maxCombo); hStuf += 2; var extraYOffset:Float = 5; - var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.tallies.sick, 0xFF89E59E); + var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.scoreData.tallies.sick, 0xFF89E59E); ratingGrp.add(tallySick); - var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5); + var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.scoreData.tallies.good, 0xFF89C9E5); ratingGrp.add(tallyGood); - var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xFFE6CF8A); + var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.scoreData.tallies.bad, 0xFFE6CF8A); ratingGrp.add(tallyBad); - var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A); + var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.scoreData.tallies.shit, 0xFFE68C8A); ratingGrp.add(tallyShit); - var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.tallies.missed, 0xFFC68AE6); + var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6); ratingGrp.add(tallyMissed); + var score:TallyCounter = new TallyCounter(825, 630, params.scoreData.score, RIGHT); + score.scale.set(2, 2); + ratingGrp.add(score); + for (ind => rating in ratingGrp.members) { rating.visible = false; @@ -235,9 +249,16 @@ class ResultState extends MusicBeatSubState scorePopin.animation.play("score"); scorePopin.visible = true; - highscoreNew.visible = true; - highscoreNew.animation.play("new"); - FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut}); + if (params.isNewHighscore) + { + highscoreNew.visible = true; + highscoreNew.animation.play("new"); + FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut}); + } + else + { + highscoreNew.visible = false; + } }; switch (resultsVariation) @@ -276,8 +297,6 @@ class ResultState extends MusicBeatSubState } }); - if (params.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!"); - super.create(); } @@ -393,8 +412,13 @@ typedef ResultsStateParams = */ var title:String; + /** + * Whether the displayed score is a new highscore + */ + var isNewHighscore:Bool; + /** * The score, accuracy, and judgements. */ - var tallies:Highscore.Tallies; + var scoreData:SaveScoreData; }; diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx index 39fc192a0..724bf0cb9 100644 --- a/source/funkin/play/components/PopUpStuff.hx +++ b/source/funkin/play/components/PopUpStuff.hx @@ -85,7 +85,7 @@ class PopUpStuff extends FlxTypedGroup comboSpr.velocity.y -= 150; comboSpr.velocity.x += FlxG.random.int(1, 10); - add(comboSpr); + // add(comboSpr); if (PlayState.instance.currentStageId.startsWith('school')) { diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx index 77e6ef4ec..35a8f3f51 100644 --- a/source/funkin/play/components/TallyCounter.hx +++ b/source/funkin/play/components/TallyCounter.hx @@ -6,6 +6,8 @@ import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.math.FlxMath; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; +import flixel.text.FlxText.FlxTextAlign; +import funkin.util.MathUtil; /** * Numerical counters used next to each judgement in the Results screen. @@ -13,18 +15,23 @@ import flixel.tweens.FlxTween; class TallyCounter extends FlxTypedSpriteGroup { public var curNumber:Float = 0; - public var neededNumber:Int = 0; + public var flavour:Int = 0xFFFFFFFF; - public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF) + public var align:FlxTextAlign = FlxTextAlign.LEFT; + + public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF, align:FlxTextAlign = FlxTextAlign.LEFT) { super(x, y); + this.align = align; + this.flavour = flavour; this.neededNumber = neededNumber; - drawNumbers(); + + if (curNumber == neededNumber) drawNumbers(); } var tmr:Float = 0; @@ -41,6 +48,8 @@ class TallyCounter extends FlxTypedSpriteGroup var seperatedScore:Array = []; var tempCombo:Int = Math.round(curNumber); + var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber)))); + while (tempCombo != 0) { seperatedScore.push(tempCombo % 10); @@ -55,7 +64,13 @@ class TallyCounter extends FlxTypedSpriteGroup { if (ind >= members.length) { - var numb:TallyNumber = new TallyNumber(ind * 43, 0, num); + var xPos = ind * (43 * this.scale.x); + if (this.align == FlxTextAlign.RIGHT) + { + xPos -= (fullNumberDigits * (43 * this.scale.x)); + } + var numb:TallyNumber = new TallyNumber(xPos, 0, num); + numb.scale.set(this.scale.x, this.scale.y); add(numb); numb.color = flavour; } diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 9a6699c43..2b10c05ee 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -295,6 +295,11 @@ class Strumline extends FlxSpriteGroup { if (noteData.length == 0) return; + // Ensure note data gets reset if the song happens to loop. + // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times. + // I don't remember what bug I was trying to fix by adding this. + // if (conductorInUse.currentStep == 0) nextNoteIndex = 0; + var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0; var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS; var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS; @@ -822,7 +827,7 @@ class Strumline extends FlxSpriteGroup { // The note sprite pool is full and all note splashes are active. // We have to create a new note. - result = new SustainTrail(0, 100, noteStyle); + result = new SustainTrail(0, 0, noteStyle); this.holdNotes.add(result); } diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx new file mode 100644 index 000000000..bf7f13ad5 --- /dev/null +++ b/source/funkin/ui/credits/CreditsData.hx @@ -0,0 +1,34 @@ +package funkin.ui.credits; + +/** + * The members of the Funkin' Crew, organized by their roles. + */ +typedef CreditsData = +{ + var entries:Array; +} + +/** + * The members of a specific role on the Funkin' Crew. + */ +typedef CreditsDataRole = +{ + @:optional + var header:String; + + @:optional + @:default([]) + var body:Array; + + @:optional + @:default(false) + var appendBackers:Bool; +} + +/** + * A member of a specific person on the Funkin' Crew. + */ +typedef CreditsDataMember = +{ + var line:String; +} diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx new file mode 100644 index 000000000..f2722ffbf --- /dev/null +++ b/source/funkin/ui/credits/CreditsDataHandler.hx @@ -0,0 +1,134 @@ +package funkin.ui.credits; + +import funkin.data.JsonFile; + +using StringTools; + +@:nullSafety +class CreditsDataHandler +{ + public static final BACKER_PUBLIC_URL:String = 'https://funkin.me/backers'; + + #if HARDCODED_CREDITS + static final CREDITS_DATA_PATH:String = "assets/exclude/data/credits.json"; + #else + static final CREDITS_DATA_PATH:String = "assets/data/credits.json"; + #end + + public static function debugPrint(data:Null):Void + { + if (data == null) + { + trace('CreditsData(NULL)'); + + return; + } + + var entryCount = data.entries.length; + var lineCount = 0; + for (entry in data.entries) + { + lineCount += entry?.body?.length ?? 0; + } + + trace('CreditsData($entryCount entries containing $lineCount lines)'); + } + + /** + * If for some reason the full credits won't load, + * use this hardcoded data for the original Funkin' Crew. + * + * @return `CreditsData` + */ + public static inline function getFallback():CreditsData + { + return { + entries: [ + { + header: 'Founders', + body: [ + {line: 'ninjamuffin99'}, + {line: 'PhantomArcade'}, + {line: 'KawaiSprite'}, + {line: 'evilsk8r'}, + ] + }, + { + header: 'Kickstarter Backers', + appendBackers: true + } + ] + }; + } + + public static function fetchBackerEntries():Array + { + // TODO: Replace this with a web request. + // We can't just grab the current Kickstarter data and include it in builds, + // because we don't want to deadname people who haven't logged into the portal yet. + // It can be async and paginated for performance! + return ['See the list of backers at $BACKER_PUBLIC_URL.']; + } + + #if HARDCODED_CREDITS + /** + * The data for the credits. + * Hardcoded into game via a macro at compile time. + */ + public static final CREDITS_DATA:Null = #if macro null #else CreditsDataMacro.loadCreditsData() #end; + #else + + /** + * The data for the credits. + * Loaded dynamically from the game folder when needed. + * Nullable because data may fail to parse. + */ + public static var CREDITS_DATA(get, default):Null = null; + + static function get_CREDITS_DATA():Null + { + if (CREDITS_DATA == null) CREDITS_DATA = parseCreditsData(fetchCreditsData()); + + return CREDITS_DATA; + } + + static function fetchCreditsData():funkin.data.JsonFile + { + var rawJson:String = openfl.Assets.getText(CREDITS_DATA_PATH).trim(); + + return { + fileName: CREDITS_DATA_PATH, + contents: rawJson + }; + } + + static function parseCreditsData(file:JsonFile):Null + { + #if !macro + if (file.contents == null) return null; + + var parser = new json2object.JsonParser(); + parser.ignoreUnknownVariables = false; + trace('[CREDITS] Parsing credits data from ${CREDITS_DATA_PATH}'); + parser.fromJson(file.contents, file.fileName); + + if (parser.errors.length > 0) + { + printErrors(parser.errors, file.fileName); + return null; + } + return parser.value; + #else + return null; + #end + } + + static function printErrors(errors:Array, id:String = ''):Void + { + trace('[CREDITS] Failed to parse credits data: ${id}'); + + for (error in errors) + funkin.data.DataError.printError(error); + } + #end +} diff --git a/source/funkin/ui/credits/CreditsDataMacro.hx b/source/funkin/ui/credits/CreditsDataMacro.hx new file mode 100644 index 000000000..c97770eef --- /dev/null +++ b/source/funkin/ui/credits/CreditsDataMacro.hx @@ -0,0 +1,67 @@ +package funkin.ui.credits; + +#if macro +import haxe.macro.Context; +#end + +@:access(funkin.ui.credits.CreditsDataHandler) +class CreditsDataMacro +{ + public static macro function loadCreditsData():haxe.macro.Expr.ExprOf + { + #if !display + trace('Hardcoding credits data...'); + var json = CreditsDataMacro.fetchJSON(); + + if (json == null) + { + Context.info('[WARN] Could not fetch JSON data for credits.', Context.currentPos()); + return macro $v{CreditsDataHandler.getFallback()}; + } + + var creditsData = CreditsDataMacro.parseJSON(json); + + if (creditsData == null) + { + Context.info('[WARN] Could not parse JSON data for credits.', Context.currentPos()); + return macro $v{CreditsDataHandler.getFallback()}; + } + + CreditsDataHandler.debugPrint(creditsData); + return macro $v{creditsData}; + // return macro $v{null}; + #else + // `#if display` is used for code completion. In this case we return + // a minimal value to keep code completion fast. + return macro $v{CreditsDataHandler.getFallback()}; + #end + } + + #if macro + static function fetchJSON():Null + { + return sys.io.File.getContent(CreditsDataHandler.CREDITS_DATA_PATH); + } + + /** + * Parse the JSON data for the credits. + * + * @param json The string data to parse. + * @return The parsed data. + */ + static function parseJSON(json:String):Null + { + try + { + // TODO: Use something with better validation but that still works at macro time. + return haxe.Json.parse(json); + } + catch (e) + { + trace('[ERROR] Failed to parse JSON data for credits.'); + trace(e); + return null; + } + } + #end +} diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx new file mode 100644 index 000000000..d43e25114 --- /dev/null +++ b/source/funkin/ui/credits/CreditsState.hx @@ -0,0 +1,213 @@ +package funkin.ui.credits; + +import flixel.text.FlxText; +import flixel.util.FlxColor; +import funkin.audio.FunkinSound; +import flixel.FlxSprite; +import flixel.group.FlxSpriteGroup; + +/** + * The state used to display the credits scroll. + * AAA studios often fail to credit properly, and we're better than them! + */ +class CreditsState extends MusicBeatState +{ + /** + * The height the credits should start at. + * Make this an instanced variable so it gets set by the constructor. + */ + final STARTING_HEIGHT = FlxG.height; + + /** + * The padding on each side of the screen. + */ + static final SCREEN_PAD = 24; + + /** + * The width of the screen the credits should maximally fill up. + * Make this an instanced variable so it gets set by the constructor. + */ + final FULL_WIDTH = FlxG.width - (SCREEN_PAD * 2); + + /** + * The font to use to display the text. + * To use a font from the `assets` folder, use `Paths.font(...)`. + * Choose something that will render Unicode properly. + */ + static final CREDITS_FONT = 'Arial'; + + /** + * The size of the font. + */ + static final CREDITS_FONT_SIZE = 48; + + static final CREDITS_HEADER_FONT_SIZE = 72; + + /** + * The color of the text itself. + */ + static final CREDITS_FONT_COLOR = FlxColor.WHITE; + + /** + * The color of the text's outline. + */ + static final CREDITS_FONT_STROKE_COLOR = FlxColor.BLACK; + + /** + * The speed the credits scroll at, in pixels per second. + */ + static final CREDITS_SCROLL_BASE_SPEED = 25.0; + + /** + * The speed the credits scroll at while the button is held, in pixels per second. + */ + static final CREDITS_SCROLL_FAST_SPEED = CREDITS_SCROLL_BASE_SPEED * 4.0; + + /** + * The actual sprites and text used to display the credits. + */ + var creditsGroup:FlxSpriteGroup; + + var scrollPaused:Bool = false; + + public function new() + { + super(); + } + + public override function create():Void + { + super.create(); + + // Background + var bg = new FlxSprite(Paths.image('menuDesat')); + bg.scrollFactor.x = 0; + bg.scrollFactor.y = 0; + bg.setGraphicSize(Std.int(FlxG.width)); + bg.updateHitbox(); + bg.x = 0; + bg.y = 0; + bg.visible = true; + bg.color = 0xFFB57EDC; // Lavender + add(bg); + + // TODO: Once we need to display Kickstarter backers, + // make this use a recycled pool so we don't kill peformance. + creditsGroup = new FlxSpriteGroup(); + creditsGroup.x = SCREEN_PAD; + creditsGroup.y = STARTING_HEIGHT; + + buildCreditsGroup(); + + add(creditsGroup); + + // Music + FunkinSound.playMusic('freeplayRandom', + { + startingVolume: 0.0, + overrideExisting: true, + restartTrack: true, + loop: true + }); + FlxG.sound.music.fadeIn(2, 0, 0.8); + } + + function buildCreditsGroup():Void + { + var y = 0; + + for (entry in CreditsDataHandler.CREDITS_DATA.entries) + { + if (entry.header != null) + { + creditsGroup.add(buildCreditsLine(entry.header, y, true, CreditsSide.Center)); + y += CREDITS_HEADER_FONT_SIZE; + } + + for (line in entry?.body ?? []) + { + creditsGroup.add(buildCreditsLine(line.line, y, false, CreditsSide.Center)); + y += CREDITS_FONT_SIZE; + } + + if (entry.appendBackers) + { + var backers = CreditsDataHandler.fetchBackerEntries(); + for (backer in backers) + { + creditsGroup.add(buildCreditsLine(backer, y, false, CreditsSide.Center)); + y += CREDITS_FONT_SIZE; + } + } + + // Padding between each role. + y += CREDITS_FONT_SIZE * 2; + } + } + + function buildCreditsLine(text:String, yPos:Float, header:Bool, side:CreditsSide = CreditsSide.Center):FlxText + { + // CreditsSide.Center: Full screen width + // CreditsSide.Left: Left half of screen + // CreditsSide.Right: Right half of screen + var xPos = (side == CreditsSide.Right) ? (FULL_WIDTH / 2) : 0; + var width = (side == CreditsSide.Center) ? FULL_WIDTH : (FULL_WIDTH / 2); + var size = header ? CREDITS_HEADER_FONT_SIZE : CREDITS_FONT_SIZE; + + var creditsLine:FlxText = new FlxText(xPos, yPos, width, text); + creditsLine.setFormat(CREDITS_FONT, size, CREDITS_FONT_COLOR, FlxTextAlign.CENTER, FlxTextBorderStyle.OUTLINE, CREDITS_FONT_STROKE_COLOR, true); + + return creditsLine; + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (!scrollPaused) + { + // TODO: Replace with whatever the special note button is. + if (controls.ACCEPT || FlxG.keys.pressed.SPACE) + { + // Move the whole group. + creditsGroup.y -= CREDITS_SCROLL_FAST_SPEED * elapsed; + } + else + { + // Move the whole group. + creditsGroup.y -= CREDITS_SCROLL_BASE_SPEED * elapsed; + } + } + + if (controls.BACK || hasEnded()) + { + exit(); + } + else if (controls.PAUSE) + { + scrollPaused = !scrollPaused; + } + } + + function hasEnded():Bool + { + return creditsGroup.y < -creditsGroup.height; + } + + function exit():Void + { + FlxG.switchState(new funkin.ui.mainmenu.MainMenuState()); + } + + public override function destroy():Void + { + super.destroy(); + } +} + +enum CreditsSide +{ + Left; + Center; + Right; +} diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index 40081b2ec..dbae59f97 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -133,8 +133,8 @@ class FreeplayState extends MusicBeatSubState var stickerSubState:StickerSubState; - static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; - static var rememberedSongId:Null = null; + public static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; + public static var rememberedSongId:Null = null; public function new(?params:FreeplayStateParams, ?stickers:StickerSubState) { @@ -536,21 +536,18 @@ class FreeplayState extends MusicBeatSubState }); } + var currentFilter:SongFilter = null; + var currentFilteredSongs:Array = []; + /** * Given the current filter, rebuild the current song list. * * @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite) * @param force + * @param onlyIfChanged Only apply the filter if the song list has changed */ - public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void + public function generateSongList(filterStuff:Null, force:Bool = false, onlyIfChanged:Bool = true):Void { - curSelected = 1; - - for (cap in grpCapsules.members) - { - cap.kill(); - } - var tempSongs:Array = songs; if (filterStuff != null) @@ -582,6 +579,35 @@ class FreeplayState extends MusicBeatSubState } } + // Filter further by current selected difficulty. + if (currentDifficulty != null) + { + tempSongs = tempSongs.filter(song -> { + if (song == null) return true; // Random + return song.songDifficulties.contains(currentDifficulty); + }); + } + + if (onlyIfChanged) + { + // == performs equality by reference + if (tempSongs.isEqualUnordered(currentFilteredSongs)) return; + } + + // Only now do we know that the filter is actually changing. + + rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId; + + for (cap in grpCapsules.members) + { + cap.kill(); + } + + currentFilter = filterStuff; + + currentFilteredSongs = tempSongs; + curSelected = 0; + var hsvShader:HSVShader = new HSVShader(); var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem); @@ -658,11 +684,12 @@ class FreeplayState extends MusicBeatSubState if (FlxG.keys.justPressed.F) { - if (songs[curSelected] != null) + var targetSong = grpCapsules.members[curSelected]?.songData; + if (targetSong != null) { var realShit:Int = curSelected; - songs[curSelected].isFav = !songs[curSelected].isFav; - if (songs[curSelected].isFav) + targetSong.isFav = !targetSong.isFav; + if (targetSong.isFav) { FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4, { @@ -854,11 +881,13 @@ class FreeplayState extends MusicBeatSubState { dj.resetAFKTimer(); changeDiff(-1); + generateSongList(currentFilter, true); } if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL) { dj.resetAFKTimer(); changeDiff(1); + generateSongList(currentFilter, true); } if (controls.BACK && !typing.hasFocus) @@ -926,7 +955,7 @@ class FreeplayState extends MusicBeatSubState public override function destroy():Void { super.destroy(); - var daSong:Null = songs[curSelected]; + var daSong:Null = grpCapsules.members[curSelected]?.songData; if (daSong != null) { clearDaCache(daSong.songName); @@ -948,10 +977,10 @@ class FreeplayState extends MusicBeatSubState currentDifficulty = diffIdsCurrent[currentDifficultyIndex]; - var daSong:Null = songs[curSelected]; + var daSong:Null = grpCapsules.members[curSelected].songData; if (daSong != null) { - var songScore:SaveScoreData = Save.instance.getSongScore(songs[curSelected].songId, currentDifficulty); + var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore?.accuracy ?? 0.0; rememberedDifficulty = currentDifficulty; @@ -1103,6 +1132,12 @@ class FreeplayState extends MusicBeatSubState targetVariation: targetVariation, practiceMode: false, minimalMode: false, + + #if (debug || FORCE_DEBUG_VERSION) + botPlayMode: FlxG.keys.pressed.SHIFT, + #else + botPlayMode: false, + #end // TODO: Make these an option! It's currently only accessible via chart editor. // startTimestamp: 0.0, // playbackRate: 0.5, @@ -1115,10 +1150,12 @@ class FreeplayState extends MusicBeatSubState { if (rememberedSongId != null) { - curSelected = songs.findIndex(function(song) { + curSelected = currentFilteredSongs.findIndex(function(song) { if (song == null) return false; return song.songId == rememberedSongId; }); + + if (curSelected == -1) curSelected = 0; } if (rememberedDifficulty != null) @@ -1127,7 +1164,7 @@ class FreeplayState extends MusicBeatSubState } // Set the difficulty star count on the right. - var daSong:Null = songs[curSelected]; + var daSong:Null = grpCapsules.members[curSelected]?.songData; albumRoll.setDifficultyStars(daSong?.songRating ?? 0); } @@ -1156,6 +1193,7 @@ class FreeplayState extends MusicBeatSubState { intendedScore = 0; intendedCompletion = 0.0; + diffIdsCurrent = diffIdsTotal; rememberedSongId = null; rememberedDifficulty = null; } diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index df81cf6f2..f38db1ccd 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -51,10 +51,7 @@ class MainMenuState extends MusicBeatState transIn = FlxTransitionableState.defaultTransIn; transOut = FlxTransitionableState.defaultTransOut; - if (!(FlxG?.sound?.music?.playing ?? false)) - { - playMenuMusic(); - } + playMenuMusic(); persistentUpdate = false; persistentDraw = true; @@ -110,14 +107,21 @@ class MainMenuState extends MusicBeatState }); #if CAN_OPEN_LINKS + // In order to prevent popup blockers from triggering, + // we need to open the link as an immediate result of a keypress event, + // so we can't wait for the flicker animation to complete. var hasPopupBlocker = #if web true #else false #end; - createMenuItem('donate', 'mainmenu/donate', selectDonate, hasPopupBlocker); + createMenuItem('merch', 'mainmenu/merch', selectMerch, hasPopupBlocker); #end createMenuItem('options', 'mainmenu/options', function() { startExitState(() -> new funkin.ui.options.OptionsState()); }); + createMenuItem('credits', 'mainmenu/credits', function() { + startExitState(() -> new funkin.ui.credits.CreditsState()); + }); + // Reset position of menu items. var spacing = 160; var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2; @@ -126,6 +130,9 @@ class MainMenuState extends MusicBeatState var menuItem = menuItems.members[i]; menuItem.x = FlxG.width / 2; menuItem.y = top + spacing * i; + menuItem.scrollFactor.x = 0.0; + // This one affects how much the menu items move when you scroll between them. + menuItem.scrollFactor.y = 0.4; } resetCamStuff(); @@ -213,6 +220,11 @@ class MainMenuState extends MusicBeatState { WindowUtil.openURL(Constants.URL_ITCH); } + + function selectMerch() + { + WindowUtil.openURL(Constants.URL_MERCH); + } #end #if newgrounds diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index 980c264e3..d913b8099 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -210,7 +210,8 @@ class LoadingState extends MusicBeatState } // Load and cache the song's charts. - if (params?.targetSong != null) + // Don't do this if we already provided the music and charts. + if (params?.targetSong != null && !params.overrideMusic) { params.targetSong.cacheCharts(true); } diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index 5d355f2da..70899ce92 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -60,6 +60,11 @@ class Constants */ // ============================== + /** + * Link to buy merch for the game. + */ + public static final URL_MERCH:String = 'https://needlejuicerecords.com/pages/friday-night-funkin'; + /** * Preloader sitelock. * Matching is done by `FlxStringUtil.getDomain`, so any URL on the domain will work. @@ -181,6 +186,12 @@ class Constants */ public static final DEFAULT_DIFFICULTY_LIST:Array = ['easy', 'normal', 'hard']; + /** + * List of all difficulties used by the base game. + * Includes Erect and Nightmare. + */ + public static final DEFAULT_DIFFICULTY_LIST_FULL:Array = ['easy', 'normal', 'hard', 'erect', 'nightmare']; + /** * Default player character for charts. */