Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions client/src/FileUtils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
199 changes: 199 additions & 0 deletions client/src/mods/ModImport.lua
Original file line number Diff line number Diff line change
@@ -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
54 changes: 22 additions & 32 deletions docs/installMods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,33 +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/
~/.local/share/

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.
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
#### Android

Navigate to
/Android/data/com.panelattack.android/files/save/

The exact path for Android may differ, check in Options -> About -> System Info for the exact path.

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.
Expand All @@ -69,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.
Expand All @@ -90,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.
You can also hide single replay, puzzle and training files by renaming them and adding __ in front.
Loading