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.
*/