squad-proto/src/ecs/ecs.h

232 lines
8.4 KiB
C
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#pragma once
// -----------------------------------------------------------------------------
// Минимальная ECS: сущность — это идентификатор, данные лежат в типизированных
// плотных пулах, системы — свободные функции над пулами.
//
// Зачем она здесь, если прототип и без неё работал:
// * оружие стало отдельной СУЩНОСТЬЮ со своим набором статов (пистолет,
// карабин, клинок). Боец ссылается на неё, а не носит числа в себе
// значит ствол можно подменить, не трогая ни бойца, ни правила боя;
// * мишени, пули и бойцы перестали быть тремя разными контейнерами в Game
// и стали тремя пулами одного мира. Добавить четвёртый вид сущности —
// это новый компонент, а не новое поле в Game и новый цикл в Step.
//
// Чего здесь НЕТ и почему:
// * архетипов и запросов вида View<A, B, C>. Сущностей в прототипе десятки,
// а не десятки тысяч; выигрыш от архетипов ушёл бы в сложность, а цена
// промаха кеша на 5 агентах равна нулю;
// * реакции на изменение компонентов, планировщика систем, рефлексии.
//
// Гранулярность компонентов сознательно КРУПНАЯ (Agent, Target, Bullet целиком,
// а не Position+Velocity+Body по отдельности). Причина в docs/12-ecs.md:
// правила боя из спека читают состояние агента целиком и в одном порядке,
// и дробление их только запутало бы, не дав ни строчки полезного обобщения.
//
// Детерминизм: удаление — swap-remove, порядок пулов зависит только от истории
// вызовов. Одинаковая последовательность операций даёт одинаковый мир, поэтому
// --headless по-прежнему воспроизводим.
// -----------------------------------------------------------------------------
#include <cstdint>
#include <memory>
#include <vector>
namespace ecs
{
// Идентификатор: младшие 24 бита — индекс слота, старшие 8 — поколение.
// Поколение отличает «ту же ячейку, но уже другую сущность» от живой ссылки.
using Entity = uint32_t;
constexpr Entity NO_ENTITY = 0xFFFFFFFFu;
constexpr uint32_t ENTITY_INDEX_BITS = 24;
constexpr uint32_t ENTITY_INDEX_MASK = (1u << ENTITY_INDEX_BITS) - 1u;
inline uint32_t IndexOf(Entity e) { return e & ENTITY_INDEX_MASK; }
inline uint32_t GenOf(Entity e) { return e >> ENTITY_INDEX_BITS; }
inline Entity MakeEntity(uint32_t index, uint32_t gen)
{
return (index & ENTITY_INDEX_MASK) | (gen << ENTITY_INDEX_BITS);
}
// --- пулы --------------------------------------------------------------------
struct IPool
{
virtual ~IPool() = default;
virtual void Remove(Entity e) = 0;
virtual void Clear() = 0;
};
// Плотный массив компонентов + разрежённая таблица «индекс сущности -> место».
// data остаётся сплошным, поэтому его можно отдавать наружу как обычный массив
// (именно так SolverContext получает Agent* и ничего не знает про ECS).
template <class T>
struct Pool final : IPool
{
std::vector<T> data;
std::vector<Entity> owner; // кому принадлежит data[i]
std::vector<int32_t> sparse; // индекс сущности -> позиция в data, -1 = нет
bool Has(Entity e) const
{
const uint32_t i = IndexOf(e);
return i < sparse.size() && sparse[i] >= 0;
}
T* Find(Entity e)
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
return &data[size_t(sparse[i])];
}
const T* Find(Entity e) const
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return nullptr;
return &data[size_t(sparse[i])];
}
T& Add(Entity e, const T& value)
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size()) sparse.resize(size_t(i) + 1, -1);
if (sparse[i] >= 0)
{
data[size_t(sparse[i])] = value;
return data[size_t(sparse[i])];
}
sparse[i] = int32_t(data.size());
data.push_back(value);
owner.push_back(e);
return data.back();
}
void Remove(Entity e) override
{
const uint32_t i = IndexOf(e);
if (i >= sparse.size() || sparse[i] < 0) return;
const size_t slot = size_t(sparse[i]);
const size_t last = data.size() - 1;
if (slot != last)
{
data[slot] = data[last];
owner[slot] = owner[last];
sparse[IndexOf(owner[slot])] = int32_t(slot);
}
data.pop_back();
owner.pop_back();
sparse[i] = -1;
}
void Clear() override
{
data.clear();
owner.clear();
for (int32_t& s : sparse) s = -1;
}
int Count() const { return int(data.size()); }
};
// --- идентификаторы типов ----------------------------------------------------
inline uint32_t NextTypeId()
{
static uint32_t next = 0;
return next++;
}
template <class T>
inline uint32_t TypeId()
{
static const uint32_t id = NextTypeId();
return id;
}
// --- мир ---------------------------------------------------------------------
struct World
{
// Создание/уничтожение сущностей.
Entity Create()
{
if (!freeSlots.empty())
{
const uint32_t i = freeSlots.back();
freeSlots.pop_back();
alive[i] = 1;
return MakeEntity(i, generation[i]);
}
const uint32_t i = uint32_t(alive.size());
alive.push_back(1);
generation.push_back(0);
return MakeEntity(i, 0);
}
bool Alive(Entity e) const
{
const uint32_t i = IndexOf(e);
return i < alive.size() && alive[i] != 0 && generation[i] == GenOf(e);
}
void Destroy(Entity e)
{
if (!Alive(e)) return;
const uint32_t i = IndexOf(e);
for (auto& p : pools)
if (p) p->Remove(e);
alive[i] = 0;
generation[i] = uint8_t((generation[i] + 1u) & 0xFFu);
freeSlots.push_back(i);
}
// Пул компонента. Создаётся при первом обращении и живёт до смерти мира:
// Clear() чистит СОДЕРЖИМОЕ, но не сами пулы, поэтому ссылки на data
// (например Game::targets) переживают Reset уровня.
template <class T>
Pool<T>& Store()
{
const uint32_t id = TypeId<T>();
if (id >= pools.size()) pools.resize(size_t(id) + 1);
if (!pools[id]) pools[id] = std::make_unique<Pool<T>>();
return *static_cast<Pool<T>*>(pools[id].get());
}
template <class T>
const Pool<T>& Store() const
{
return const_cast<World*>(this)->Store<T>();
}
template <class T>
T& Add(Entity e, const T& value) { return Store<T>().Add(e, value); }
template <class T>
T* Get(Entity e) { return Store<T>().Find(e); }
template <class T>
const T* Get(Entity e) const { return Store<T>().Find(e); }
template <class T>
std::vector<T>& All() { return Store<T>().data; }
void Clear()
{
for (auto& p : pools)
if (p) p->Clear();
alive.clear();
generation.clear();
freeSlots.clear();
}
private:
std::vector<uint8_t> alive;
std::vector<uint8_t> generation;
std::vector<uint32_t> freeSlots;
std::vector<std::unique_ptr<IPool>> pools;
};
} // namespace ecs