From 608d2d93b61cef2f698a9040f0daa0717c669d91 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 14 Nov 2024 19:59:54 +0100 Subject: [PATCH 1/3] draft for a drag + drop import for mods --- client/src/FileUtils.lua | 18 +++ client/src/mods/ModImport.lua | 199 ++++++++++++++++++++++++++++++++++ docs/installMods.md | 44 ++++---- docs/sharingMods.md | 65 +++++++++++ 4 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 client/src/mods/ModImport.lua create mode 100644 docs/sharingMods.md diff --git a/client/src/FileUtils.lua b/client/src/FileUtils.lua index 07bc9b01..d1a8c390 100644 --- a/client/src/FileUtils.lua +++ b/client/src/FileUtils.lua @@ -39,6 +39,18 @@ function fileUtils.copyFile(source, destination) return success, err end +-- returns only the last directory specifier from a path specified with "/" notation +function fileUtils.getDirectoryName(directoryPath) + local len = string.len(directoryPath) + local reversed = string.reverse(directoryPath) + local index, stop, _ = string.find(reversed, "/") + if index then + return string.sub(directoryPath, len - index + 1, len) + else + return directoryPath + end +end + -- copies a file from the given source to the given destination function fileUtils.recursiveCopy(source, destination, yields) local lfs = love.filesystem @@ -88,6 +100,9 @@ function fileUtils.recursiveRemoveFiles(folder, targetName) end end +-- tries to open a file and decode it using the project's json library +-- returns nil if an error occured +-- returns the decoded json in the form of a lua table otherwise function fileUtils.readJsonFile(file) if not love.filesystem.getInfo(file, "file") then logger.debug("No file at specified path " .. file) @@ -135,6 +150,8 @@ function fileUtils.findSound(sound_name, dirs_to_check, streamed) return nil end +-- returns true if a soundfile with the given name and a valid extension exists at the given path +-- false otherwise function fileUtils.soundFileExists(soundName, path) for _, extension in pairs(SUPPORTED_SOUND_FORMATS) do if love.filesystem.exists(path .. "/" .. soundName .. extension) then @@ -145,6 +162,7 @@ function fileUtils.soundFileExists(soundName, path) return false end +-- encodes a texture in the given format and writes it to the relative filePath in the saveDirectory function fileUtils.saveTextureToFile(texture, filePath, format) local imageData = love.graphics.readbackTexture(texture) local data = imageData:encode(format) diff --git a/client/src/mods/ModImport.lua b/client/src/mods/ModImport.lua new file mode 100644 index 00000000..a5687550 --- /dev/null +++ b/client/src/mods/ModImport.lua @@ -0,0 +1,199 @@ +local tableUtils = require("common.lib.tableUtils") +local fileUtils = require("client.src.FileUtils") +local logger = require("common.lib.logger") +require("common.lib.timezones") + +local lfs = love.filesystem + +local ModImport = {} + +function ModImport.importCharacter(path) + local configPath = path .. "/config.json" + if not lfs.getInfo(configPath, "file") then + return false + else + local configData, err = lfs.read(configPath) + if not configData then + error("Error trying to import character " .. path .. "\nCouldn't read config.json\n" .. err) + else + local modConfig = json.decode(configData) + if tableUtils.contains(characters_ids, modConfig["id"]) then + local existingPath = characters[modConfig["id"]].path + local backUpPath = ModImport.createBackupDirectory(existingPath) + -- next is just a slightly scuffed way to access the only top level element in the table + -- we need to pass without the head as otherwise different folder names can screw the import up + local _, importFiles = next(ModImport.recursiveRead(path)) + local _, currentFiles = next(ModImport.recursiveRead(existingPath)) + ModImport.recursiveCompareBackupAndCopy(importFiles.files, backUpPath, currentFiles.files) + else + if not lfs.getInfo("characters/" .. modConfig["name"]) then + fileUtils.recursiveCopy(path, "characters/" .. modConfig["name"]) + else + fileUtils.recursiveCopy(path, "characters/" .. modConfig["id"]) + end + end + + return true + end + end +end + +function ModImport.importStage(path) + local configPath = path .. "/config.json" + if not lfs.getInfo(configPath, "file") then + return false + else + local configData, err = lfs.read(configPath) + if not configData then + error("Error trying to import stage " .. path .. "\nCouldn't read config.json\n" .. err) + else + local modConfig = json.decode(configData) + if tableUtils.contains(stages_ids, modConfig["id"]) then + local existingPath = stages[modConfig["id"]].path + local backUpPath = ModImport.createBackupDirectory(existingPath) + -- next is just a slightly scuffed way to access the only top level element in the table + -- we need to pass without the head as otherwise different folder names can screw the import up + local _, importFiles = next(ModImport.recursiveRead(path)) + local _, currentFiles = next(ModImport.recursiveRead(existingPath)) + ModImport.recursiveCompareBackupAndCopy(importFiles.files, backUpPath, currentFiles.files) + else + if not lfs.getInfo("stages/" .. modConfig["name"]) then + fileUtils.recursiveCopy(path, "stages/" .. modConfig["name"]) + else + fileUtils.recursiveCopy(path, "stages/" .. modConfig["id"]) + end + end + + return true + end + end +end + +function ModImport.importPanelSet(path) + local configPath = path .. "/config.json" + if not lfs.getInfo(configPath, "file") then + return false + else + local configData, err = lfs.read(configPath) + if not configData then + error("Error trying to import panels " .. path .. "\nCouldn't read config.json\n" .. err) + else + local modConfig = json.decode(configData) + if tableUtils.contains(panels_ids, modConfig["id"]) then + local existingPath = panels[modConfig["id"]].path + local backUpPath = ModImport.createBackupDirectory(existingPath) + -- next is just a slightly scuffed way to access the only top level element in the table + -- we need to pass without the head as otherwise different folder names can screw the import up + local _, importFiles = next(ModImport.recursiveRead(path)) + local _, currentFiles = next(ModImport.recursiveRead(existingPath)) + ModImport.recursiveCompareBackupAndCopy(importFiles.files, backUpPath, currentFiles.files) + else + if not lfs.getInfo("panels/" .. fileUtils.getDirectoryName(path)) then + fileUtils.recursiveCopy(path, "panels/" .. fileUtils.getDirectoryName(path)) + else + fileUtils.recursiveCopy(path, "panels/" .. modConfig["id"]) + end + end + + return true + end + end +end + +function ModImport.importTheme(path) + local configPath = path .. "/config.json" + if not lfs.getInfo(configPath, "file") then + return false + else + local configData, err = lfs.read(configPath) + if not configData then + error("Error trying to import theme " .. path .. "\nCouldn't read config.json\n" .. err) + else + local themeName = fileUtils.getDirectoryName(path) + if lfs.getInfo("themes/" .. themeName, "directory") then + local existingPath = "themes/" .. themeName + local backUpPath = ModImport.createBackupDirectory(existingPath) + local importFiles = ModImport.recursiveRead(path) + local currentFiles = ModImport.recursiveRead(existingPath) + -- unlike the other mod types, themes are (unfortunately) still identified by foldername + -- so we can keep the top level element in (if it wasn't the same, we'd have landed in the else branch) + ModImport.recursiveCompareBackupAndCopy(importFiles, backUpPath, currentFiles) + else + fileUtils.recursiveCopy(path, "themes/" .. themeName) + end + + return true + end + end +end + +function ModImport.importPuzzleFile(path) + -- we really need a proper puzzle format that guarantees identification to some degree + -- way too easy to overwrite otherwise compared to themes +end + +function ModImport.createBackupDirectory(path) + local now = os.date("*t", to_UTC(os.time())) + local backUpPath = path .. "/__backup_" .. + string.format("%04d-%02d-%02d-%02d-%02d-%02d", now.year, now.month, now.day, now.hour, now.min, now.sec) + lfs.createDirectory(backUpPath) + return backUpPath +end + +-- This function will recursively populate the passed in empty table fileTree with the directory and fileData +function ModImport.recursiveRead(folder, fileTree) + if not fileTree then + fileTree = {} + end + + local filesTable = fileUtils.getFilteredDirectoryItems(folder) + local folderName = fileUtils.getDirectoryName(folder) + fileTree[folderName] = {type = "directory", files = {}, path = folder} + logger.debug("Reading folder " .. folder .. " into memory") + for _, v in ipairs(filesTable) do + local filePath = folder .. "/" .. v + local info = lfs.getInfo(filePath) + if info then + if info.type == "file" then + logger.debug("Reading file " .. filePath .. " into memory") + local fileContent, size = lfs.read(filePath) + fileTree[folderName].files[v] = {type = "file", content = {size = size, content = fileContent}, path = filePath} + elseif info.type == "directory" then + ModImport.recursiveRead(filePath, fileTree[folderName].files) + end + end + end + return fileTree +end + +function ModImport.recursiveCompareBackupAndCopy(importFiles, backUpPath, currentFiles) + -- droppedFiles and currentFiles are always directories in the filetree structure + -- assert(droppedFiles.type == "directory") + + for key, value in pairs(importFiles) do + if value.type == "file" then + if not currentFiles[key] then + -- the file doesn't exist, we can just copy over + fileUtils.copyFile(importFiles[key].path, currentFiles.path .. "/" .. key) + elseif value.content.size == currentFiles[key].content.size and value.content.content == currentFiles[key].content.content then + -- files are identical, no need to do anything + else + -- files are not identical, copy the old one to backup before copying the new one over + fileUtils.copyFile(currentFiles[key].path, backUpPath .. "/" .. key) + fileUtils.copyFile(importFiles[key].path, currentFiles[key].path) + end + else + if currentFiles[key] then + local nextBackUpPath = backUpPath .. "/" .. key + -- create the path in the backup folder so writes to backup don't fail in the recursive call + lfs.createDirectory(nextBackUpPath) + return ModImport.recursiveCompareBackupAndCopy(value.files, nextBackUpPath, currentFiles[key].files) + else + -- the subfolder doesn't exist, we can just copy over + fileUtils.recursiveCopy(importFiles[key].path, currentFiles.path .. "/" .. key) + end + end + end +end + +return ModImport \ No newline at end of file diff --git a/docs/installMods.md b/docs/installMods.md index dfd5a95c..636670cb 100644 --- a/docs/installMods.md +++ b/docs/installMods.md @@ -9,13 +9,23 @@ The traditional mods based on image and sound assets consist of the theme, chara But there are other files you can "install" in effectively the same way so they become available in Panel Attack: Puzzles, training mode files and replays. -## Step 1: Find your Panel Attack user data folder +## Drag and Drop + +Panel Attack supports the import of mods via drag and drop for characters, panels, stages and themes. +Simply drag the .zip file with the mods onto Panel Attack while in a menu. +This method only works on Windows, MacOS and Linux. It does not work on Android. + +## Manual installation + +Manual installation is necessary whenever you want to add files not supported by the drag and drop support or if you're on Android. + +### Step 1: Find your Panel Attack user data folder Panel Attack saves most of its data in (somewhat hidden) user data folder so that multiple users of the same PC don't interfere with each other. Depending on your operating system the location is different. You can always find out about the save location by going to Options -> About -> System Info -### Windows +#### Windows Press the Windows key then type "%appdata%" without quotes and hit enter. or @@ -25,35 +35,31 @@ This folder will contain a directory called "Roaming" that holds application dat Regardless of which method you used, you should be able to find a Panel Attack directory in that location if you ever started Panel Attack before. -### MacOS +#### MacOS In your Finder, navigate to /Users/user/Library/Application Support/Panel Attack -### Linux +#### Linux Depending on whether your $XDG_DATA_HOME environment variable is set or not, the Panel Attack folder will be located in either - $XDG_DATA_HOME/love/ + $XDG_DATA_HOME/ or - ~/.local/share/love/ - -Note that running a panel.exe through wine and running a panel.love through a native love installation on the same machine may result in different save locations. + ~/.local/share/ -### Android +Note that if for some reason you run a Windows version of Panel Attack through wine, please navigate to ~/.wine and follow the directions for Windows. -Android is a special case as it is very protective of its internal data and usually does not let users edit the Panel Attack save directory on non-rooted devices. -That save data usually looks like this: - /data/data/org.love2d.android/files/save/ +#### Android -In early 2023 we changed Panel Attack to save its data in external (user-visible) storage: - /Android/data/org.love2d.android/files/save/ +Navigate to + /Android/data/com.panelattack.android/files/save/ -This automatically applies for new installations but old installations may go through a migration process. -Please ask in the discord for help with this. +Depending on your Android and file browser you may not be able to view these files on your phone. +It is generally recommended to connect Android devices to PC and use the file browser access from there. -## Step 2: Unpacking your mod and understanding where it belongs +### Step 2: Unpacking your mod and understanding where it belongs -### Unpacking a package +#### Unpacking a package This guide cannot know which exact mode you are trying to install but it is going to assume the "worst" case: You are trying to install a big package with a theme, various characters, stages, panels and maybe even puzzles. @@ -71,7 +77,7 @@ Inside you may find one or multiple folders. A good mod package will mimic the f Inside of each of these folders you will find the mod folders that need to be in the directory with the same name inside the Panel Attack folder. Once you copied everything into its correct subfolder, you will have to restart Panel Attack in order for your new mods to show up! -### Unpacking a single mod +#### Unpacking a single mod For reference, still read the part about packages above. The way in which single mods are different is that they may not follow the folder structure above but instead you have to know based on where you got the link from what kind of mod it is. diff --git a/docs/sharingMods.md b/docs/sharingMods.md new file mode 100644 index 00000000..578fa88d --- /dev/null +++ b/docs/sharingMods.md @@ -0,0 +1,65 @@ +# Creating archives for drag+drop import + +This file documents the format a mod needs to be shared in in order to work with Panel Attack's drag + drop import. + +## Folder structure + +Archives always need to replicate the root folder structure of Panel Attack's save directory. +This structure does not need to be complete but may only contain the folders you want to provide mods for. Other folders are ignored. + +## Requirements + +All mods need to at least contain a config.json file. Non-theme mods need this config.json to specify their id and in the case of characters and stages, their name, otherwise they cannot be imported. + +## Limitations + +Attack pattern file import is currently not supported. +Challenge mode file import is currently not supported. +Puzzle file import is currently not supported + +## Examples + +### Valid package example + +``` +├── Mod Package +│ ├── characters +│ │ ├── Pikachu +│ │ | ├── config.json +│ │ | ├── more files +│ │ ├── Charizard +│ │ | ├── config.json +│ │ | ├── more files +│ ├── stages +│ ├── themes +│ │ ├── PPL +│ │ | ├── config.json +│ │ | ├── more files +``` + +### Invalid package example + +``` +├── Pikachu +| ├── config.json +| ├── more files +``` + +#### Correct + +``` +├── Pikachu +│ ├── characters +│ │ ├── Pikachu +│ │ | ├── config.json +│ │ | ├── more files +``` + +also correct + +``` +├── characters +│ ├── Pikachu +│ | ├── config.json +│ | ├── more files +``` \ No newline at end of file From 82775830d188326a55cbd66b1a4022d1d07d2af2 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 14 Nov 2024 20:08:43 +0100 Subject: [PATCH 2/3] remove now obsolete portion on getting default mods back (you can't delete them anymore, ha!) --- docs/installMods.md | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/docs/installMods.md b/docs/installMods.md index 636670cb..43accfc5 100644 --- a/docs/installMods.md +++ b/docs/installMods.md @@ -98,22 +98,4 @@ Panel Attack uses a universal convention: Directories and files that start with two underscores (__) will be ignored. So all you need to do to disable a character or stage is to rename its folder. -You can also hide single replay, puzzle and training files by renaming them and adding __ in front. - -## How to get the default mods back - -You might have deleted the default mods that came with the game at some point and want to get them back. But how? -There are two possibilities: - 1. Download them again - 2. Let Panel Attack reinstall them - -### Download them again - -You can find all default assets of Panel Attack at https://github.com/panel-attack/panel-game/tree/beta/client/assets/ -You can download the Panel Attack source code including the default mods from there any time and reinstall them via the instructions in this document. - -### Let Panel Attack reinstall them - -Panel Attack cannot function properly if you have no panels, no character or no stage available. -For that reason it will always install the default characters again on start-up if no mods are available at all. -That means to get the default characters/stages/panels back, you can simply temporarily disable all your installed mods by renaming them and then start Panel Attack. \ No newline at end of file +You can also hide single replay, puzzle and training files by renaming them and adding __ in front. \ No newline at end of file From 05dc00c25347307b74befd91ac46e1b170038d7c Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 14 Nov 2024 20:28:27 +0100 Subject: [PATCH 3/3] add missing love.filedropped callback --- main.lua | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/main.lua b/main.lua index 4ccd172b..d6e1b027 100644 --- a/main.lua +++ b/main.lua @@ -349,4 +349,84 @@ end function love.joystickaxis(joystick, axisIndex, value) inputManager:joystickaxis(joystick, axisIndex, value) +end + +function love.filedropped(file) + if GAME.match == nil then + local mountPath = "dropped" + love.filesystem.mount(file:getFilename(), mountPath) + -- if a file is a directory, that means it's a zip archive + if love.filesystem.getInfo(mountPath, "directory") then + local path = mountPath + local subDirectories = fileUtils.getFilteredDirectoryItems(path, "directory") + -- the mod folders need to be either directly at the top level or one below + if #subDirectories == 1 then + -- verify it's not a single asset type drop + if subDirectories[1] ~= "characters" and subDirectories[1] ~= "panels" and subDirectories[1] ~= "stages" + and subDirectories[1] ~= "themes" then + path = "dropped/" .. subDirectories[1] + subDirectories = fileUtils.getFilteredDirectoryItems(path, "directory") + end + end + if tableUtils.contains(subDirectories, "characters") then + local characterDirs = fileUtils.getFilteredDirectoryItems(path .. "/characters", "directory") + for i = 1, #characterDirs do + if ModImport.importCharacter(path .. "/characters/" .. characterDirs[i]) then + logger.info("imported character " .. characterDirs[i]) + else + logger.warn("failed to import character " .. characterDirs[i]) + end + end + + CharacterLoader.initCharacters() + end + + if tableUtils.contains(subDirectories, "stages") then + local stageDirs = fileUtils.getFilteredDirectoryItems(path .."/stages", "directory") + for i = 1, #stageDirs do + if ModImport.importStage(path .. "/stages/" .. stageDirs[i]) then + logger.info("imported stage " .. stageDirs[i]) + else + logger.warn("failed to import stage " .. stageDirs[i]) + end + end + + StageLoader.initStages() + end + + if tableUtils.contains(subDirectories, "panels") then + local panelDirs = fileUtils.getFilteredDirectoryItems(path .. "/panels", "directory") + for i = 1, #panelDirs do + if ModImport.importPanelSet(path .. "/panels/" .. panelDirs[i]) then + logger.info("imported panels " .. panelDirs[i]) + else + logger.warn("failed to import panels " .. panelDirs[i]) + end + end + + panels_init() + end + + if tableUtils.contains(subDirectories, "themes") then + local themeDirs = fileUtils.getFilteredDirectoryItems(path .. "/themes", "directory") + for i = 1, #themeDirs do + if ModImport.importTheme(path .. "/themes/" .. themeDirs[i]) then + logger.info("imported themes " .. themeDirs[i]) + else + logger.warn("failed to import themes " .. themeDirs[i]) + end + end + + -- themes never get initialized until selected + end + + -- TODO + -- puzzles + -- training files + -- challengemode files + -- replays + end + + love.filesystem.unmount(file:getFilename()) + end end \ No newline at end of file