#include "engine/sfa_library.h" #include #include #include #include #include #include // 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& 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(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 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 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 SpriteLibrary::Ids() const { std::vector 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 {}; }