419 lines
16 KiB
C++
419 lines
16 KiB
C++
|
|
#include "engine/sfa_library.h"
|
|||
|
|
|
|||
|
|
#include <algorithm>
|
|||
|
|
#include <cmath>
|
|||
|
|
#include <cstdlib>
|
|||
|
|
#include <cstring>
|
|||
|
|
#include <filesystem>
|
|||
|
|
#include <fstream>
|
|||
|
|
|
|||
|
|
// sfa.h — чужой заголовок из репозитория SpriteForge, единственный экземпляр
|
|||
|
|
// на всю игру лежит здесь. Он держит весь код в статических функциях, поэтому
|
|||
|
|
// без глушилки предупреждений /W4 и -Wall тонут в шуме про неиспользованное.
|
|||
|
|
#if defined(_MSC_VER)
|
|||
|
|
# pragma warning(push, 0)
|
|||
|
|
#elif defined(__GNUC__)
|
|||
|
|
# pragma GCC diagnostic push
|
|||
|
|
# pragma GCC diagnostic ignored "-Wunused-function"
|
|||
|
|
#endif
|
|||
|
|
#include "sfa.h"
|
|||
|
|
#if defined(_MSC_VER)
|
|||
|
|
# pragma warning(pop)
|
|||
|
|
# pragma warning(disable : 4505) // C4505 приходит в конце файла, а не в блоке выше
|
|||
|
|
#elif defined(__GNUC__)
|
|||
|
|
# pragma GCC diagnostic pop
|
|||
|
|
#endif
|
|||
|
|
|
|||
|
|
namespace fs = std::filesystem;
|
|||
|
|
|
|||
|
|
namespace
|
|||
|
|
{
|
|||
|
|
|
|||
|
|
// Полный оборот в сотых долях градуса: .sfa хранит направления именно так.
|
|||
|
|
constexpr int FULL_TURN_CD = 36000;
|
|||
|
|
constexpr double RAD_TO_CD = 18000.0 / 3.14159265358979323846;
|
|||
|
|
constexpr float MS_TO_SEC = 0.001f;
|
|||
|
|
|
|||
|
|
// Тот же хеш, которым `sf codegen` считает SF_ASSET_*: FNV-1a, 32 бита, по
|
|||
|
|
// байтам UTF-8. Совпадение обязано быть точным, иначе ассет не найдётся.
|
|||
|
|
uint32_t Fnv1a(const std::string& s)
|
|||
|
|
{
|
|||
|
|
uint32_t h = 2166136261u;
|
|||
|
|
for (const char c : s) h = (h ^ uint8_t(c)) * 16777619u;
|
|||
|
|
return h;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool ReadWhole(const fs::path& path, std::vector<uint8_t>& out)
|
|||
|
|
{
|
|||
|
|
std::error_code ec;
|
|||
|
|
const auto size = fs::file_size(path, ec);
|
|||
|
|
if (ec) return false;
|
|||
|
|
|
|||
|
|
std::ifstream in(path, std::ios::binary);
|
|||
|
|
if (!in) return false;
|
|||
|
|
|
|||
|
|
out.resize(size_t(size));
|
|||
|
|
if (out.empty()) return false;
|
|||
|
|
in.read(reinterpret_cast<char*>(out.data()), std::streamsize(out.size()));
|
|||
|
|
return bool(in);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Кадр .sfa -> Sprite на холсте по размеру кадра, привязка ровно в pivot.
|
|||
|
|
// Дальше игра работает только с этим спрайтом: RLE распаковывается один раз
|
|||
|
|
// за загрузку библиотеки.
|
|||
|
|
bool DecodeFrame(const sfa_view& view, const sfa_frame& frame, const uint8_t* palette, Sprite& out)
|
|||
|
|
{
|
|||
|
|
Sprite sprite;
|
|||
|
|
sprite.Init(int(frame.width), int(frame.height), 0); // прозрачный фон
|
|||
|
|
sprite.ax = int(frame.pivot_x);
|
|||
|
|
sprite.ay = int(frame.pivot_y);
|
|||
|
|
if (sprite.px.empty()) return false;
|
|||
|
|
|
|||
|
|
// SFA_BLEND_OPAQUE ставит альфу 255 ровно на записанных пикселях, а порядок
|
|||
|
|
// каналов в sfa_mix (R в младшем байте) совпадает с RGBA() фреймбуфера.
|
|||
|
|
sfa_surface surface{sprite.px.data(), sprite.w, sprite.h, sprite.w};
|
|||
|
|
if (sfa_blit(&view, &frame, palette, nullptr, surface, sprite.ax, sprite.ay,
|
|||
|
|
SFA_BLEND_OPAQUE) != SFA_OK)
|
|||
|
|
return false;
|
|||
|
|
|
|||
|
|
out = std::move(sprite);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} // namespace
|
|||
|
|
|
|||
|
|
bool SpriteLibrary::Open(const std::string& dir)
|
|||
|
|
{
|
|||
|
|
// Копия до Close(): аргументом легко приезжает Dir() этой же библиотеки
|
|||
|
|
// (`lib.Open(lib.Dir())`), а Close() эту строку обнуляет — дальше каталог
|
|||
|
|
// читался бы по висячей ссылке.
|
|||
|
|
const std::string path = dir;
|
|||
|
|
Close();
|
|||
|
|
|
|||
|
|
const fs::path root(path);
|
|||
|
|
|
|||
|
|
// Палитра общая на всю библиотеку: индексы в кадрах без неё бессмысленны,
|
|||
|
|
// и читать её надо до ассетов — кадры распаковываются прямо здесь.
|
|||
|
|
std::vector<uint8_t> pack;
|
|||
|
|
if (!ReadWhole(root / "palettes.sfp", pack)) return false;
|
|||
|
|
|
|||
|
|
sfp_view pv{};
|
|||
|
|
const uint8_t* rgb = nullptr;
|
|||
|
|
if (sfp_open(pack.data(), pack.size(), &pv) != SFA_OK) return false;
|
|||
|
|
if (sfp_palette(&pv, 0, &rgb) != SFA_OK) return false;
|
|||
|
|
std::memcpy(palette_.data(), rgb, palette_.size());
|
|||
|
|
|
|||
|
|
// Имя файла — это и есть строковый ID ассета. Больше строковых ID в игре
|
|||
|
|
// нет нигде: дальше работают только числа из generated/.
|
|||
|
|
std::error_code ec;
|
|||
|
|
for (const fs::directory_entry& entry : fs::directory_iterator(root, ec))
|
|||
|
|
{
|
|||
|
|
if (!entry.is_regular_file(ec)) continue;
|
|||
|
|
if (entry.path().extension() != ".sfa") continue;
|
|||
|
|
|
|||
|
|
std::vector<uint8_t> bytes;
|
|||
|
|
if (!ReadWhole(entry.path(), bytes)) continue;
|
|||
|
|
|
|||
|
|
Asset asset;
|
|||
|
|
asset.name = entry.path().stem().string();
|
|||
|
|
if (!DecodeAsset(bytes.data(), bytes.size(), asset)) continue;
|
|||
|
|
|
|||
|
|
assets_.emplace(Fnv1a(asset.name), std::move(asset));
|
|||
|
|
}
|
|||
|
|
if (ec) { Close(); return false; }
|
|||
|
|
|
|||
|
|
dir_ = path;
|
|||
|
|
stamp_ = ReloadStamp();
|
|||
|
|
open_ = !assets_.empty();
|
|||
|
|
if (!open_) Close();
|
|||
|
|
return open_;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void SpriteLibrary::Close()
|
|||
|
|
{
|
|||
|
|
assets_.clear();
|
|||
|
|
palette_.fill(0);
|
|||
|
|
dir_.clear();
|
|||
|
|
stamp_ = Stamp{};
|
|||
|
|
open_ = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool SpriteLibrary::DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const
|
|||
|
|
{
|
|||
|
|
sfa_view view{};
|
|||
|
|
if (sfa_open(bytes, size, &view) != SFA_OK) return false;
|
|||
|
|
|
|||
|
|
out.frames.resize(size_t(view.frame_count));
|
|||
|
|
out.durationsMs.resize(size_t(view.frame_count));
|
|||
|
|
for (uint32_t i = 0; i < view.frame_count; ++i)
|
|||
|
|
{
|
|||
|
|
sfa_frame frame{};
|
|||
|
|
if (sfa_get_frame(&view, i, &frame) != SFA_OK) return false;
|
|||
|
|
if (!DecodeFrame(view, frame, palette_.data(), out.frames[i])) return false;
|
|||
|
|
out.durationsMs[i] = frame.duration_ms;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
out.clips.reserve(view.animation_count);
|
|||
|
|
for (uint32_t i = 0; i < view.animation_count; ++i)
|
|||
|
|
{
|
|||
|
|
sfa_animation anim{};
|
|||
|
|
if (sfa_get_animation(&view, uint16_t(i), &anim) != SFA_OK) return false;
|
|||
|
|
|
|||
|
|
// Диапазон клипа проверяется здесь и только здесь: если он сходится,
|
|||
|
|
// выборка кадра дальше не может выйти за плоский список.
|
|||
|
|
const uint64_t last = uint64_t(anim.first_frame) +
|
|||
|
|
uint64_t(anim.frames_per_direction) * view.direction_count;
|
|||
|
|
if (anim.frames_per_direction == 0 || last > view.frame_count) return false;
|
|||
|
|
|
|||
|
|
out.clips.push_back(AnimClip{anim.name_hash, int(anim.first_frame),
|
|||
|
|
int(anim.frames_per_direction)});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
out.dirs.reserve(view.direction_count);
|
|||
|
|
for (uint32_t i = 0; i < view.direction_count; ++i)
|
|||
|
|
{
|
|||
|
|
sfa_direction dir{};
|
|||
|
|
if (sfa_get_direction(&view, uint16_t(i), &dir) != SFA_OK) return false;
|
|||
|
|
// Угол знаковый: 315 градусов приезжают и как 31500, и как -4500.
|
|||
|
|
int cd = int(dir.centidegrees) % FULL_TURN_CD;
|
|||
|
|
if (cd < 0) cd += FULL_TURN_CD;
|
|||
|
|
out.dirs.push_back(cd);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return !out.frames.empty() && !out.clips.empty() && !out.dirs.empty();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const SpriteLibrary::Asset* SpriteLibrary::FindAsset(uint32_t assetId) const
|
|||
|
|
{
|
|||
|
|
const auto it = assets_.find(assetId);
|
|||
|
|
return (it == assets_.end()) ? nullptr : &it->second;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const AnimClip* SpriteLibrary::FindClip(const Asset& asset, uint32_t animNameHash)
|
|||
|
|
{
|
|||
|
|
// Клипов у ассета единицы, хеш-таблица тут дороже перебора.
|
|||
|
|
for (const AnimClip& clip : asset.clips)
|
|||
|
|
if (clip.nameHash == animNameHash) return &clip;
|
|||
|
|
return nullptr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
float SpriteLibrary::ClipLength(const Asset& asset, const AnimClip& clip)
|
|||
|
|
{
|
|||
|
|
// Тайминг берётся с направления 0: кадры у направлений одни и те же, а
|
|||
|
|
// разъехавшиеся длительности превратили бы поворот на месте в рывок.
|
|||
|
|
float total = 0.0f;
|
|||
|
|
for (int i = 0; i < clip.framesPerDir; ++i)
|
|||
|
|
total += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC;
|
|||
|
|
return total;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int SpriteLibrary::FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop)
|
|||
|
|
{
|
|||
|
|
const float length = ClipLength(asset, clip);
|
|||
|
|
if (length <= 0.0f) return 0; // все кадры нулевой длительности — статичная поза
|
|||
|
|
|
|||
|
|
// Отрицательное время и NaN трактуем как начало клипа: сущность в первом
|
|||
|
|
// кадре выглядит нормально, а пропуск отрисовки — нет.
|
|||
|
|
float t = (timeSec > 0.0f) ? timeSec : 0.0f;
|
|||
|
|
if (loop)
|
|||
|
|
{
|
|||
|
|
t = std::fmod(t, length);
|
|||
|
|
if (t < 0.0f) t = 0.0f;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
float acc = 0.0f;
|
|||
|
|
for (int i = 0; i < clip.framesPerDir; ++i)
|
|||
|
|
{
|
|||
|
|
acc += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC;
|
|||
|
|
if (t < acc) return i;
|
|||
|
|
}
|
|||
|
|
return clip.framesPerDir - 1; // незацикленный клип замирает на последнем кадре
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int SpriteLibrary::FrameCount(uint32_t assetId) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
return asset ? int(asset->frames.size()) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int SpriteLibrary::AnimationCount(uint32_t assetId) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
return asset ? int(asset->clips.size()) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool SpriteLibrary::FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
if (!asset) return false;
|
|||
|
|
|
|||
|
|
const AnimClip* clip = FindClip(*asset, animNameHash);
|
|||
|
|
if (!clip) return false;
|
|||
|
|
|
|||
|
|
out = *clip;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int SpriteLibrary::DirectionCount(uint32_t assetId) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
return asset ? int(asset->dirs.size()) : 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int SpriteLibrary::DirectionOf(uint32_t assetId, float angleRad) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
if (!asset || asset->dirs.size() < 2) return 0;
|
|||
|
|
|
|||
|
|
const double turn = double(angleRad) * RAD_TO_CD;
|
|||
|
|
if (!std::isfinite(turn)) return 0; // угол из мусора — не повод считать fmod
|
|||
|
|
int cd = int(std::fmod(turn, double(FULL_TURN_CD)));
|
|||
|
|
if (cd < 0) cd += FULL_TURN_CD;
|
|||
|
|
|
|||
|
|
// Набор направлений не обязан быть равномерным, поэтому не деление, а поиск
|
|||
|
|
// ближайшего по кругу: 350 градусов должны попасть в направление 0.
|
|||
|
|
int best = 0;
|
|||
|
|
int bestDist = FULL_TURN_CD;
|
|||
|
|
for (size_t i = 0; i < asset->dirs.size(); ++i)
|
|||
|
|
{
|
|||
|
|
int dist = std::abs(asset->dirs[i] - cd);
|
|||
|
|
if (dist > FULL_TURN_CD / 2) dist = FULL_TURN_CD - dist;
|
|||
|
|
if (dist < bestDist)
|
|||
|
|
{
|
|||
|
|
bestDist = dist;
|
|||
|
|
best = int(i);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Sprite* SpriteLibrary::Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex,
|
|||
|
|
float timeSec, bool loop) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
if (!asset) return nullptr;
|
|||
|
|
|
|||
|
|
const AnimClip* clip = FindClip(*asset, animNameHash);
|
|||
|
|
if (!clip) return nullptr;
|
|||
|
|
|
|||
|
|
// Направление вне набора — не повод не нарисовать сущность.
|
|||
|
|
const int dirs = int(asset->dirs.size());
|
|||
|
|
const int dir = (dirIndex < 0) ? 0 : ((dirIndex >= dirs) ? dirs - 1 : dirIndex);
|
|||
|
|
|
|||
|
|
// Раскладка направленчески-мажорная, границы клипа проверены на загрузке.
|
|||
|
|
const size_t index = size_t(clip->firstFrame) + size_t(dir) * size_t(clip->framesPerDir) +
|
|||
|
|
size_t(FrameInClip(*asset, *clip, timeSec, loop));
|
|||
|
|
if (index >= asset->frames.size()) return nullptr;
|
|||
|
|
return &asset->frames[index];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
float SpriteLibrary::AnimationLength(uint32_t assetId, uint32_t animNameHash) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
if (!asset) return 0.0f;
|
|||
|
|
|
|||
|
|
const AnimClip* clip = FindClip(*asset, animNameHash);
|
|||
|
|
return clip ? ClipLength(*asset, *clip) : 0.0f;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool SpriteLibrary::Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
if (!asset || frame < 0 || frame >= int(asset->frames.size())) return false;
|
|||
|
|
|
|||
|
|
const Sprite& src = asset->frames[size_t(frame)];
|
|||
|
|
|
|||
|
|
// Холст по размеру кадра — копия как есть, привязка уже стоит в pivot.
|
|||
|
|
const bool padded = place.canvasW > 0 && place.canvasH > 0;
|
|||
|
|
if (!padded)
|
|||
|
|
{
|
|||
|
|
out = src;
|
|||
|
|
if (place.anchorX >= 0) out.ax = place.anchorX;
|
|||
|
|
if (place.anchorY >= 0) out.ay = place.anchorY;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Sprite sprite;
|
|||
|
|
sprite.Init(place.canvasW, place.canvasH, 0);
|
|||
|
|
sprite.ax = (place.anchorX >= 0) ? place.anchorX : place.pivotX;
|
|||
|
|
sprite.ay = (place.anchorY >= 0) ? place.anchorY : place.pivotY;
|
|||
|
|
|
|||
|
|
// Тот же перенос, что делал бы sfa_blit с якорем (pivotX, pivotY): pivot
|
|||
|
|
// кадра совмещается с заданной точкой холста, прозрачное не пишется.
|
|||
|
|
const int x0 = place.pivotX - src.ax;
|
|||
|
|
const int y0 = place.pivotY - src.ay;
|
|||
|
|
for (int y = 0; y < src.h; ++y)
|
|||
|
|
for (int x = 0; x < src.w; ++x)
|
|||
|
|
{
|
|||
|
|
const uint32_t c = src.px[size_t(y) * size_t(src.w) + size_t(x)];
|
|||
|
|
if (AlphaOf(c) != 0) sprite.Set(x0 + x, y0 + y, c);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
out = std::move(sprite);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const char* SpriteLibrary::NameOf(uint32_t assetId) const
|
|||
|
|
{
|
|||
|
|
const Asset* asset = FindAsset(assetId);
|
|||
|
|
return asset ? asset->name.c_str() : "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
std::vector<uint32_t> SpriteLibrary::Ids() const
|
|||
|
|
{
|
|||
|
|
std::vector<uint32_t> ids;
|
|||
|
|
ids.reserve(assets_.size());
|
|||
|
|
for (const auto& pair : assets_) ids.push_back(pair.first);
|
|||
|
|
std::sort(ids.begin(), ids.end(), [this](uint32_t a, uint32_t b) {
|
|||
|
|
return assets_.at(a).name < assets_.at(b).name;
|
|||
|
|
});
|
|||
|
|
return ids;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SpriteLibrary::Stamp SpriteLibrary::ReloadStamp() const
|
|||
|
|
{
|
|||
|
|
Stamp stamp;
|
|||
|
|
if (dir_.empty()) return stamp;
|
|||
|
|
|
|||
|
|
std::error_code ec;
|
|||
|
|
const fs::path path = fs::path(dir_) / "reload.json";
|
|||
|
|
const auto time = fs::last_write_time(path, ec);
|
|||
|
|
if (ec) return stamp;
|
|||
|
|
stamp.time = time.time_since_epoch().count();
|
|||
|
|
stamp.size = uint64_t(fs::file_size(path, ec));
|
|||
|
|
if (ec) return Stamp{};
|
|||
|
|
return stamp;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool SpriteLibrary::PollReload()
|
|||
|
|
{
|
|||
|
|
if (!open_) return false;
|
|||
|
|
|
|||
|
|
const Stamp fresh = ReloadStamp();
|
|||
|
|
if (fresh.time == 0 || !(fresh != stamp_)) return false;
|
|||
|
|
|
|||
|
|
// `sf watch` обновляет reload.json последним, когда выходные файлы уже на
|
|||
|
|
// месте. Но если пересборка всё же долетела не целиком, старая библиотека
|
|||
|
|
// обязана остаться рабочей: собираем новую рядом и подменяем только целую.
|
|||
|
|
// Заодно это единственный честный способ пережить смену набора анимаций.
|
|||
|
|
SpriteLibrary next;
|
|||
|
|
if (!next.Open(dir_))
|
|||
|
|
{
|
|||
|
|
stamp_ = fresh; // иначе неудачную попытку будет видно каждый кадр
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
*this = std::move(next);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
std::string FindSpriteLibraryDir()
|
|||
|
|
{
|
|||
|
|
// Запуск из корня репозитория и запуск из build/bin — оба рабочие.
|
|||
|
|
static const char* const candidates[] = {"build/sprites", "sprites", "../sprites",
|
|||
|
|
"../../build/sprites"};
|
|||
|
|
std::error_code ec;
|
|||
|
|
for (const char* candidate : candidates)
|
|||
|
|
if (fs::exists(fs::path(candidate) / "palettes.sfp", ec)) return candidate;
|
|||
|
|
return {};
|
|||
|
|
}
|