squad-proto/engine/sfa_library.cpp

419 lines
16 KiB
C++
Raw Normal View History

Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
#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 {};
}