squad-proto/SQUAD_PROTOTYPE_SPEC.md

314 lines
21 KiB
Markdown
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
# Прототип: тактический отряд с авто-огнём (изометрия, software rendering)
Ты делаешь **технический прототип боевого ядра** для 2D-изометрического sci-fi horror roguelite / incremental ARPG.
Цель прототипа — **только одно**: доказать, что отряд из 5 агентов ведёт себя как «умная текучая сущность», которая сама расставляется так, чтобы стрелять и не задевать своих.
Всё остальное (лут, прогрессия, враги-ИИ, уровни) — вне рамок. Не добавляй.
---
## 1. Технические ограничения
- **C++17**, **raylib 5.x**, **CMake** (raylib через `FetchContent`, если не найден в системе).
- Никаких других зависимостей. Никакого ECS-фреймворка, никакой физдвижка.
- **Software rendering.** Вся отрисовка сцены — ручная запись пикселей в собственный фреймбуфер (`uint32_t*`, RGBA8). Ни одного вызова raylib-примитивов для игровой сцены.
- Из raylib используются только: окно, ввод, таймер, `UpdateTexture` + `DrawTexturePro` для финального блита фреймбуфера на экран (фильтрация `TEXTURE_FILTER_POINT`).
- Исключение: **debug-оверлей** можно рисовать через `DrawText`/`DrawLine` поверх уже отмасштабированного кадра. Это dev-only, отключается по F1.
- **Внутреннее разрешение 480×270**, окно 1440×810 (целочисленный ×3 апскейл). Разрешение и множитель — константы.
- **Fixed timestep симуляции 60 Гц**, накопитель времени, рендер с интерполяцией позиций. Симуляция детерминирована.
- Однопоточно. Читаемость важнее производительности, но 5 агентов + 12 мишеней + 200 пуль должны идти в 60 fps без проблем.
---
## 2. Ассеты
**Никаких файлов.** Все спрайты генерируются процедурно в код при старте (пишешь пиксели в буфер):
- `tile_floor` — ромб 32×16, 2 варианта оттенка (шахматка), тёмно-серый металл.
- `tile_wall` — блок 32×32 (ромб + вертикальные грани), заметно светлее пола.
- `agent` — 10×14, капсула с обводкой, 5 цветовых вариантов (по одному на агента).
- `target` — 12×16, тускло-красная капсула с обводкой.
- `bullet` — 2×2 точка.
Спрайты хранятся как `Sprite { int w, h; std::vector<uint32_t> px; }` с альфа-тестом (0 = прозрачно), без блендинга.
---
## 3. Координаты и рендер
**Критично: вся симуляция — в декартовом мире (top-down), изометрия существует только в рендерере.**
Никакой iso-математики в логике, ИИ или коллизиях.
- Мир в тайлах, `float` координаты. Тайл = 1.0 юнит.
- Проекция: `sx = (wx - wy) * 16`, `sy = (wx + wy) * 8` (тайл 32×16), плюс смещение камеры.
- Камера следует за якорем отряда со сглаживанием, округляется до целых пикселей (чтобы не дрожало).
- Сортировка по глубине: все видимые сущности и стены в один список, сортировка по `(wx + wy)`, потом блит.
- Пол рисуется первым сплошным проходом по видимой области карты.
---
## 4. Уровень
Один захардкоженный тайлмап ~48×48, генерируется в коде:
- открытая арена в центре;
- **колонны/огрызки стен по всей карте** — они обязаны быть, без них не проверить LOS и обход;
- пара узких проходов (шириной 2 тайла) — там правило «не стрелять сквозь своих» упирается в геометрию, это важный тест-кейс;
- по периметру — сплошная стена.
Тайл: `EMPTY` (проходим, прозрачен) / `WALL` (непроходим, блокирует LOS и пули).
**Мишени:** 12 штук, статичные, разбросаны кучками (по 1, по 3, по 5). У мишени есть HP (например, 500) и она **респавнится через 3 сек** после смерти на том же месте — прототип должен работать бесконечно. Мишени не атакуют и не двигаются (на этом этапе это шум).
---
## 5. Управление (только клавиатура)
| Клавиша | Действие |
|---|---|
| `WASD` | движение якоря отряда (в **экранных** осях, не мировых — иначе неинтуитивно) |
| `Shift` (удерж.) | медленный шаг |
| `Space` | переключить режим ручного прицеливания |
| `←/→` | в ручном режиме — поворот вектора прицела |
| `Tab` | сменить назначенную цель |
| `1/2/3` | пресет строя: клин / линия / кольцо |
| `F1` | debug-оверлей вкл/выкл |
| `F2` | показ линий огня |
| `F3` | показ кандидатов позиций и их оценок |
| `F4` | метрики |
| `R` | сброс уровня |
| `Esc` | выход |
Стрельба **всегда автоматическая**. Игрок не нажимает «огонь» никогда, ни в одном режиме.
---
## 6. ЯДРО: поведение отряда
Это главная часть задания. Ниже — точная спецификация, реализуй её как **чистые, тестируемые функции**, отдельно от рендера.
### 6.1 Сущности
```cpp
struct Agent {
Vec2 pos, vel;
float facing; // радианы, куда смотрит ствол
float bodyRadius = 0.28f;
int slotIndex; // место в строю
AgentState state;
int currentTargetId; // -1 = нет
Vec2 postPos; // куда агент решил встать (огневая позиция)
float commitTimer; // блокировка перерешивания
float solveTimer; // стаггер решателя
Weapon weapon;
};
struct Weapon {
float range = 9.0f;
float fireInterval = 0.35f;
float cooldown;
float laneHalfWidth = 0.10f; // «толщина» траектории пули
float damage = 12.0f;
};
```
### 6.2 Состояния отряда
- `ADVANCING` — есть ввод движения.
- `HOLDING` — ввода нет дольше `0.15 сек`.
### 6.3 Проверка линии огня (сердце всей механики)
```
bool LaneClear(shooter, targetPos, allAgents, tilemap):
seg = Segment(shooter.muzzle(), targetPos)
// 1. свои
for each ally in allAgents, ally != shooter:
d = DistancePointToSegment(ally.pos, seg)
if d < ally.bodyRadius + shooter.weapon.laneHalfWidth + SAFETY_MARGIN:
return false
// 2. геометрия
if TileRaycastBlocked(seg, tilemap):
return false
// 3. дистанция
if seg.length() > shooter.weapon.range:
return false
return true
```
`SAFETY_MARGIN = 0.15f` (тюнится). `muzzle()` — точка в `0.3` юнита от центра агента по `facing`.
Рейкаст по тайлам — Amanatides & Woo (DDA), без выделений памяти.
### 6.4 Логика в состоянии `ADVANCING`
- Каждый агент движется к своему **слоту строя** относительно якоря, строй ориентирован по направлению движения.
- Стрельба **оппортунистическая**: стреляет только тот, у кого прямо сейчас `LaneClear` до какой-либо цели в радиусе. Остальные просто бегут.
- Приоритет цели у бегущего: ближайшая, до которой линия чиста.
- **Никакого перестроения ради огня** в этом состоянии — отряд едет туда, куда сказал игрок.
- Работает уклонение от чужих линий (см. 6.6) — агент, влезший в чужой сектор обстрела, смещается.
### 6.5 Логика в состоянии `HOLDING` — решатель огневых позиций
Запускается **не каждый кадр**: у каждого агента свой `solveTimer` (интервал `0.25 сек`), фазы сдвинуты так, чтобы за кадр решал максимум 1 агент.
Условие запуска: у агента **нет** валидной линии огня ни к одной живой цели в радиусе.
**Кандидаты позиций:**
- текущая позиция (для сравнения);
- 3 кольца радиусами `0.8 / 1.6 / 2.4` юнита × 12 направлений = 36 точек.
Отбрасываются кандидаты: в стене, ближе `0.55` юнита к другому агенту, вне карты.
**Оценка кандидата:**
```
score = 0
if LaneClearFrom(candidate, bestTarget): score += 100
score += 25 * (число целей, простреливаемых из candidate) / totalTargets
score -= 35 * (число союзников, чью текущую валидную линию перекрывает candidate)
score -= 9 * distance(candidate, agent.pos)
score -= 14 * max(0, distance(candidate, anchor) - COHESION_RADIUS)
if distance(candidate, ближайшая цель) < MIN_ENGAGE_DIST: score -= 60
```
**Коммит с гистерезисом (обязательно, иначе будет дёрганье):**
```
if bestScore > currentPositionScore + HYSTERESIS(15.0):
agent.postPos = bestCandidate
agent.commitTimer = 0.6f // не перерешивать это время
```
`COHESION_RADIUS = 3.5`, `MIN_ENGAGE_DIST = 2.0`.
Пока `commitTimer > 0` — агент идёт к `postPos` и не пересчитывает.
### 6.6 Резервирование линий и расступание («текучесть»)
Каждый кадр собирается список **активных линий огня** — сегментов от стреляющих агентов к их целям.
Любой агент, чьё тело попадает в чужую активную линию (`dist < bodyRadius + laneHalfWidth + SAFETY_MARGIN`), получает **steering-импульс перпендикулярно линии**, в сторону ближайшего края. Сила импульса растёт при приближении к оси.
Это работает в **обоих** состояниях отряда и даёт главное ощущение: отряд сам «расплывается», освобождая сектора обстрела, без единой явной команды.
### 6.7 Движение агента (слои, складываются в таком порядке)
1. `seek` к целевой точке (слот строя или `postPos`);
2. `separation` от союзников (радиус `0.7`);
3. `laneAvoidance` (6.6) — **вес выше, чем у separation**;
4. `wallAvoidance` + скольжение вдоль стен при коллизии;
5. клампинг по максимальной скорости, интеграция, разрешение коллизий с тайлами (circle-vs-AABB, push-out).
Скорость: `3.2` юнита/сек, `1.6` на Shift.
### 6.8 Ручное прицеливание (`Space`)
- Игрок вращает вектор прицела стрелками.
- **Назначенная цель** — ближайшая мишень в конусе ±20° от вектора прицела. `Tab` циклит между целями в конусе.
- Все агенты приоритезируют назначенную цель.
- **Правила 6.36.6 не меняются вообще.** Агент без чистой линии до назначенной цели:
- в `HOLDING` — перестраивается через решатель, целью решателя становится назначенная цель;
- в `ADVANCING` — не стреляет по ней; стреляет по вторичным целям, только если включён флаг `freeFireSecondary` (по умолчанию `true`).
- Ручной режим **никогда** не позволяет стрелять сквозь своих. Никаких исключений.
### 6.9 Пули
Настоящие снаряды, не хитскан: скорость `22` юнита/сек, коллизия с тайлами и со **всеми** капсулами, включая союзников.
**`friendlyFire = true` всегда.** Дружественный урон должен быть физически возможен — иначе прототип ничего не доказывает. Счётчик попаданий по своим — главная метрика корректности.
---
## 7. Debug-оверлей
По `F1`, поверх кадра:
- состояние отряда (`ADVANCING` / `HOLDING`), режим прицеливания;
- над каждым агентом: индекс, состояние, id цели;
- `F2`: линии огня — **зелёная** (чистая, стреляет), **жёлтая** (цель есть, линия перекрыта своим), **красная** (перекрыта стеной);
- `F3`: кандидаты последнего решателя точками, цвет = оценка (синий низкая → белый высокая), выбранный кандидат обведён;
- якорь отряда, радиус когезии, слоты строя;
- `F4`, метрики:
- **`FRIENDLY_HITS`** — накопительный счётчик попаданий по своим;
- **`FIRE UPTIME`** — % времени, когда агент имел валидную линию (за последние 10 сек, по каждому агенту и среднее);
- **`SETTLE TIME`** — время от перехода в `HOLDING` до момента, когда ≥4 агентов получили линию огня;
- fps, время симуляции на кадр.
---
## 8. Тюнинг
Все числа выше — в **одной структуре** `Tuning` в `tuning.h`, ни одного магического числа по коду.
Плюс runtime-подкрутка: `[` / `]` выбирают параметр, `-` / `=` меняют значение, текущее значение видно в оверлее. Это нужно, чтобы я сам почувствовал баланс.
---
## 9. Структура проекта
```
CMakeLists.txt
src/
main.cpp — окно, главный цикл, fixed timestep
tuning.h — все константы
core/math.h — Vec2, сегменты, DistPointSegment, углы
render/framebuffer.h/.cpp — фреймбуфер, clear, blit, линия, круг, блит на экран
render/sprites.h/.cpp — процедурная генерация спрайтов
render/iso.h — проекция мир↔экран, сортировка глубины
world/tilemap.h/.cpp — карта, DDA-рейкаст, коллизии
sim/agent.h/.cpp — агент, движение, steering
sim/squad.h/.cpp — якорь, строй, состояния отряда
sim/target.h/.cpp
sim/bullet.h/.cpp
ai/firing_solver.h/.cpp — LaneClear, оценка кандидатов, коммит
ai/lane_registry.h/.cpp — активные линии, расступание
debug/overlay.h/.cpp
debug/metrics.h/.cpp
```
`ai/` не должен знать ничего о рендере и об изометрии. Вообще.
---
## 10. Порядок работы
Делай по вехам, **после каждой — собери и запусти**, убедись что работает, потом иди дальше. Не пиши всё сразу.
- **M0** — окно, фреймбуфер, апскейл, изометрическая сетка на экране.
- **M1** — тайлмап, стены, камера, сортировка по глубине.
- **M2** — 5 агентов, движение якоря, строй, separation, коллизии со стенами.
- **M3** — мишени, DDA-рейкаст, `LaneClear`, авто-огонь, пули, дружественный урон, счётчик `FRIENDLY_HITS`.
- **M4** — решатель огневых позиций, гистерезис, коммит, расступание из чужих линий. **Ключевая веха.**
- **M5** — ручное прицеливание, назначенная цель, `Tab`.
- **M6** — полный debug-оверлей, метрики, runtime-тюнинг.
---
## 11. Критерии приёмки
Прототип готов, когда **все** пункты выполняются:
1. Отряд стоит в куче, игрок останавливается → **за ≤1.5 сек** минимум 4 из 5 агентов имеют чистую линию огня. Видно, как они расходятся веером.
2. **`FRIENDLY_HITS` = 0** за 3 минуты непрерывного боя во всех режимах, включая узкие проходы.
3. При движении отряда стреляют только те, у кого чистая линия; остальные не палят в спину союзникам. Визуально это заметно — часть агентов молчит.
4. Агент, оказавшийся на чужой линии огня, **сам отходит вбок** в течение ~0.3 сек.
5. Нет дёрганья: агент не переключает `postPos` чаще чем раз в `0.6` сек, позиции не осциллируют между двумя точками.
6. В узком проходе (2 тайла) отряд **самостоятельно вытягивается в колонну**, стреляет передний, задние подтягиваются и не стреляют.
7. В ручном режиме те же правила: агент без линии до назначенной цели перестраивается (стоя) либо молчит (в движении), но **никогда** не стреляет сквозь своего.
8. `FIRE UPTIME` в стоячем бою на открытой арене ≥ 80%.
9. Стабильные 60 fps.
10. Вся сцена нарисована собственным софтверным растеризатором; в игровой отрисовке нет raylib-примитивов.
---
## 12. Чего не делать
- Не добавлять врагов с ИИ, атаками, лут, UI, меню, звук, партиклы, освещение, туман войны.
- Не подключать сторонние библиотеки.
- Не писать ECS, не абстрагировать преждевременно. Простые структуры и вектора.
- Не «улучшать» правила стрельбы по своему усмотрению — раздел 6 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.