diff --git a/assets b/assets index c1899ffbef..53d97b69a3 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit c1899ffbefb9a7c98b030c75a33623431d7ea6ba +Subproject commit 53d97b69a36d6154e9bcf4086c6ce04b9824e190 diff --git a/source/Main.hx b/source/Main.hx index c0a85d315f..42b1f2da0b 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -46,8 +46,7 @@ class Main extends Sprite funkin.util.logging.AnsiTrace.traceBF(); // Load mods to override assets. - // TODO: Replace with loadEnabledMods() once the user can configure the mod list. - funkin.modding.PolymodHandler.loadAllMods(); + funkin.modding.PolymodHandler.loadEnabledMods(); if (stage != null) { diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 62b05fb90e..260d549398 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -43,7 +43,7 @@ class PolymodHandler /** * Where relative to the executable that mods are located. */ - static final MOD_FOLDER:String = + public static final MOD_FOLDER:String = #if (REDIRECT_ASSETS_FOLDER && macos) '../../../../../../../example_mods' #elseif REDIRECT_ASSETS_FOLDER @@ -414,8 +414,7 @@ class PolymodHandler Polymod.clearScripts(); // Forcibly reload Polymod so it finds any new files. - // TODO: Replace this with loadEnabledMods(). - funkin.modding.PolymodHandler.loadAllMods(); + funkin.modding.PolymodHandler.loadEnabledMods(); // Reload scripted classes so stages and modules will update. Polymod.registerAllScriptClasses(); diff --git a/source/funkin/ui/debug/mods/ModsSelectState.hx b/source/funkin/ui/debug/mods/ModsSelectState.hx new file mode 100644 index 0000000000..49f723a791 --- /dev/null +++ b/source/funkin/ui/debug/mods/ModsSelectState.hx @@ -0,0 +1,279 @@ +package funkin.ui.debug.mods; + +import flixel.FlxG; +import funkin.audio.FunkinSound; +import funkin.input.Cursor; +import funkin.modding.PolymodHandler; +import funkin.save.Save; +import funkin.ui.debug.mods.components.ModInfoWindow; +import funkin.ui.debug.mods.components.ModButton; +import haxe.ui.backend.flixel.UISubState; +import haxe.ui.events.UIEvent; +import haxe.ui.components.Button; +import haxe.ui.containers.VBox; +import haxe.ui.containers.windows.WindowManager; +import haxe.ui.tooltips.ToolTipManager; +import polymod.util.DependencyUtil; +import polymod.Polymod.ModMetadata; + +using StringTools; + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/mod-select/main-view.xml")) +class ModsSelectState extends UISubState +{ + var modListLoadedBox:VBox; + var modListUnloadedBox:VBox; + var modListLoadAll:Button; + var modListUnloadAll:Button; + var modListApplyButton:Button; + var modListExitButton:Button; + + var prevPersistentDraw:Bool; + var prevPersistentUpdate:Bool; + + /** + * A list for all enabled mods. The main point of this list is to provide priorities of enabled mods without focusing on dependencies. + * Polymod handles sorting by dependencies internally upon loading mods. + */ + var changeableModList:Array = []; + + override public function create() + { + super.create(); + + prevPersistentDraw = FlxG.state.persistentDraw; + prevPersistentUpdate = FlxG.state.persistentUpdate; + + FlxG.state.persistentDraw = false; + FlxG.state.persistentUpdate = false; + + Cursor.show(); + WindowManager.instance.reset(); + + changeableModList = Save.instance.enabledModIds.copy(); + reloadModOrder(); + + modListLoadAll.onClick = function(_) { + changeableModList = PolymodHandler.getAllModIds().copy(); + reloadModOrder(); + } + + modListUnloadAll.onClick = function(_) { + changeableModList = []; + reloadModOrder(); + } + + modListLoadedBox.registerEvent(UIEvent.COMPONENT_ADDED, function(_) { + modListApplyButton.disabled = false; + }); + modListUnloadedBox.registerEvent(UIEvent.COMPONENT_ADDED, function(_) { + modListApplyButton.disabled = false; + }); + + modListApplyButton.onClick = function(_) save(); + modListExitButton.onClick = function(_) close(); + } + + // This should also account for the mod order and put dependencies on the bottom. + function reloadModOrder() + { + modListUnloadedBox.removeAllComponents(); + modListLoadedBox.removeAllComponents(); + + var allMods:Array = listAllModsOrdered(); + + for (mod in allMods) + { + var isLoaded:Bool = changeableModList.contains(mod.id); + var dependableChildren:Array = []; + var optionalChildren:Array = []; + + for (childMod in allMods) + { + if (childMod.id == mod.id) continue; + for (dep => ver in childMod.dependencies) + { + if (dep == mod.id && ver.isSatisfiedBy(mod.modVersion) && !dependableChildren.contains(mod.id)) dependableChildren.push(mod.id); + } + for (dep => ver in childMod.optionalDependencies) + { + if (dep == mod.id && ver.isSatisfiedBy(mod.modVersion) && !optionalChildren.contains(mod.id)) optionalChildren.push(mod.id); + } + } + + // Update color of the mod title based on if it's an optional dependency, a required dependency or not a dependency at all. + var overrideColor:Null = null; + if (optionalChildren.length > 0 && dependableChildren.length == 0) + { + overrideColor = "0xffff00"; + } + else if (dependableChildren.length > 0) + { + overrideColor = "0xff8c00"; + } + + var button = new ModButton(mod, overrideColor); + button.tooltip = "Click to Enable/Disable.\nRight Click to View Info."; + if (isLoaded) button.tooltip += "\nShift+Click to Move Upwards.\nCtrl+Click to Move Downwards."; + + // Check if there is a window present and apply a different style to the corresponding button. + if (windowContainer.childComponents.length > 0) + { + var firstComp = windowContainer.childComponents[0]; + if (Std.isOfType(firstComp, ModInfoWindow)) + { + var modWindow:ModInfoWindow = cast firstComp; + if (button.linkedMod.id == modWindow.linkedMod.id + && button.linkedMod.modVersion == modWindow.linkedMod.modVersion) button.styleNames = "modBoxSelected"; + } + } + + button.onRightClick = function(_) { + cleanupBeforeSwitch(); + button.styleNames = "modBoxSelected"; + var infoWindow = new ModInfoWindow(this, mod); + + if (dependableChildren.length > 0) infoWindow.modWindowDependency.text = "This Mod is a Dependency of: " + dependableChildren.join(", "); + if (optionalChildren.length > 0) infoWindow.modWindowOptional.text = "This Mod is an Optional Dependency of: " + optionalChildren.join(", "); + + windowContainer.addComponent(infoWindow); + } + + button.onClick = function(_) { + if (isLoaded) + { + var modIndex:Int = changeableModList.indexOf(mod.id); + if (FlxG.keys.pressed.SHIFT) modIndex++; + else if (FlxG.keys.pressed.CONTROL) modIndex--; + + var prevIndex:Int = changeableModList.indexOf(mod.id); + changeableModList.remove(mod.id); + + if (prevIndex != modIndex) + { + // The priority of the mod has been changed. + modIndex = Std.int(flixel.math.FlxMath.bound(modIndex, 0, changeableModList.length - 1)); + changeableModList.insert(modIndex, mod.id); + } + else + { + // Go through a list of all mods. If a mod depends on this mod to works and this mod's version satisfies the mod's version rule, remove it from the list. + for (childMod in allMods) + { + if (childMod.dependencies.exists(mod.id) + && childMod.dependencies[mod.id].isSatisfiedBy(mod.modVersion) + && changeableModList.contains(childMod.id)) changeableModList.remove(childMod.id); + } + } + } + else + { + changeableModList.push(mod.id); + + // Go through a list of all mods. If a mod is a dependency of this mod and it's version satisfies this mod's version rule, add it to the list. + for (childMod in allMods) + { + if (mod.dependencies.exists(childMod.id) + && mod.dependencies[childMod.id].isSatisfiedBy(childMod.modVersion) + && !changeableModList.contains(childMod.id)) changeableModList.push(childMod.id); + } + } + + reloadModOrder(); + } + + if (isLoaded) modListLoadedBox.addComponent(button); + else + modListUnloadedBox.addComponent(button); + } + } + + /** + * Order the mods so that the enabled mods are first. + */ + function listAllModsOrdered() + { + var allMods:Array = PolymodHandler.getAllMods().copy(); + var finishedList:Array = []; + + for (modId in changeableModList) + { + for (mod in allMods) + { + if (mod.id == modId) + { + finishedList.push(mod); + allMods.remove(mod); + break; + } + } + } + + // Order the enabled mods by dependencies. + finishedList = DependencyUtil.sortByDependencies(finishedList); + + // Add the remainding mods. + finishedList = finishedList.concat(allMods); + + // Reverse the list so that the first mods go down. + finishedList.reverse(); + + return finishedList; + } + + function cleanupBeforeSwitch() + { + for (window in WindowManager.instance.windows) + WindowManager.instance.closeWindow(window); + + for (button in modListUnloadedBox.childComponents.concat(modListLoadedBox.childComponents)) + { + if (Std.isOfType(button, ModButton)) + { + var realButton:ModButton = cast button; + realButton.styleNames = "modBox"; + } + } + + windowContainer.removeAllComponents(); + } + + override public function close() + { + FlxG.state.persistentDraw = prevPersistentDraw; + FlxG.state.persistentUpdate = prevPersistentUpdate; + + Cursor.hide(); + WindowManager.instance.reset(); + ToolTipManager.instance.reset(); + + super.close(); + } + + function save() + { + trace("Loading Mods: " + changeableModList); + + Save.instance.enabledModIds = changeableModList; + PolymodHandler.forceReloadAssets(); + modListApplyButton.disabled = true; + } + + override public function update(elapsed:Float) + { + super.update(elapsed); + + if (FlxG.mouse.justPressed || FlxG.mouse.justPressedRight) + { + FunkinSound.playOnce(Paths.sound("chartingSounds/ClickDown")); + } + + if (FlxG.mouse.justReleased || FlxG.mouse.justReleasedRight) + { + FunkinSound.playOnce(Paths.sound("chartingSounds/ClickUp")); + } + + if (controls.BACK) close(); + if (controls.ACCEPT) save(); + } +} diff --git a/source/funkin/ui/debug/mods/components/ModButton.hx b/source/funkin/ui/debug/mods/components/ModButton.hx new file mode 100644 index 0000000000..fe93013af9 --- /dev/null +++ b/source/funkin/ui/debug/mods/components/ModButton.hx @@ -0,0 +1,24 @@ +package funkin.ui.debug.mods.components; + +import haxe.ui.containers.HBox; +import polymod.Polymod.ModMetadata; + +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/mod-select/components/mod-button.xml")) +class ModButton extends HBox +{ + public var linkedMod:ModMetadata; + + override public function new(mod:ModMetadata, ?changeTextColor:String) + { + super(); + + this.id = mod.id; + linkedMod = mod; + + modButtonLabel.text = mod.id + " (" + mod.modVersion + ")"; + if (changeTextColor != null) modButtonLabel.styleString = 'color: $changeTextColor;'; + + var img = openfl.display.BitmapData.fromBytes(mod.icon); + if (img != null) modButtonIcon.resource = new flixel.FlxSprite().loadGraphic(img).frames.frames[0]; // hacky way but it works + } +} diff --git a/source/funkin/ui/debug/mods/components/ModImageFileViewer.hx b/source/funkin/ui/debug/mods/components/ModImageFileViewer.hx new file mode 100644 index 0000000000..f687716e4c --- /dev/null +++ b/source/funkin/ui/debug/mods/components/ModImageFileViewer.hx @@ -0,0 +1,22 @@ +package funkin.ui.debug.mods.components; + +import flixel.graphics.frames.FlxFrame; +import haxe.ui.containers.windows.Window; + +@:xml(' + + + + + + +') +class ModImageFileViewer extends Window +{ + override public function new(img:FlxFrame) + { + super(); + + modWindowFileImage.resource = img; + } +} diff --git a/source/funkin/ui/debug/mods/components/ModInfoWindow.hx b/source/funkin/ui/debug/mods/components/ModInfoWindow.hx new file mode 100644 index 0000000000..085d7b86b7 --- /dev/null +++ b/source/funkin/ui/debug/mods/components/ModInfoWindow.hx @@ -0,0 +1,215 @@ +package funkin.ui.debug.mods.components; + +import funkin.modding.PolymodHandler; +import funkin.util.WindowUtil; +import haxe.io.Path; +import haxe.ui.components.Button; +import haxe.ui.components.Image; +import haxe.ui.components.Label; +import haxe.ui.components.Link; +import haxe.ui.components.Spacer; +import haxe.ui.containers.TreeViewNode; +import haxe.ui.containers.VBox; +import haxe.ui.containers.windows.WindowManager; +import polymod.Polymod.ModMetadata; +import thx.semver.VersionRule; +#if sys +import sys.FileSystem; +#end + +@:access(funkin.ui.debug.mods.ModsSelectState) +@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/mod-select/components/mod-info.xml")) +class ModInfoWindow extends VBox +{ + public var parentState:ModsSelectState; + public var linkedMod:ModMetadata; + + public var modWindowIcon:Image; + public var modWindowName:Label; + public var modWindowVersion:Label; + public var modWindowDependency:Label; + public var modWindowOptional:Label; + public var modWindowHomepage:Button; + public var modWindowDesc:Label; + public var modWindowDependencies:VBox; + public var modWindowContributors:VBox; + public var modWindowFiles:VBox; + public var modWindowLicense:Label; + + override public function new(parentState:ModsSelectState, data:ModMetadata) + { + super(); + this.parentState = parentState; + linkedMod = data; + + var img = openfl.display.BitmapData.fromBytes(data.icon); + if (img != null) modWindowIcon.resource = new flixel.FlxSprite().loadGraphic(img).frames.frames[0]; // such a hacky thing I hate it + + modWindowName.text = data.title; + modWindowDesc.text = data.description; + modWindowVersion.text = "Mod Version: " + data.modVersion; + modWindowLicense.text = "License: " + data.license; + + #if CAN_OPEN_LINKS + modWindowHomepage.disabled = (data.homepage == "" || data.homepage == null); + modWindowHomepage.onClick = function(_) WindowUtil.openURL(data.homepage); + #else + modWindowHomepage.disabled = true; + #end + + // Dependencies. + if (data.dependencies.keys().array().length > 0) + { + var nameLabel = new Label(); + nameLabel.text = "Required:"; + modWindowDependencies.addComponent(nameLabel); + + for (mod => version in data.dependencies) + { + var depLink = new Link(); + depLink.text = mod + " (" + version + ")"; + depLink.onClick = function(_) openDifferentMod(mod, version); + modWindowDependencies.addComponent(depLink); + } + } + + if (data.optionalDependencies.keys().array().length > 0) + { + var nameLabel = new Label(); + nameLabel.text = "Optional:"; + modWindowDependencies.addComponent(nameLabel); + + for (mod => version in data.optionalDependencies) + { + var depLink = new Link(); + depLink.text = mod + " (" + version + ")"; + depLink.onClick = function(_) openDifferentMod(mod, version); + modWindowDependencies.addComponent(depLink); + } + } + + // Contributors. + for (info in data.contributors) + { + var nameLabel = new Label(); + nameLabel.text = info.name; + nameLabel.styleString = "font-size: 18px; font-bold: true; font-underline: true;"; + modWindowContributors.addComponent(nameLabel); + + if (info.role != null) + { + var roleLabel = new Label(); + roleLabel.text = info.role; + modWindowContributors.addComponent(roleLabel); + } + + if (info.email != null) + { + var emailLabel = new Label(); + emailLabel.text = info.email; + modWindowContributors.addComponent(emailLabel); + } + + #if CAN_OPEN_LINKS + if (info.url != null) + { + var urlLink = new Link(); + urlLink.text = "Visit URL"; + urlLink.onClick = function(_) WindowUtil.openURL(info.url); + modWindowContributors.addComponent(urlLink); + } + #end + + var spacer = new Spacer(); + spacer.height = 25; + modWindowContributors.addComponent(spacer); + } + + // Files. + var modPath:String = PolymodHandler.MOD_FOLDER + "/" + data.id; + var pathObj:Path = new Path(modPath); + var rootFileNode:TreeViewNode = modWindowFileTree.addNode({text: pathObj.file, icon: "haxeui-core/styles/shared/folder-light.png"}); + fillUpTreeView(rootFileNode, modPath); + } + + function openDifferentMod(mod:String, version:VersionRule) + { + parentState.cleanupBeforeSwitch(); + + for (button in parentState.modListUnloadedBox.childComponents.concat(parentState.modListLoadedBox.childComponents)) + { + if (Std.isOfType(button, ModButton)) + { + var realButton:ModButton = cast button; + if (realButton.linkedMod.id == mod && version.isSatisfiedBy(realButton.linkedMod.modVersion)) + { + var dependableChildren:Array = []; + var optionalChildren:Array = []; + + for (childMod in parentState.listAllModsOrdered()) + { + if (childMod.id == mod) continue; + for (dep => ver in childMod.dependencies) + { + if (dep == mod + && ver.isSatisfiedBy(realButton.linkedMod.modVersion) + && !dependableChildren.contains(mod)) dependableChildren.push(mod); + } + for (dep => ver in childMod.optionalDependencies) + { + if (dep == mod + && ver.isSatisfiedBy(realButton.linkedMod.modVersion) + && !optionalChildren.contains(mod)) optionalChildren.push(mod); + } + } + realButton.styleNames = "modBoxSelected"; + + var infoWindow = new ModInfoWindow(parentState, realButton.linkedMod); + if (dependableChildren.length > 0) infoWindow.modWindowDependency.text = "This Mod is a Dependency of: " + dependableChildren.join(", "); + if (optionalChildren.length > 0) infoWindow.modWindowOptional.text = "This Mod is an Optional Dependency of: " + optionalChildren.join(", "); + parentState.windowContainer.addComponent(infoWindow); + break; + } + } + } + } + + function fillUpTreeView(parent:TreeViewNode, path:String) + { + #if sys + for (item in FileSystem.readDirectory(path)) + { + var fullPath = path + "/" + item; + var pathObj = new haxe.io.Path(fullPath); + var isFolder = FileSystem.isDirectory(fullPath); + + var newNode = parent.addNode( + { + text: pathObj.file + (isFolder ? "" : " (." + pathObj.ext + ")"), + icon: isFolder ? "haxeui-core/styles/shared/folder-light.png" : null + }); + + if (isFolder) + { + fillUpTreeView(newNode, fullPath); + } + else + { + newNode.onDblClick = function(_) { + switch (pathObj.ext) + { + case "txt" | "json" | "xml" | "hx" | "hxc" | "hscript" | "hxs": + WindowManager.instance.addWindow(new ModTxtFileViewer(sys.io.File.getContent(fullPath))); + + case "png" | "jpg" | "jpeg": + var bitmap = openfl.display.BitmapData.fromFile(fullPath); + var graphic = flixel.graphics.FlxGraphic.fromBitmapData(bitmap, false, fullPath); + + WindowManager.instance.addWindow(new ModImageFileViewer(graphic.imageFrame.frame)); + } + } + } + } + #end + } +} diff --git a/source/funkin/ui/debug/mods/components/ModTxtFileViewer.hx b/source/funkin/ui/debug/mods/components/ModTxtFileViewer.hx new file mode 100644 index 0000000000..c0acb1ae93 --- /dev/null +++ b/source/funkin/ui/debug/mods/components/ModTxtFileViewer.hx @@ -0,0 +1,21 @@ +package funkin.ui.debug.mods.components; + +import haxe.ui.containers.windows.Window; + +@:xml(' + + + + + +') +class ModTxtFileViewer extends Window +{ + override public function new(txt:String) + { + super(); + + modWindowFileLabel.text = txt; + } +} diff --git a/source/funkin/ui/options/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx index 6bf637eb99..7c73ef5dca 100644 --- a/source/funkin/ui/options/OptionsState.hx +++ b/source/funkin/ui/options/OptionsState.hx @@ -193,6 +193,12 @@ class OptionsMenu extends Page #end }); + #if !web + createItem("MODS", function() { + FlxG.state.openSubState(new funkin.ui.debug.mods.ModsSelectState()); + }); + #end + #if newgrounds if (NGio.isLoggedIn) createItem("LOGOUT", selectLogout); else