squad-proto/engine/sfa_library.h
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

161 lines
9.1 KiB
C++
Raw 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.

#pragma once
// -----------------------------------------------------------------------------
// Чтение библиотеки спрайтов, собранной SpriteForge (`sf build`).
//
// Игра не знает про YAML, генераторы и PNG. Она видит только каталог с файлами
// `<asset_id>.sfa` и общей палитрой `palettes.sfp`, а обращается к ассетам по
// числовым идентификаторам из generated/spriteforge_assets.h. Строковых ID
// ассетов в коде игры быть не должно.
//
// Слой ровно один: .sfa (индексы палитры + RLE по строкам) -> игровой Sprite
// (RGBA8 с альфа-тестом). Дальше по коду разницы между процедурным спрайтом
// и собранным нет.
//
// Распаковка идёт целиком на Open() и только там. Ленивое декодирование стоило
// бы RLE-прохода и аллокации холста на КАЖДУЮ анимированную сущность в КАЖДОМ
// кадре игры; готовые Sprite лежат в памяти, наружу отдаются ссылки на них.
// -----------------------------------------------------------------------------
#include <array>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#include "engine/framebuffer.h"
#include "spriteforge_assets.h" // sf_asset_id, SF_ASSET_*, SF_ANIM_*
// Куда положить кадр .sfa на холст игрового спрайта.
//
// SpriteForge обрезает кадр по силуэту, поэтому размер кадра плывёт вместе с
// содержимым. Тайлам это не подходит: ShadeQuad считается в координатах
// спрайта и предполагает канонический холст с ромбом в известном месте
// (Lighting::QuadFor). Placement кладёт кадр на такой холст: pivot из .sfa
// совмещается с (pivotX, pivotY).
struct FramePlacement
{
int canvasW = 0; // 0 — холст по размеру кадра, без дополнения
int canvasH = 0;
int pivotX = 0; // куда на холсте попадает pivot кадра
int pivotY = 0;
int anchorX = -1; // Sprite::ax; -1 — привязка в точке pivot
int anchorY = -1; // Sprite::ay
};
// Один клип внутри ассета. Имя приходит только хешем (SF_ANIM_*): строковых
// имён анимаций в игре, как и строковых ID ассетов, быть не должно.
struct AnimClip
{
uint32_t nameHash = 0;
int firstFrame = 0; // индекс первого кадра клипа в плоском списке кадров
int framesPerDir = 0; // кадров на одно направление
};
class SpriteLibrary
{
public:
// Открыть каталог сборки (обычно build/sprites). Файлы читаются целиком
// и распаковываются сразу: битый ассет просто не попадает в набор.
bool Open(const std::string& dir);
void Close();
bool IsOpen() const { return open_; }
const std::string& Dir() const { return dir_; }
int AssetCount() const { return int(assets_.size()); }
// Все кадры ассета скопом, по всем анимациям и направлениям.
int FrameCount(uint32_t assetId) const;
// Кадр как игровой Sprite. false — нет такого ассета или кадра; out в этом
// случае не трогается. Копия: вызывающий волен её пережить и подшаманить
// (так грузятся тайлы, им нужен канонический холст).
bool Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const;
bool Read(uint32_t assetId, int frame, Sprite& out) const
{
return Read(assetId, frame, FramePlacement{}, out);
}
// --- анимации ---
//
// Раскладка кадров в .sfa НАПРАВЛЕНЧЕСКИ-МАЖОРНАЯ, клипы идут подряд:
// index = clip.firstFrame + dirIndex * clip.framesPerDir + frameInClip
// Поэтому firstFrame анимации — это не ноль, а конец предыдущей.
int AnimationCount(uint32_t assetId) const;
// Клип по хешу имени (SF_ANIM_* из generated/spriteforge_assets.h).
bool FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const;
// Сколько направлений отрисовано у ассета; 1 — вид один на все стороны.
int DirectionCount(uint32_t assetId) const;
// Индекс направления, ближайшего к мировому углу (радианы, как Agent::facing).
// Нет ассета — 0: рисовать всё равно нечего, а ветвиться у вызывающего лишнее.
int DirectionOf(uint32_t assetId, float angleRad) const;
// Кадр анимации: timeSec — время от её начала, loop — зациклить, иначе клип
// замирает на последнем кадре (смерть так и остаётся лежать).
// nullptr — нет такого ассета или анимации.
//
// ВНИМАНИЕ: указатель живёт до ближайшего PollReload(), вернувшего true.
// После пересборки библиотеки все выданные раньше кадры недействительны,
// держать их между кадрами игры нельзя.
const Sprite* Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex,
float timeSec, bool loop) const;
// Длительность клипа целиком, сек. 0 — нет такой анимации либо все кадры
// нулевой длительности (статичная поза).
float AnimationLength(uint32_t assetId, uint32_t animNameHash) const;
// Watch-режим `sf watch` атомарно переписывает reload.json после успешной
// пересборки. Вызов раз в кадр; true — библиотека перечитана и спрайты,
// взятые из неё раньше, устарели. Смена набора анимаций переваривается
// сама собой: пересобирается вся библиотека целиком, а не отдельные кадры.
bool PollReload();
// Имя ассета (оно же имя файла) — только для отладочного отчёта.
const char* NameOf(uint32_t assetId) const;
// Все идентификаторы, отсортированные по имени. Тоже только для отчёта.
std::vector<uint32_t> Ids() const;
private:
struct Asset
{
std::string name;
std::vector<Sprite> frames; // уже RGBA8; индекс — как в .sfa
std::vector<uint16_t> durationsMs; // длительность кадра, параллельно frames
std::vector<AnimClip> clips;
std::vector<int> dirs; // сотые доли градуса, приведённые к [0, 36000)
};
// Отпечаток reload.json: время правки и размер. 0 — файла нет.
struct Stamp
{
int64_t time = 0;
uint64_t size = 0;
bool operator!=(const Stamp& o) const { return time != o.time || size != o.size; }
};
// Разобрать .sfa целиком: все кадры в Sprite, клипы, направления.
// false — файл битый или его раскладка не сходится сама с собой.
bool DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const;
const Asset* FindAsset(uint32_t assetId) const;
static const AnimClip* FindClip(const Asset& asset, uint32_t animNameHash);
static float ClipLength(const Asset& asset, const AnimClip& clip);
static int FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop);
Stamp ReloadStamp() const;
std::unordered_map<uint32_t, Asset> assets_;
std::array<uint8_t, 768> palette_{}; // 256 RGB-троек, индекс 0 прозрачен
std::string dir_;
Stamp stamp_;
bool open_ = false;
};
// Первый каталог сборки, в котором лежит palettes.sfp: рядом с бинарником
// (build/bin -> ../sprites) или относительно корня репозитория. Пустая строка —
// библиотека не собрана.
std::string FindSpriteLibraryDir();