squad-proto/engine/sfa_library.cpp
z.kirill 7d6266aaf3 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-13 21:07:19 +03:00

419 lines
16 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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 {};
}