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/assets b/assets
index 763c833cb..d7e85ef60 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 763c833cbcde724d50ff31f5bac9f2ac3d5e61a7
+Subproject commit d7e85ef60933ca93d47e1db6295aba8aa64fcbdf
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/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/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index a8c2039ab..02632628f 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 = persistentDraw = true;
@@ -109,14 +106,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;
@@ -125,6 +129,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();
@@ -212,6 +219,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/util/Constants.hx b/source/funkin/util/Constants.hx
index 5d355f2da..e2e3da2c6 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.