314 lines
21 KiB
Markdown
314 lines
21 KiB
Markdown
|
|
# Прототип: тактический отряд с авто-огнём (изометрия, 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.3–6.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 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.
|