squad-proto/SQUAD_PROTOTYPE_SPEC.md
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

21 KiB
Raw Permalink Blame History

Прототип: тактический отряд с авто-огнём (изометрия, 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 Сущности

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 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.