squad-proto/src/ecs/ecs.h
z.kirill 7880f275bd Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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 13:00:49 +03:00

232 lines
8.4 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
// -----------------------------------------------------------------------------
// Минимальная 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