Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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>
21 KiB
Прототип: тактический отряд с авто-огнём (изометрия, 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.
- Из raylib используются только: окно, ввод, таймер,
- Внутреннее разрешение 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 Движение агента (слои, складываются в таком порядке)
seekк целевой точке (слот строя илиpostPos);separationот союзников (радиус0.7);laneAvoidance(6.6) — вес выше, чем у separation;wallAvoidance+ скольжение вдоль стен при коллизии;- клампинг по максимальной скорости, интеграция, разрешение коллизий с тайлами (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.5 сек минимум 4 из 5 агентов имеют чистую линию огня. Видно, как они расходятся веером.
FRIENDLY_HITS= 0 за 3 минуты непрерывного боя во всех режимах, включая узкие проходы.- При движении отряда стреляют только те, у кого чистая линия; остальные не палят в спину союзникам. Визуально это заметно — часть агентов молчит.
- Агент, оказавшийся на чужой линии огня, сам отходит вбок в течение ~0.3 сек.
- Нет дёрганья: агент не переключает
postPosчаще чем раз в0.6сек, позиции не осциллируют между двумя точками. - В узком проходе (2 тайла) отряд самостоятельно вытягивается в колонну, стреляет передний, задние подтягиваются и не стреляют.
- В ручном режиме те же правила: агент без линии до назначенной цели перестраивается (стоя) либо молчит (в движении), но никогда не стреляет сквозь своего.
FIRE UPTIMEв стоячем бою на открытой арене ≥ 80%.- Стабильные 60 fps.
- Вся сцена нарисована собственным софтверным растеризатором; в игровой отрисовке нет raylib-примитивов.
12. Чего не делать
- Не добавлять врагов с ИИ, атаками, лут, UI, меню, звук, партиклы, освещение, туман войны.
- Не подключать сторонние библиотеки.
- Не писать ECS, не абстрагировать преждевременно. Простые структуры и вектора.
- Не «улучшать» правила стрельбы по своему усмотрению — раздел 6 реализуется как написано. Если считаешь, что где-то ошибка — сначала скажи, потом делай.