582 lines
13 KiB
C++
582 lines
13 KiB
C++
#include <SDL.h>
|
|
#include <SDL_image.h>
|
|
#include <algorithm>
|
|
#include <cerrno>
|
|
#include <cctype>
|
|
#include <climits>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <vector>
|
|
#include "Image.h"
|
|
#include "SpriteAssetLoader.h"
|
|
|
|
#if defined(_WIN32)
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#define NOMINMAX
|
|
#include <windows.h>
|
|
#else
|
|
#include <dirent.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#endif
|
|
|
|
namespace
|
|
{
|
|
enum class TransformMode
|
|
{
|
|
None,
|
|
Resize,
|
|
Fit,
|
|
Tom800x480Preset
|
|
};
|
|
|
|
struct TransformOptions
|
|
{
|
|
TransformMode mode;
|
|
int32_t width;
|
|
int32_t height;
|
|
|
|
TransformOptions() : mode(TransformMode::None), width(0), height(0) {}
|
|
};
|
|
|
|
static bool IsPathSeparator(char value)
|
|
{
|
|
return value == '/' || value == '\\';
|
|
}
|
|
|
|
static std::string ToLower(std::string text)
|
|
{
|
|
std::transform(text.begin(), text.end(), text.begin(), [](char value) {
|
|
return static_cast<char>(std::tolower(static_cast<unsigned char>(value)));
|
|
});
|
|
return text;
|
|
}
|
|
|
|
static std::string JoinPath(const std::string& directory, const std::string& fileName)
|
|
{
|
|
if (directory.empty())
|
|
{
|
|
return fileName;
|
|
}
|
|
|
|
if (IsPathSeparator(directory[directory.size() - 1]))
|
|
{
|
|
return directory + fileName;
|
|
}
|
|
|
|
return directory + "/" + fileName;
|
|
}
|
|
|
|
static std::string GetFileName(const std::string& path)
|
|
{
|
|
const size_t slash = path.find_last_of("/\\");
|
|
if (slash == std::string::npos)
|
|
{
|
|
return path;
|
|
}
|
|
|
|
return path.substr(slash + 1);
|
|
}
|
|
|
|
static std::string GetParentPath(const std::string& path)
|
|
{
|
|
const size_t slash = path.find_last_of("/\\");
|
|
if (slash == std::string::npos)
|
|
{
|
|
return std::string();
|
|
}
|
|
if (slash == 0)
|
|
{
|
|
return path.substr(0, 1);
|
|
}
|
|
|
|
return path.substr(0, slash);
|
|
}
|
|
|
|
static std::string ChangeExtensionToSprite(const std::string& fileName)
|
|
{
|
|
const size_t slash = fileName.find_last_of("/\\");
|
|
const size_t dot = fileName.find_last_of('.');
|
|
if (dot != std::string::npos && (slash == std::string::npos || dot > slash))
|
|
{
|
|
return fileName.substr(0, dot) + Asset::SpriteAssetLoader::GetFileExtension();
|
|
}
|
|
|
|
return fileName + Asset::SpriteAssetLoader::GetFileExtension();
|
|
}
|
|
|
|
static bool HasPngExtension(const std::string& path)
|
|
{
|
|
const std::string lower = ToLower(path);
|
|
return lower.size() >= 4 && lower.substr(lower.size() - 4) == ".png";
|
|
}
|
|
|
|
static bool ParsePositiveInt(const char* text, int32_t& value)
|
|
{
|
|
if (text == nullptr || text[0] == '\0')
|
|
{
|
|
return false;
|
|
}
|
|
|
|
errno = 0;
|
|
char* end = nullptr;
|
|
const long parsed = std::strtol(text, &end, 10);
|
|
if (errno != 0 || end == text || *end != '\0' || parsed <= 0 || parsed > INT_MAX)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
value = static_cast<int32_t>(parsed);
|
|
return true;
|
|
}
|
|
|
|
static bool DirectoryExists(const std::string& path)
|
|
{
|
|
#if defined(_WIN32)
|
|
const DWORD attrs = GetFileAttributesA(path.c_str());
|
|
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
|
#else
|
|
struct stat info;
|
|
return stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode);
|
|
#endif
|
|
}
|
|
|
|
static bool CreateSingleDirectory(const std::string& path)
|
|
{
|
|
if (path.empty() || DirectoryExists(path))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
#if defined(_WIN32)
|
|
if (CreateDirectoryA(path.c_str(), nullptr) != 0)
|
|
{
|
|
return true;
|
|
}
|
|
return GetLastError() == ERROR_ALREADY_EXISTS && DirectoryExists(path);
|
|
#else
|
|
if (mkdir(path.c_str(), 0755) == 0)
|
|
{
|
|
return true;
|
|
}
|
|
return errno == EEXIST && DirectoryExists(path);
|
|
#endif
|
|
}
|
|
|
|
static bool EnsureDirectoryTree(const std::string& path)
|
|
{
|
|
if (path.empty())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
std::string normalized = path;
|
|
while (!normalized.empty() && IsPathSeparator(normalized[normalized.size() - 1]))
|
|
{
|
|
normalized.erase(normalized.size() - 1);
|
|
}
|
|
|
|
if (normalized.empty())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
size_t start = 0;
|
|
if (normalized.size() >= 2 && normalized[1] == ':')
|
|
{
|
|
start = 2;
|
|
}
|
|
if (start < normalized.size() && IsPathSeparator(normalized[start]))
|
|
{
|
|
++start;
|
|
}
|
|
|
|
for (size_t i = start; i <= normalized.size(); ++i)
|
|
{
|
|
if (i != normalized.size() && !IsPathSeparator(normalized[i]))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const std::string part = normalized.substr(0, i);
|
|
if (part.empty() || (part.size() == 2 && part[1] == ':'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!CreateSingleDirectory(part))
|
|
{
|
|
std::cerr << "Create directory failed: " << part << std::endl;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return DirectoryExists(normalized);
|
|
}
|
|
|
|
static bool EnsureParentDirectory(const std::string& path)
|
|
{
|
|
return EnsureDirectoryTree(GetParentPath(path));
|
|
}
|
|
|
|
static bool ListPngFiles(const std::string& directory, std::vector<std::string>& fileNames)
|
|
{
|
|
fileNames.clear();
|
|
|
|
#if defined(_WIN32)
|
|
const std::string searchPath = JoinPath(directory, "*");
|
|
WIN32_FIND_DATAA data;
|
|
HANDLE handle = FindFirstFileA(searchPath.c_str(), &data);
|
|
if (handle == INVALID_HANDLE_VALUE)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
do
|
|
{
|
|
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 &&
|
|
HasPngExtension(data.cFileName))
|
|
{
|
|
fileNames.push_back(data.cFileName);
|
|
}
|
|
}
|
|
while (FindNextFileA(handle, &data) != 0);
|
|
|
|
FindClose(handle);
|
|
#else
|
|
DIR* dir = opendir(directory.c_str());
|
|
if (dir == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
dirent* entry = nullptr;
|
|
while ((entry = readdir(dir)) != nullptr)
|
|
{
|
|
const std::string name = entry->d_name;
|
|
if (name == "." || name == ".." || !HasPngExtension(name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
fileNames.push_back(name);
|
|
}
|
|
|
|
closedir(dir);
|
|
#endif
|
|
|
|
std::sort(fileNames.begin(), fileNames.end(), [](const std::string& left, const std::string& right) {
|
|
return ToLower(left) < ToLower(right);
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
static bool LoadPngImage(const std::string& path, RenderData::Image& image)
|
|
{
|
|
SDL_Surface* loadedSurface = IMG_Load(path.c_str());
|
|
if (loadedSurface == nullptr)
|
|
{
|
|
std::cerr << "IMG_Load failed: " << path << " : " << IMG_GetError() << std::endl;
|
|
return false;
|
|
}
|
|
|
|
SDL_Surface* rgbaSurface = SDL_ConvertSurfaceFormat(loadedSurface, SDL_PIXELFORMAT_RGBA8888, 0);
|
|
SDL_FreeSurface(loadedSurface);
|
|
if (rgbaSurface == nullptr)
|
|
{
|
|
std::cerr << "SDL_ConvertSurfaceFormat failed: " << SDL_GetError() << std::endl;
|
|
return false;
|
|
}
|
|
|
|
std::vector<uint32_t> pixels(static_cast<size_t>(rgbaSurface->w) * rgbaSurface->h, 0);
|
|
if (SDL_LockSurface(rgbaSurface) != 0)
|
|
{
|
|
std::cerr << "SDL_LockSurface failed: " << SDL_GetError() << std::endl;
|
|
SDL_FreeSurface(rgbaSurface);
|
|
return false;
|
|
}
|
|
|
|
for (int32_t y = 0; y < rgbaSurface->h; ++y)
|
|
{
|
|
const uint8_t* srcRow = static_cast<const uint8_t*>(rgbaSurface->pixels) + y * rgbaSurface->pitch;
|
|
const uint32_t* srcPixels = reinterpret_cast<const uint32_t*>(srcRow);
|
|
for (int32_t x = 0; x < rgbaSurface->w; ++x)
|
|
{
|
|
pixels[static_cast<size_t>(y) * rgbaSurface->w + x] = srcPixels[x];
|
|
}
|
|
}
|
|
SDL_UnlockSurface(rgbaSurface);
|
|
|
|
image = RenderData::Image(rgbaSurface->w, rgbaSurface->h, pixels);
|
|
SDL_FreeSurface(rgbaSurface);
|
|
return image.is_valid();
|
|
}
|
|
|
|
static RenderData::Image ResizeImageNearest(const RenderData::Image& source, int32_t width, int32_t height)
|
|
{
|
|
if (!source.is_valid() || width <= 0 || height <= 0)
|
|
{
|
|
return RenderData::Image();
|
|
}
|
|
|
|
RenderData::Image result(width, height);
|
|
for (int32_t y = 0; y < height; ++y)
|
|
{
|
|
const int32_t sourceY = y * source.get_height() / height;
|
|
for (int32_t x = 0; x < width; ++x)
|
|
{
|
|
const int32_t sourceX = x * source.get_width() / width;
|
|
result.set_pixel(x, y, source.get_pixel_fast(sourceX, sourceY));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static RenderData::Image ResizeImageToFit(const RenderData::Image& source, int32_t maxWidth, int32_t maxHeight)
|
|
{
|
|
if (!source.is_valid() || maxWidth <= 0 || maxHeight <= 0)
|
|
{
|
|
return RenderData::Image();
|
|
}
|
|
|
|
const float scaleX = static_cast<float>(maxWidth) / source.get_width();
|
|
const float scaleY = static_cast<float>(maxHeight) / source.get_height();
|
|
const float scale = std::min(scaleX, scaleY);
|
|
|
|
const int32_t width = std::max(1, static_cast<int32_t>(source.get_width() * scale));
|
|
const int32_t height = std::max(1, static_cast<int32_t>(source.get_height() * scale));
|
|
return ResizeImageNearest(source, width, height);
|
|
}
|
|
|
|
static TransformOptions ResolveTransformForInput(
|
|
const std::string& inputPath,
|
|
const TransformOptions& requested)
|
|
{
|
|
if (requested.mode != TransformMode::Tom800x480Preset)
|
|
{
|
|
return requested;
|
|
}
|
|
|
|
TransformOptions resolved;
|
|
const std::string fileName = ToLower(GetFileName(inputPath));
|
|
if (fileName == "background.png")
|
|
{
|
|
resolved.mode = TransformMode::Resize;
|
|
resolved.width = 800;
|
|
resolved.height = 480;
|
|
}
|
|
else if (fileName.find("tom-") == 0)
|
|
{
|
|
resolved.mode = TransformMode::Fit;
|
|
resolved.width = 560;
|
|
resolved.height = 360;
|
|
}
|
|
else if (fileName.find("ui-") == 0)
|
|
{
|
|
resolved.mode = TransformMode::Fit;
|
|
resolved.width = 72;
|
|
resolved.height = 72;
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
static bool ApplyTransform(const std::string& inputPath, const TransformOptions& options, RenderData::Image& image)
|
|
{
|
|
const TransformOptions resolved = ResolveTransformForInput(inputPath, options);
|
|
switch (resolved.mode)
|
|
{
|
|
case TransformMode::None:
|
|
return image.is_valid();
|
|
|
|
case TransformMode::Resize:
|
|
image = ResizeImageNearest(image, resolved.width, resolved.height);
|
|
return image.is_valid();
|
|
|
|
case TransformMode::Fit:
|
|
image = ResizeImageToFit(image, resolved.width, resolved.height);
|
|
return image.is_valid();
|
|
|
|
case TransformMode::Tom800x480Preset:
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static bool ConvertFile(
|
|
const std::string& inputPath,
|
|
const std::string& outputPath,
|
|
const TransformOptions& transformOptions)
|
|
{
|
|
RenderData::Image image;
|
|
if (!LoadPngImage(inputPath, image))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!ApplyTransform(inputPath, transformOptions, image))
|
|
{
|
|
std::cerr << "Image transform failed: " << inputPath << std::endl;
|
|
return false;
|
|
}
|
|
|
|
if (!EnsureParentDirectory(outputPath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!Asset::SpriteAssetLoader::Save(outputPath, image))
|
|
{
|
|
std::cerr << "Save failed: " << outputPath << std::endl;
|
|
return false;
|
|
}
|
|
|
|
std::cout << inputPath << " -> " << outputPath
|
|
<< " (" << image.get_width() << "x" << image.get_height() << ")" << std::endl;
|
|
return true;
|
|
}
|
|
|
|
static bool ConvertBatch(
|
|
const std::string& inputDirectory,
|
|
const std::string& outputDirectory,
|
|
const TransformOptions& transformOptions)
|
|
{
|
|
std::vector<std::string> files;
|
|
if (!ListPngFiles(inputDirectory, files) || files.empty())
|
|
{
|
|
std::cerr << "No PNG files found: " << inputDirectory << std::endl;
|
|
return false;
|
|
}
|
|
|
|
if (!EnsureDirectoryTree(outputDirectory))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
size_t successCount = 0;
|
|
for (size_t i = 0; i < files.size(); ++i)
|
|
{
|
|
const std::string inputPath = JoinPath(inputDirectory, files[i]);
|
|
const std::string outputPath = JoinPath(outputDirectory, ChangeExtensionToSprite(files[i]));
|
|
if (ConvertFile(inputPath, outputPath, transformOptions))
|
|
{
|
|
++successCount;
|
|
}
|
|
}
|
|
|
|
std::cout << "Converted " << successCount << " / " << files.size() << " PNG files." << std::endl;
|
|
return successCount == files.size();
|
|
}
|
|
|
|
static bool ParseTransformOptions(int argc, char* argv[], int startIndex, TransformOptions& options)
|
|
{
|
|
if (startIndex >= argc)
|
|
{
|
|
options = TransformOptions();
|
|
return true;
|
|
}
|
|
|
|
const std::string mode = argv[startIndex];
|
|
if (mode == "--resize" || mode == "--fit")
|
|
{
|
|
if (startIndex + 2 >= argc)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int32_t width = 0;
|
|
int32_t height = 0;
|
|
if (!ParsePositiveInt(argv[startIndex + 1], width) ||
|
|
!ParsePositiveInt(argv[startIndex + 2], height) ||
|
|
startIndex + 3 != argc)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
options.mode = mode == "--resize" ? TransformMode::Resize : TransformMode::Fit;
|
|
options.width = width;
|
|
options.height = height;
|
|
return true;
|
|
}
|
|
|
|
if (mode == "--preset")
|
|
{
|
|
if (startIndex + 2 != argc)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const std::string presetName = argv[startIndex + 1];
|
|
if (presetName != "tom-800x480")
|
|
{
|
|
std::cerr << "Unknown preset: " << presetName << std::endl;
|
|
return false;
|
|
}
|
|
|
|
options.mode = TransformMode::Tom800x480Preset;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static void PrintUsage()
|
|
{
|
|
std::cout << "Usage:" << std::endl;
|
|
std::cout << " SpriteAssetTool input.png output.sprite [--resize width height]" << std::endl;
|
|
std::cout << " SpriteAssetTool input.png output.sprite [--fit maxWidth maxHeight]" << std::endl;
|
|
std::cout << " SpriteAssetTool --batch inputDir outputDir [--resize width height]" << std::endl;
|
|
std::cout << " SpriteAssetTool --batch inputDir outputDir [--fit maxWidth maxHeight]" << std::endl;
|
|
std::cout << " SpriteAssetTool --batch game/assets/raw game/assets/sprites --preset tom-800x480" << std::endl;
|
|
}
|
|
|
|
int main(int argc, char* argv[])
|
|
{
|
|
if (argc < 3)
|
|
{
|
|
PrintUsage();
|
|
return 1;
|
|
}
|
|
|
|
const int imageFlags = IMG_INIT_PNG;
|
|
if ((IMG_Init(imageFlags) & imageFlags) != imageFlags)
|
|
{
|
|
std::cerr << "IMG_Init failed: " << IMG_GetError() << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
TransformOptions transformOptions;
|
|
bool ok = false;
|
|
const std::string command = argv[1];
|
|
if (command == "--batch")
|
|
{
|
|
if (argc < 4 || !ParseTransformOptions(argc, argv, 4, transformOptions))
|
|
{
|
|
PrintUsage();
|
|
IMG_Quit();
|
|
return 1;
|
|
}
|
|
|
|
ok = ConvertBatch(argv[2], argv[3], transformOptions);
|
|
}
|
|
else
|
|
{
|
|
if (!ParseTransformOptions(argc, argv, 3, transformOptions))
|
|
{
|
|
PrintUsage();
|
|
IMG_Quit();
|
|
return 1;
|
|
}
|
|
|
|
ok = ConvertFile(argv[1], argv[2], transformOptions);
|
|
}
|
|
|
|
IMG_Quit();
|
|
return ok ? 0 : 1;
|
|
}
|