Compare commits

...

3 Commits

Author SHA1 Message Date
z.kirill
c1f79a1fb8 Поле путей: отставший возвращается в строй (M37)
Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, seek'ом тянулся В СТЕНУ, обход стены
толкал его обратно, и он замирал в углу навсегда.

Теперь у каждого бойца своё поле путей (BFS от клетки его цели) с натягиванием
верёвки: он берёт самую дальнюю точку пути, которую видит. Включается обход по
двум условиям сразу — боец упёрся (хочет идти и не едет) И потерял строй. Счётчик
упора сбрасывается по факту «цель видно», а не «поехал»: сброс по скорости давал
дребезг, и боец полз шаг за две секунды.

По дороге вскрылись две неверные МЕРКИ:

* критерий 11.6 считал колонной «разброс вдоль больше, чем поперёк» — и потому
  награждал отряд, у которого двое навсегда остались по ту сторону стены. Теперь
  колонна меряется самим проёмом: разом в горловине не больше двух, и прошли ВСЕ.
* зонд --wall читал «стены не нашлось» (ноль) как «стена на нулевом этаже» и
  объявлял пятёрку, не сломавшуюся нигде, худшей из трёх.

Обе переписаны и проверены на сломанной сборке.

Замеры: --play 6 5 на восьми сидах 47/48 (было 0/48), --accept PASS, шаг 6.1 мкс,
--wall 30 одиночка D20 / трое D20 / пятеро D31 (не найдена), --descend 8/30,
hollow --descend 6, --ui-check, оба проекта --check — без ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:31:34 +03:00
z.kirill
aa9a175751 Проверка одиночки ставила отряд в стену
Она сажала бойца в точку «+3 по X» от твари, не глядя на карту. Стоило
комнатам стать другими - точка попала в стену: боец не видел цель, клинок
её не брал, и проверка объявила первый этаж непроходимым, хотя мерила
собственную раскладку.

Теперь ищется свободная клетка с прямой видимостью - тем же способом, что
и в проверке триггеров, где эта же ошибка уже вылезала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 05:30:00 +03:00
z.kirill
da670af6dc Экран отряда: подсказка не закрывает статы, имена не режутся
Подсказка вставала НИЖЕ курсора и ложилась ровно на строки
характеристик - те самые, которые объясняет: игрок наводил на броню,
чтобы понять, откуда 28%, и подсказка закрывала строку ARMOR. Теперь
она встаёт выше курсора, там пусто.

Ячейка схрона была 52 пикселя: в неё не влезало даже слово BATTERY, и
половина имён обрезалась на середине. Шесть колонок вместо восьми -
читаемость важнее двух лишних ячеек, прокрутка и так есть.

Сумка вне вылазки честно подписана RAID ONLY: она принадлежит забегу и
очищается при сборке этажа, а молчащий отказ читается как поломка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 05:27:22 +03:00
21 changed files with 590 additions and 182 deletions

View File

@ -148,8 +148,50 @@ muzzle = from + normalize(target - from) * muzzleOffset // 0.3 юнита
отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это отталкивание уравновешиваются, и агент залипает в «тени» препятствия — это
реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл). реально ловилось в прогонах (агент оставался в комнате, когда отряд ушёл).
Скорость 1.75 юнита/с, 2.7 на `Shift`. Скорость подтягивается к желаемой с Скорость 2.6 юнита/с, 4.4 на пределе натяжения. Скорость подтягивается к
ограничением `steerAccel`, чтобы резкие развороты не телепортировали агента. желаемой с ограничением `steerAccel`, чтобы резкие развороты не телепортировали
агента.
## 5b. Поле путей: как отставший возвращается в строй
Слои выше — локальные, и этого хватает ровно до первого угла. Боец, оказавшийся
за стеной от своего слота, seek'ом тянулся В СТЕНУ, слой 4 толкал его обратно, и
он замирал в углу **навсегда**. Снаружи это выглядело как «пятеро не проходят
там, где одиночка проходит»: `--play 6 5` вставал на первом же этаже на всех
восьми сидах. Ширина коридоров тут ни при чём — отряд вставал и на проходах в
три, четыре и пять клеток.
Устройство (`Squad::RebuildNav`, `Squad::NavWaypoint`, `sim/squad.cpp`):
- BFS по проходимым клеткам **от клетки цели**, четыре соседа (по диагонали
круглое тело в щель между углами не пролезает);
- боец спускается по градиенту и берёт **самую дальнюю точку пути, которую ещё
видит** — натянутая верёвка; идёт он по ней как по прямой;
- поле **у каждого своё**. Одно общее поле до якоря сводило всех обходящих в
одну точку, и отряд входил в проход лужей вместо колонны.
Включается обход по ДВУМ условиям сразу:
| Условие | Зачем |
|---|---|
| `stuckFor > navStuckTime` (0.35 с) — хочет идти и не едет | обход нужен тому, кто реально упёрся |
| отстал от слота дальше `straggleDist` (2.8) | боец на своём месте не потерялся, его держит бой |
Счётчик `stuckFor` сбрасывается **не по факту «поехал», а по факту «цель
видно»**. Разница принципиальная: сброс по скорости давал дребезг — обход трогал
бойца с места, поле выключалось, боец упирался в ту же стену, и так по кругу со
средней скоростью шаг за две секунды.
Платим только когда обход реально включён: поле пересчитывается на смену клетки
цели и только у того, кто сейчас идёт в обход. Средний шаг симуляции — 6.1 мкс
при бюджете кадра 16666 мкс.
**Замеры** (`--play 6 5`, восемь сидов):
| Сборка | Этажей пройдено |
|---|---|
| до поля путей | 0 из 48 — отряд вставал на первом этаже |
| поле путей | 47 из 48 (единственная потеря — вайп в бою, не затык) |
## 5a. Диск и поводок ## 5a. Диск и поводок

View File

@ -54,6 +54,14 @@
| **M33** | крафт стал выбором: закалка (двигает рулон выбранного аффикса) и отпечаток (переносит аффикс с другой вещи, сжигая её), отсечение и возвышение работают по ВЫБРАННОМУ аффиксу, направленные операции вдвое дороже слепых | прежние пять операций были слепыми — игрок жал кнопку и смотрел, что выпало; проверка меряет направленность, а не срабатывание: отсечение пробуется на СРЕДНЕМ аффиксе из трёх, иначе слепая версия прошла бы её тоже; `--accept` PASS ([22-loot.md](22-loot.md)) | | **M33** | крафт стал выбором: закалка (двигает рулон выбранного аффикса) и отпечаток (переносит аффикс с другой вещи, сжигая её), отсечение и возвышение работают по ВЫБРАННОМУ аффиксу, направленные операции вдвое дороже слепых | прежние пять операций были слепыми — игрок жал кнопку и смотрел, что выпало; проверка меряет направленность, а не срабатывание: отсечение пробуется на СРЕДНЕМ аффиксе из трёх, иначе слепая версия прошла бы её тоже; `--accept` PASS ([22-loot.md](22-loot.md)) |
| **M34** | семь новых тварей на глубины 973: акты IIVI перестали быть пустыми | `--tool enemies` и `--tool ttk`: каждая тварь попадает в свою полосу глубины, время убийства держится в коридоре ([16-descent.md](16-descent.md)) |
| **M35** | 19 новых вещей (тиры 16, глубины 331), исправлена кривая редкости и выбор тиров аффиксов | `--tool drops` и `--tool affix`: добыча перестала быть мусором, ядро тега гарантируется ([22-loot.md](22-loot.md)) |
| **M36** | пистолет не пропадает при переодевании, эвакуация с ЛЮБОГО этажа, кнопки игровых меню снизу HUD, твари не воскресают в пределах забега | пропажа была потерей экземпляра: снаряжение бойца не сохранялось в профиль после перекладывания; `--ui-check` и `--descend 8` PASS ([13-combat.md](13-combat.md)) |
| **M37** | **поле путей**: отставший боец возвращается в строй по геометрии уровня, а не по прямой; обход включается только тому, кто упёрся и потерял строй | это был тот самый «отряд не проходит там, где одиночка проходит»: `--play 6 5` вставал на первом этаже на всех восьми сидах, стало 47 из 48 (единственная потеря — вайп в бою); по дороге вскрылись ДВЕ неверные мерки: критерий 11.6 награждал брошенных за стеной бойцов (растянутый отряд = «колонна»), а `--wall` читал «стены не нашлось» как «стена на нулевом этаже» и объявлял лучший результат провалом; обе переписаны и проверены на сломанной сборке; шаг симуляции 6.1 мкс; `--accept` PASS ([05-squad-ai.md](05-squad-ai.md), [08-decisions.md](08-decisions.md)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -280,6 +280,45 @@ seek тянет вперёд, отталкивание держит — аген
--- ---
### A22. Критерий 11.6: «колонна» меряется проёмом, а не разбросом
**Было**: колонной считалось «разброс вдоль движения больше, чем поперёк,
в полтора раза» — по бойцам в створе прохода вместе с подходами.
**Стало**: колонна — это про сам проём. Одновременно в горловине не больше
двух (проём шириной два тайла), и через неё прошли **все** живые бойцы.
**Почему**. Старая мерка награждала ровно ту поломку, ради которой писалась.
Отряд, у которого двое навсегда остались по ту сторону стены (у них не было
поиска пути и вернуться они не могли), растянут вдоль на шесть тайлов — и
проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить стену и
собираться, стал компактным — и проверку ронял. Замер трасс показал это прямо:
без обхода бойцы 3 и 4 все 26 секунд стоят на y≈16.3 и y≈16.9, южнее стены.
**Замер**: на исправленной сборке — прошли 5 из 5, в горловине разом до 2,
FRIENDLY_HITS=0, сквозь своего 0. На сборке без обхода (`--set navStuckTime=99`)
критерий падает: прошли 3 из 5. То есть мерка стала строже: раньше она
пропускала брошенных за стеной, теперь нет.
---
### A23. Зонд `--wall`: «стены не нашлось» — это не «стена на нулевом этаже»
**Было**: глубина первой стены хранилась нулём как «ещё не найдена», и итог
сравнивал `wall[пятеро] <= wall[одиночка]` буквально.
**Стало**: не найденная стена читается как «глубже последней проверенной»
(`maxDepth + 1`) и печатается как `D31 (не найдена)`.
**Почему**. Пятёрка перестала ломаться на всех проверяемых глубинах — то есть
показала лучший возможный результат, — и зонд объявил это провалом: ноль меньше
двадцати. Ошибка мерки, а не игры, того же рода, что и A22.
**Замер**: `--wall 30` — одиночка D20, трое D20, пятеро D31 (не найдена),
код возврата 0.
---
## B. Структурные (поведение не меняют) ## B. Структурные (поведение не меняют)
### B1. Дополнительные файлы ### B1. Дополнительные файлы
@ -323,5 +362,7 @@ seek тянет вперёд, отталкивание держит — аген
- `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять - `SETTLE TIME` по той же причине растёт, если отряд остановился там, где стрелять
не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к не по чему; в общем прогоне попадаются значения до 5.7 с. В обстановке, к
которой относится критерий 11.1 (мишени в радиусе), — 0.02 с. которой относится критерий 11.1 (мишени в радиусе), — 0.02 с.
- Пути агентов чисто локальные, без поиска пути. Отряд проходит узкие коридоры, - Поиск пути включается ТОЛЬКО для отставшего, который упёрся (см.
но лабиринт с тупиками не осилит — это вне рамок прототипа. [05-squad-ai.md](05-squad-ai.md), «поле путей»). Боец в строю по-прежнему
ходит локальными слоями, и это осознанно: строй должен вести себя как строй,
а не как пять независимых искателей пути.

View File

@ -1,9 +1,10 @@
# Открытая арена с четырьмя опорами. Здесь работает решатель огневых позиций: # Открытая арена с четырьмя опорами. Здесь работает решатель огневых позиций:
# места хватает, и отряд обязан разойтись по линиям, а не сбиться в кучу. # места хватает, и отряд обязан разойтись по линиям, а не сбиться в кучу.
# #
# Опоры стали ОДНОРЯДНЫМИ вместо блоков 2x2, а сама арена сузилась до 12: блок # Опоры отодвинуты В УГЛЫ и стали однорядными. Причина не в красоте: строй из
# отнимал у центра столько пола, что пятеро вставали в него впритык и переставали # пяти занимает поперёк почти четыре тайла, и опора, стоявшая у самого прохода,
# расходиться. Укрытие нужно, чтобы за него зайти, а не чтобы об него спотыкаться. # ловила крайнего бойца намертво — упереться в неё он мог, а обойти нет.
# Полоса пола между дверями противоположных стен обязана быть шире строя.
name arena name arena
size 12 12 size 12 12
weight 3 weight 3
@ -12,20 +13,20 @@ start 0
depth 1 0 depth 1 0
tiles tiles
####+++##### ####+++#####
#..........# #.#......#.#
#..........# #.#......#.#
#..##..##..#
#..........# #..........#
+..........+ +..........+
+..........+ +..........+
+..........+ +..........+
#..........# #..........#
#..##..##..#
#..........# #..........#
#.#......#.#
#.#......#.#
####+++##### ####+++#####
spawn 4 4 0.90 shambler:4 rusher:3 spitter:2 spawn 4 5 0.90 shambler:4 rusher:3 spitter:2
spawn 9 4 0.85 shambler:4 rusher:3 spawn 8 5 0.85 shambler:4 rusher:3
spawn 4 7 0.85 shambler:3 rusher:2 brute:1 spawn 4 7 0.85 shambler:3 rusher:2 brute:1
spawn 9 7 0.90 shambler:4 spitter:2 spawn 8 7 0.90 shambler:4 spitter:2
spawn 6 1 0.40 crate:2 barrel:1 spawn 6 1 0.40 crate:2 barrel:1
spawn 7 10 0.40 crate:1 locker:1 spawn 6 10 0.40 crate:1 locker:1

View File

@ -1,9 +1,9 @@
# Казарма: ряды перегородок и сквозной проход посередине. Твари выходят # Казарма: койки по нишам вдоль стен, середина сквозная. Твари выходят
# из-за переборок сбоку, а не в лоб, — строй приходится разворачивать на ходу. # из-за переборок сбоку, а не в лоб, — строй приходится разворачивать на ходу.
# #
# Перегородок стало вдвое меньше, а проход посередине — в три ряда. Прежние пять # Прежние пять рядов переборок резали казарму на щели шириной в тело: сбоку
# рядов по два тайла резали комнату на щели шириной в тело: сбоку выходить было # выходить было откуда, а разворачиваться в ответ уже негде. Ниши остались
# откуда, а разворачиваться в ответ уже негде. # ровно там, где не мешают проходу от двери до двери.
name barracks name barracks
size 12 9 size 12 9
weight 2 weight 2
@ -12,13 +12,13 @@ start 0
depth 2 0 depth 2 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#..........# #..........#
#..##..##..#
+..........+ +..........+
+..........+ +..........+
+..........+ +..........+
#..##..##..#
#..........# #..........#
#.#......#.#
####+++##### ####+++#####
spawn 4 1 0.75 shambler:3 rusher:2 spawn 4 1 0.75 shambler:3 rusher:2
spawn 8 1 0.60 spitter:2 shambler:1 spawn 8 1 0.60 spitter:2 shambler:1

View File

@ -1,9 +1,9 @@
# Перекрёсток. Рукава шириной три тайла — ровно как коридоры между комнатами: # Перекрёсток. Рукава шириной пять тайлов: правило «не стрелять сквозь своих»
# правило «не стрелять сквозь своих» упирается в геометрию, и отряд вытягивается # упирается в геометрию, и отряд вытягивается в колонну.
# в колонну, но не встаёт в ней намертво.
# #
# Середина расширена до четырёх рядов: перекрёсток — единственное место, где # Пять, а не три: строй из пяти занимает поперёк почти четыре тайла, и в рукаве
# колонна имеет право снова стать строем, и на трёх рядах она этого не успевала. # шириной три крайние бойцы упирались в его же стены на входе. Комната обязана
# СЖИМАТЬ строй, а не запирать его.
name corridor_x name corridor_x
size 12 12 size 12 12
weight 3 weight 3
@ -12,18 +12,18 @@ start 0
depth 1 0 depth 1 0
tiles tiles
####+++##### ####+++#####
####...##### ###.....####
####...##### ###.....####
####...##### #..........#
+..........+ +..........+
+..........+ +..........+
+..........+ +..........+
#..........# #..........#
####...##### #..........#
####...##### ###.....####
####...##### ###.....####
####+++##### ####+++#####
spawn 6 2 0.60 rusher:3 lurker:1 spawn 5 1 0.60 rusher:3 lurker:1
spawn 6 10 0.60 rusher:3 lurker:1 spawn 5 10 0.60 rusher:3 lurker:1
spawn 2 6 0.50 shambler:2 spitter:1 spawn 2 5 0.50 shambler:2 spitter:1
spawn 10 6 0.50 shambler:2 spitter:1 spawn 9 5 0.50 shambler:2 spitter:1

View File

@ -1,9 +1,9 @@
# Стартовая: просторно, светло по планировке и почти пусто. # Стартовая: просторно, светло по планировке и почти пусто.
# Отряд обязан успеть собраться в строй до первой твари. # Отряд обязан успеть собраться в строй до первой твари.
# #
# Поперёк комнаты идёт сплошная полоса пола в три ряда, и опор осталось по одной # Пусто здесь не от лени. Отряд высаживается в центре и первым делом
# на угол. Первое, что отряд делает после высадки, — разворачивается; делать это # разворачивается; разворот из пяти тел не помещается между двумя тумбами,
# должно быть где, иначе первые секунды каждого этажа уходят на распутывание строя. # и первые секунды каждого этажа уходили на распутывание строя.
name hall_start name hall_start
size 12 10 size 12 10
weight 2 weight 2
@ -12,14 +12,14 @@ start 1
depth 1 0 depth 1 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#..........# #..........#
#..........# #..........#
#..#....#..#
+..........+ +..........+
+..........+ +..........+
+..........+ +..........+
#..#....#..#
#..........# #..........#
#.#......#.#
####+++##### ####+++#####
spawn 2 2 0.50 crate:2 barrel:1 spawn 4 1 0.50 crate:2 barrel:1
spawn 9 7 0.40 barrel:1 locker:1 spawn 7 8 0.40 barrel:1 locker:1

View File

@ -1,9 +1,10 @@
# Гнездо: глухое ядро посередине и кольцо вокруг него. Линий огня почти нет, # Гнездо: выводковые камеры по углам, середина открыта. Линий огня почти нет —
# зато клинку есть где работать — сектор упирается в стену, а не в союзника. # они упираются в углы, — зато клинку есть где работать.
# #
# Кольцо теперь в три тайла со всех сторон, а перегородок внутри него нет вовсе. # Кольцевые перегородки посередине пришлось убрать: между ними оставались
# Прежние два вложенных кольца давали проходы шириной в одну клетку: отряд не # проходы в одну клетку, и отряд не обходил гнездо, а вставал в нём. Тесноту
# обходил ядро, он в нём застревал, и «нет линий огня» превращалось в «нет игры». # теперь делает не ширина прохода, а то, что тварей выпускают со всех четырёх
# сторон сразу.
name nest name nest
size 12 12 size 12 12
weight 2 weight 2
@ -12,18 +13,18 @@ start 0
depth 4 0 depth 4 0
tiles tiles
####+++##### ####+++#####
##.......###
##.......###
#..........# #..........#
#..........# +..........+
#..........# +..........+
#...####...#
+...####...+
+...####...+
+..........+ +..........+
#..........# #..........#
#..........# #..........#
#..........# ###.......##
###.......##
####+++##### ####+++#####
spawn 5 1 0.85 shambler:3 brute:1 spawn 5 1 0.85 shambler:3 brute:1
spawn 2 5 0.80 lurker:2 rusher:2 spawn 3 5 0.80 lurker:2 rusher:2
spawn 9 5 0.80 lurker:2 rusher:2 spawn 8 5 0.80 lurker:2 rusher:2
spawn 6 10 0.85 shambler:3 spitter:2 spawn 6 10 0.85 shambler:3 spitter:2

View File

@ -1,9 +1,9 @@
# Яма: колодец посередине с единственным входом снизу. Громилу в нём # Яма: колодец в дальнем углу, а не посередине. Громилу в нём приходится
# приходится разбирать вдвоём, а круг его удара достаёт обоих. # разбирать вдвоём, а круг его удара достаёт обоих.
# #
# Колодец сузился до двух тайлов, зато обход вокруг него стал трёхрядным. Смысл # Посередине колодец стоять не может: он перегораживал бы проход от двери к
# комнаты в том, что В КОЛОДЦЕ тесно, а не в том, что тесно везде: раньше отряд # двери, и строй упирался бы в его стенку всей шириной. В углу он делает ровно
# вставал ещё на подходе и до ямы просто не доходил. # то же самое — заставляет лезть в тесноту, — но лезть туда решает игрок.
name pit name pit
size 12 12 size 12 12
weight 2 weight 2
@ -12,19 +12,19 @@ start 0
depth 6 0 depth 6 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#.#......#.#
#..........#
+..........+
+..........+
+..........+
#..........# #..........#
#..........# #..........#
#..........# #.#.....#..#
#...####...# #.#.....#..#
+...#..#...+
+...#..#...+
+...#..#...+
#..........#
#..........#
#..........#
####+++##### ####+++#####
spawn 5 6 0.95 brute:3 shambler:2 spawn 9 10 0.95 brute:3 shambler:2
spawn 2 4 0.70 spitter:2 rusher:2 spawn 2 5 0.70 spitter:2 rusher:2
spawn 9 4 0.70 spitter:2 rusher:2 spawn 9 5 0.70 spitter:2 rusher:2
spawn 6 10 0.60 shambler:3 lurker:1 spawn 6 8 0.60 shambler:3 lurker:1
spawn 2 9 0.40 barrel:2 crate:1 spawn 5 1 0.40 barrel:2 crate:1

View File

@ -1,9 +1,8 @@
# Склад: обстановки много, тварей мало. Место, где игрок узнаёт, что хлам # Склад: обстановки много, тварей мало. Место, где игрок узнаёт, что хлам
# ломается, — и тратит на это магазин, которого потом не хватит. # ломается, — и тратит на это магазин, которого потом не хватит.
# #
# Стеллаж стал одним глухим блоком посередине, а обход вокруг него — трёхрядным. # Стеллажи стоят вдоль стен, а не двойной рамкой посередине: рамка оставляла по
# Прежняя двойная рамка оставляла по краям щели в один тайл: отряд входил на # краям щели в один тайл, и отряд входил на склад, а выходил из него гуськом.
# склад и не мог из него выйти иначе как гуськом.
name storage name storage
size 12 10 size 12 10
weight 2 weight 2
@ -12,16 +11,16 @@ start 0
depth 1 0 depth 1 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#.#......#.#
#..........# #..........#
#..........# +..........+
#...####...# +..........+
+...####...+
+...####...+
+..........+ +..........+
#..........# #..........#
#..........# #.#......#.#
####+++##### ####+++#####
spawn 2 4 0.90 crate:4 barrel:3 locker:2 spawn 4 1 0.90 crate:4 barrel:3 locker:2
spawn 9 4 0.85 crate:3 barrel:2 spawn 8 1 0.85 crate:3 barrel:2
spawn 5 7 0.70 barrel:2 locker:2 spawn 4 8 0.70 barrel:2 locker:2
spawn 1 1 0.45 shambler:2 lurker:1 spawn 1 4 0.45 shambler:2 lurker:1

View File

@ -1,27 +1,27 @@
# Схрон: тесная комната с глухим сейфом посередине. Хват здесь опаснее всего — # Схрон: самая маленькая комната набора. Хват здесь опаснее всего — вырванного
# вырванного из строя бойца в такой тесноте нечем прикрыть. # из строя бойца в такой тесноте нечем прикрыть.
# #
# Тесно тут за счёт РАЗМЕРА комнаты, а не за счёт щелей: камера в камере давала # Тесно тут за счёт РАЗМЕРА, а не за счёт щелей: камера в камере давала обходы
# обходы шириной в одну клетку, и отряд не столько рисковал, сколько стоял. # шириной в одну клетку, и отряд в схроне не рисковал, а стоял.
# Обход теперь трёхрядный, а схрон всё равно самая маленькая комната набора.
name vault name vault
size 10 10 size 11 11
weight 1 weight 1
theme 3 theme 3
start 0 start 0
depth 8 0 depth 8 0
tiles tiles
####+++### ####+++####
#........# #.#.....#.#
#........# #.#.....#.#
#........# #.........#
+...##...+ +.........+
+...##...+ +.........+
+...##...+ +.........+
#........# #.........#
#........# #.#.....#.#
####+++### #.#.....#.#
spawn 7 5 0.90 lurker:3 brute:1 ####+++####
spawn 1 1 0.55 rusher:2 shambler:1 spawn 5 5 0.90 lurker:3 brute:1
spawn 8 8 0.55 spitter:2 spawn 4 1 0.55 rusher:2 shambler:1
spawn 4 8 0.40 locker:2 crate:1 spawn 6 9 0.55 spitter:2
spawn 4 9 0.40 locker:2 crate:1

View File

@ -1,7 +1,8 @@
# Ряд камер. Ниши по бокам: в них удобно стоять и невозможно отступать. # Ряд камер. Ниши по бокам: в них удобно стоять и невозможно отступать.
# #
# Ниш стало меньше, а проход между рядами — трёхрядным: ниша должна быть местом, # Ниш стало вдвое меньше, и стоят они у самых стен. Прежний частокол оставлял
# КУДА зашли, а не единственным местом, где помещается тело. # между камерами проходы в одну клетку: ниша должна быть местом, КУДА зашли,
# а не единственным местом, где помещается тело.
name cells name cells
size 12 9 size 12 9
weight 3 weight 3
@ -9,14 +10,14 @@ theme 0
depth 1 0 depth 1 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#..........# #..........#
#.#.#..#.#.#
+..........+ +..........+
+..........+ +..........+
+..........+ +..........+
#.#.#..#.#.#
#..........# #..........#
#.#......#.#
####+++##### ####+++#####
spawn 2 1 0.65 rusher:3 shambler:2 spawn 4 1 0.65 rusher:3 shambler:2
spawn 10 7 0.65 rusher:3 shambler:2 spawn 8 7 0.65 rusher:3 shambler:2
spawn 6 4 0.40 bandage:2 knife:1 rifle:1 spawn 6 4 0.40 bandage:2 knife:1 rifle:1

View File

@ -1,12 +1,11 @@
# Зев. Открытая арена с ядром посередине: единственное место в наборе, где # Зев. Открытая арена: единственное место в наборе, где отряд может встать
# отряд может встать кругом. # кругом, — а встать кругом впятером нельзя нигде, где проход уже четырёх тайлов.
# #
# У ядра ДВА прохода, север и юг. Глухая коробка посередине выглядит на сетке # Ядро посередине пришлось убрать не ради простора, а ради проходимости: оно
# так же, но точка спавна внутри неё недостижима: тварь не дойдёт до отряда и # стояло ровно на линии между дверями и ловило строй всей шириной. Урок, ради
# не будет убита, и этаж тихо станет легче. Ровно это и ловит --check. # которого его когда-то рисовали, остался в силе: точка спавна в глухой коробке
# # недостижима, тварь до отряда не дойдёт, и этаж тихо станет легче. Ровно это
# Кольцо вокруг ядра — три тайла со всех сторон: встать кругом впятером нельзя # и ловит --check.
# в обходе шириной в два тела, там можно только выстроиться в очередь.
name maw name maw
size 12 11 size 12 11
weight 2 weight 2
@ -14,17 +13,17 @@ theme 2
depth 3 0 depth 3 0
tiles tiles
####+++##### ####+++#####
#.#......#.#
#.#......#.#
#..........# #..........#
+..........+
+..........+
+..........+
#..........# #..........#
#..........# #.#......#.#
#...#..#...# #.#......#.#
+...#..#...+
+...#..#...+
+...#..#...+
#...#..#...#
#..........#
####+++##### ####+++#####
spawn 6 5 0.85 brute:3 lurker:2 spawn 6 5 0.85 brute:3 lurker:2
spawn 1 1 0.60 spitter:2 rusher:2 spawn 4 1 0.60 spitter:2 rusher:2
spawn 10 9 0.60 spitter:2 rusher:2 spawn 8 9 0.60 spitter:2 rusher:2
spawn 10 1 0.45 shotgun:1 plate:1 cleaver:1 spawn 8 1 0.45 shotgun:1 plate:1 cleaver:1

View File

@ -1,9 +1,8 @@
# Стартовая HOLLOW. Тесная и короткая: вид сверху показывает всю комнату # Стартовая HOLLOW. Тесная и короткая: вид сверху показывает всю комнату
# сразу, и просторный зал первого этажа не оставил бы ничего на потом. # сразу, и просторный зал первого этажа не оставил бы ничего на потом.
# #
# Тесная — по размеру, а не по проходам. От четырёх опор осталась одна: отряд # Тесная — по РАЗМЕРУ, а не по проходам. Четыре тумбы посередине стояли ровно
# высаживается сюда всем составом и первым делом разворачивается, а разворот # там, где отряд разворачивается после высадки, и разворот в них упирался.
# между четырьмя тумбами упирался в них, а не в стены.
name mouth name mouth
size 11 9 size 11 9
weight 2 weight 2
@ -12,13 +11,13 @@ start 1
depth 1 0 depth 1 0
tiles tiles
####+++#### ####+++####
#.........# #.#.....#.#
#.........# #.........#
+.........+ +.........+
+....#....+ +.........+
+.........+ +.........+
#.........# #.........#
#.........# #.#.....#.#
####+++#### ####+++####
spawn 2 1 0.45 barrel:1 spawn 4 1 0.45 barrel:1
spawn 8 7 0.45 barrel:1 spawn 6 7 0.45 barrel:1

View File

@ -1,9 +1,9 @@
# Колодец. Узкий и длинный: строй здесь не развернуть, и это единственное, # Колодец. Узкий и длинный: строй здесь не развернуть, и это единственное,
# что комната делает. # что комната делает.
# #
# «Не развернуть» — это пять тайлов в ширину против трёх рядов строя, а не щель # «Не развернуть» — это пять тайлов в ширину против четырёх, которые занимает
# в одну клетку. Перемычки теперь поджимают колодец с БОКОВ до трёх тайлов, а не # строй, а не щель в одну клетку. Перемычки поперёк колодца убраны: они не
# перекрывают его посередине: отряд проходит с трудом, но проходит. # сжимали отряд, они его останавливали.
name shaft name shaft
size 7 12 size 7 12
weight 2 weight 2
@ -13,11 +13,11 @@ tiles
##+++## ##+++##
#.....# #.....#
#.....# #.....#
##...## #.....#
+.....+ +.....+
+.....+ +.....+
+.....+ +.....+
##...## #.....#
#.....# #.....#
#.....# #.....#
#.....# #.....#

View File

@ -374,6 +374,10 @@ int RunAccept()
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
int samples = 0; int samples = 0;
// --- проход гуськом --------------------------------------------------
int maxInThroat = 0; // сколько разом влезло в сам проём
bool crossed[NUM_AGENTS] = {}; // кто оказался по ту сторону стены
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем. // Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
// //
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет // Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
@ -401,6 +405,22 @@ int RunAccept()
watch.Observe(game, FIXED_DT); watch.Observe(game, FIXED_DT);
corridorWatch.Observe(game, FIXED_DT); corridorWatch.Observe(game, FIXED_DT);
// Горловина — это САМ ПРОЁМ: стена y=13..15, проём x=30..31.
// Пятеро в проём шириной два тайла лезть разом не должны, а пройти
// его обязаны все до одного.
{
int inThroat = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
if (!a.alive) continue;
if (a.pos.y > 12.8f && a.pos.y < 15.2f &&
a.pos.x > 29.6f && a.pos.x < 32.4f) ++inThroat;
if (a.pos.y < 12.8f) crossed[k] = true; // севернее стены
}
maxInThroat = std::max(maxInThroat, inThroat);
}
// Кто сейчас в створе прохода (горловина + подходы). // Кто сейчас в створе прохода (горловина + подходы).
int inside[NUM_AGENTS]; int inside[NUM_AGENTS];
int insideCount = 0; int insideCount = 0;
@ -476,15 +496,35 @@ int RunAccept()
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой. // Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
// Пороги с запасом: строгое неравенство на маленьких числах — монетка, // Пороги с запасом: строгое неравенство на маленьких числах — монетка,
// а не тест. // а не тест.
const bool column = longAvg > lateralAvg * 1.5; // Что здесь ПРАВИЛЬНО считать колонной.
//
// Первая мерка сравнивала разброс вдоль движения с разбросом поперёк — и
// была неверной насквозь. Отряд, у которого двое навсегда застряли по ту
// сторону стены, а трое стоят севернее, растянут вдоль на шесть тайлов и
// проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить
// стену и собираться, стал компактным — и проверку ронял. Мерка
// награждала ровно ту поломку, ради которой писалась (docs/08-decisions.md).
//
// Честное «гуськом» — это про сам проём: одновременно в нём не больше
// двух (проём шириной два тайла), и прошли через него ВСЕ.
int crossedCount = 0, aliveCount = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
if (!game.squad.agents[k].alive) continue;
++aliveCount;
if (crossed[k]) ++crossedCount;
}
const bool column = (maxInThroat <= 2) && (crossedCount == aliveCount);
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0); const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
const bool enough = (frontShotsInside + rearShotsInside) >= 4; const bool enough = (frontShotsInside + rearShotsInside) >= 4;
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough; const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
allPass = allPass && p6; allPass = allPass && p6;
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6, PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, " Fmt("прошли проход %d из %d, в горловине разом до %d, обходов %d, "
"в створе до %d, поперёк %.2f / вдоль %.2f тайла, "
"выстрелов вперёд передний/хвост %d/%d (назад %d), " "выстрелов вперёд передний/хвост %d/%d (назад %d), "
"сквозь своего %d, FRIENDLY_HITS=%d", "сквозь своего %d, FRIENDLY_HITS=%d",
crossedCount, aliveCount, maxInThroat, game.squad.navUses,
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside, maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
backwardShots, throughAllyShots, game.metrics.friendlyHits)); backwardShots, throughAllyShots, game.metrics.friendlyHits));
} }
@ -2036,7 +2076,24 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
return n; return n;
}(); }();
game.PlaceSquad(game.targets[size_t(victim)].pos + Vec2{3.0f, 0.0f}, true); // Ставим отряд в СВОБОДНУЮ клетку рядом с тварью и с прямой
// видимостью. Слепое «+3 по X» попадало в стену, стоило
// комнатам измениться: боец не видел цель, клинок её не брал, и
// проверка объявляла непроходимым первый этаж — хотя мерила
// собственную раскладку.
const Vec2 foePos = game.targets[size_t(victim)].pos;
Vec2 spot = foePos + Vec2{3.0f, 0.0f};
const Vec2 around[8] = {{3, 0}, {-3, 0}, {0, 3}, {0, -3},
{2, 2}, {-2, 2}, {2, -2}, {-2, -2}};
for (const Vec2& d : around)
{
const Vec2 test = foePos + d;
if (game.map.IsWallAt(test)) continue;
if (game.map.RaycastBlocked(test, foePos)) continue;
spot = test;
break;
}
game.PlaceSquad(spot, true);
game.Step(FIXED_DT); // SyncLoadout: здоровье по надетому game.Step(FIXED_DT); // SyncLoadout: здоровье по надетому
game.squad.agents[0].hp = game.squad.agents[0].maxHp; game.squad.agents[0].hp = game.squad.agents[0].maxHp;
hpStart = game.squad.agents[0].hp; hpStart = game.squad.agents[0].hp;
@ -2645,7 +2702,19 @@ int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
// этажах твари разбросаны, и отряд иногда доживает полторы минуты просто // этажах твари разбросаны, и отряд иногда доживает полторы минуты просто
// потому, что до него дошли не все. Последний выживший — шум, первая // потому, что до него дошли не все. Последний выживший — шум, первая
// стена — сигнал. // стена — сигнал.
std::printf("первая стена: одиночка D%d, трое D%d, пятеро D%d\n", wall[0], wall[1], wall[2]); // Ноль в wall[] означает «стены не нашлось ВООБЩЕ»: отряд пережил все
// проверенные глубины. Читать этот ноль как «стена на нулевом этаже» —
// ошибка мерки, а не игры: пятёрка, не сломавшаяся нигде, объявлялась худшей
// из трёх и роняла проверку. Не нашлось — значит глубже последней.
const int noWall = maxDepth + 1;
for (int si = 0; si < 3; ++si)
if (wall[si] == 0) wall[si] = noWall;
std::printf("первая стена:");
const char* names[3] = {"одиночка", "трое", "пятеро"};
for (int si = 0; si < 3; ++si)
std::printf(" %s D%d%s%s", names[si], wall[si],
wall[si] == noWall ? " (не найдена)" : "", si < 2 ? "," : "\n");
std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0], std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0],
lastAlive[1], lastAlive[2]); lastAlive[1], lastAlive[2]);
@ -2782,11 +2851,19 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
// соседняя клетка. По соседней курсор дёргается между двумя // соседняя клетка. По соседней курсор дёргается между двумя
// вариантами на каждой границе и стоит на месте; дальняя точка // вариантами на каждой границе и стоит на месте; дальняя точка
// тянет ровно, как рука игрока. // тянет ровно, как рука игрока.
// ...но НЕ ДАЛЬШЕ, ЧЕМ ВИДНО. Дальняя точка по полю может лежать за
// углом, и тогда прямая до неё идёт сквозь стену: курсор упирается
// в эту стену и стоит вплотную к спуску, до которого «две клетки по
// воздуху и шесть по полу». Берём самую дальнюю точку пути, которую
// видно, — ровно так же натягивает верёвку и сам отряд
// (Squad::NavWaypoint).
int bestDist = here; int bestDist = here;
Vec2 goal = c; Vec2 goal = c;
{ {
Vec2 hops[12];
int nHops = 0;
int wx = cx, wy = cy; int wx = cx, wy = cy;
for (int hop = 0; hop < 6; ++hop) for (int hop = 0; hop < 12; ++hop)
{ {
int nextX = wx, nextY = wy; int nextX = wx, nextY = wy;
int nextD = flow[size_t(wy * MAP_W + wx)]; int nextD = flow[size_t(wy * MAP_W + wx)];
@ -2805,8 +2882,14 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
wx = nextX; wx = nextX;
wy = nextY; wy = nextY;
bestDist = nextD; bestDist = nextD;
hops[nHops++] = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
}
if (nHops > 0)
{
goal = hops[0];
for (int h = nHops - 1; h >= 1; --h)
if (!game.map.RaycastBlocked(c, hops[h])) { goal = hops[h]; break; }
} }
goal = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
} }
// Застряли — идём вторым по качеству соседом. Отряд упирается в // Застряли — идём вторым по качеству соседом. Отряд упирается в
@ -2823,30 +2906,6 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal)); game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal));
game.Step(FIXED_DT); game.Step(FIXED_DT);
if (getenv("PLAYDBG") && (steps % 300) == 0)
{
std::printf(" DBG t=%5.1f anchor(%.2f,%.2f) here=%d goal(%.1f,%.1f) in(%.2f,%.2f)"
" state=%d\n",
double(steps) * double(FIXED_DT), double(c.x), double(c.y), here,
double(goal.x), double(goal.y), double(game.in.moveScreen.x),
double(game.in.moveScreen.y), int(game.squad.state));
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
if (!a.present) continue;
std::printf(" #%d pos(%.2f,%.2f) post(%.2f,%.2f) lane=%d tgt=%d melee=%d\n",
k, double(a.pos.x), double(a.pos.y), double(a.postPos.x),
double(a.postPos.y), int(a.hasLane), a.currentTargetId,
int(a.IsMelee()));
}
for (int yy = int(c.y) - 2; yy <= int(c.y) + 2; ++yy)
{
std::printf(" MAP y=%2d ", yy);
for (int xx = int(c.x) - 6; xx <= int(c.x) + 6; ++xx)
std::printf("%c", game.map.IsWall(xx, yy) ? '#' : '.');
std::printf("\n");
}
}
if (game.atExit) if (game.atExit)
{ {
@ -2871,6 +2930,32 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see
} }
const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП"); const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП");
// Затык — самая дорогая поломка в игре и самая трудная в чтении: снаружи
// видно только «отряд не дошёл». Поэтому зонд обязан сказать, КТО встал
// и где, иначе следующий раз опять начнётся с ручной диагностики.
if (!reached && game.AliveAgents() > 0)
{
const Vec2 c = game.squad.anchor;
const int cx = int(c.x), cy = int(c.y);
const int fld = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
? flow[size_t(cy * MAP_W + cx)] : -1;
std::printf(" затык: курсор %.1f,%.1f (до спуска по полу %d кл.), "
"спуск %.1f,%.1f, состояние %d\n",
double(c.x), double(c.y), fld,
double(game.level.exitPos.x), double(game.level.exitPos.y),
int(game.squad.state));
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present || !a.alive) continue;
std::printf(" боец %d: %.1f,%.1f скорость %.2f отстал на %.1f%s\n", i,
double(a.pos.x), double(a.pos.y), double(Length(a.vel)),
double(Distance(a.pos, game.squad.SlotWorld(i))),
a.Grabbed() ? " (захвачен)" : "");
}
}
std::printf("%-6d %7d %7d %8d %7.0f%% %9d %7.0f %s (%.0f с)\n", game.depth, startMonsters, std::printf("%-6d %7d %7d %8d %7.0f%% %9d %7.0f %s (%.0f с)\n", game.depth, startMonsters,
startMonsters - aliveNow, game.AliveAgents(), double(100.0f * hp / std::max(1.0f, maxHp)), startMonsters - aliveNow, game.AliveAgents(), double(100.0f * hp / std::max(1.0f, maxHp)),
ammo, double(game.squad.lightCharge), verdict, double(steps) * double(FIXED_DT)); ammo, double(game.squad.lightCharge), verdict, double(steps) * double(FIXED_DT));

View File

@ -42,6 +42,13 @@ struct Agent
float commitTimer = 0.0f; // блокировка перерешивания float commitTimer = 0.0f; // блокировка перерешивания
float solveTimer = 0.0f; // стаггер решателя float solveTimer = 0.0f; // стаггер решателя
// Сколько боец УЖЕ УПИРАЕТСЯ: хочет идти, а не едет. По этому счётчику
// включается поле путей (Squad::NavWaypoint) — только для того, кто реально
// застрял, и ни для кого больше. Разбирать «видно цель или нет» тут нельзя:
// на подходе к проходу цель не видна вообще никому, и отряд входил бы в
// горловину шеренгой.
float stuckFor = 0.0f;
// Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда // Сущности ECS: сам боец и его оружие. Статы оружия копируются отсюда
// в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще. // в gun/melee каждый шаг (SyncLoadout), поэтому ai/ про ECS не знает вообще.
ecs::Entity self = ecs::NO_ENTITY; ecs::Entity self = ecs::NO_ENTITY;

View File

@ -22,6 +22,10 @@ const float kLine[NUM_AGENTS][2] = {
{0.0f, 2.0f}, {0.0f, 2.0f},
}; };
// «Недостижимо» в поле путей и потолок длины натягиваемой верёвки.
constexpr uint16_t NAV_FAR = 0xFFFF;
constexpr int NAV_PATH_MAX = 64;
} // namespace } // namespace
const char* FormationName(Formation f) const char* FormationName(Formation f)
@ -142,6 +146,22 @@ Vec2 Squad::SlotWorld(int i) const
Vec2 Squad::GoalFor(int i) const Vec2 Squad::GoalFor(int i) const
{ {
// ОТСТАВШИЙ ВОЗВРАЩАЕТСЯ В СТРОЙ, что бы ни говорил решатель.
//
// Без этого правила отряд намертво вставал, и вот почему. Якорь упирается в
// поводок до центра отряда -> отряд переходит в HOLDING -> отставшие в
// HOLDING держат ОГНЕВУЮ ПОЗИЦИЮ вместо возврата в слот -> центр не
// догоняет -> якорь не может идти дальше. Круг замкнулся, и снаружи это
// выглядело как «пятеро не проходят там, где одиночка проходит».
//
// Проверено отдельно: ширина коридоров тут ни при чём — отряд вставал и на
// проходах в три, четыре и пять клеток, и даже в пустых комнатах.
//
// Правило заодно честное по игре: отряд, растянувшийся на полэтажа, — это
// не строй, а цепочка одиночек, и собраться он обязан сам.
const float lag = Distance(agents[i].pos, SlotWorld(i));
if (lag > g_tune.straggleDist) return SlotWorld(i);
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель // Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы // внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
// полезен только на стоянке — а он нужен как раз в движении. // полезен только на стоянке — а он нужен как раз в движении.
@ -153,6 +173,105 @@ Vec2 Squad::GoalFor(int i) const
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos; return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
} }
// -----------------------------------------------------------------------------
// Поле путей: BFS от клетки цели по проходимым клеткам, четыре соседа.
// Пересчёт только когда цель сменила клетку — это несколько тысяч клеток и
// стоит меньше одного кадра рендера.
// -----------------------------------------------------------------------------
void Squad::RebuildNav(NavField& field, const Tilemap& map, Vec2 goal) const
{
int gx = int(std::floor(goal.x));
int gy = int(std::floor(goal.y));
// Якорь игрок может завести в стену — поле строим от ближайшей свободной
// клетки, иначе отряд остался бы вообще без путей.
if (!Tilemap::InBounds(gx, gy) || map.IsWall(gx, gy))
{
bool found = false;
for (int r = 1; r <= 5 && !found; ++r)
for (int dy = -r; dy <= r && !found; ++dy)
for (int dx = -r; dx <= r && !found; ++dx)
{
const int x = gx + dx, y = gy + dy;
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
gx = x; gy = y; found = true;
}
if (!found) return;
}
if (gx == field.goalX && gy == field.goalY && !field.cost.empty()) return;
field.goalX = gx;
field.goalY = gy;
field.cost.assign(size_t(MAP_W) * MAP_H, NAV_FAR);
std::vector<int> queue;
queue.reserve(size_t(MAP_W) * MAP_H);
field.cost[size_t(gy) * MAP_W + gx] = 0;
queue.push_back(gy * MAP_W + gx);
static const int DX[4] = {1, -1, 0, 0};
static const int DY[4] = {0, 0, 1, -1};
for (size_t head = 0; head < queue.size(); ++head)
{
const int c = queue[head];
const int cx = c % MAP_W;
const int cy = c / MAP_W;
const uint16_t nd = uint16_t(field.cost[size_t(c)] + 1);
for (int k = 0; k < 4; ++k)
{
const int x = cx + DX[k], y = cy + DY[k];
if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue;
const size_t n = size_t(y) * MAP_W + x;
if (field.cost[n] <= nd) continue;
field.cost[n] = nd;
queue.push_back(int(n));
}
}
}
Vec2 Squad::NavWaypoint(const NavField& field, const Tilemap& map, Vec2 from, Vec2 goal) const
{
// Видно цель — идём в цель. Поле нужно ровно там, где прямой не хватает.
if (field.cost.empty() || !map.RaycastBlocked(from, goal)) return goal;
int cx = int(std::floor(from.x));
int cy = int(std::floor(from.y));
if (!Tilemap::InBounds(cx, cy)) return goal;
uint16_t here = field.cost[size_t(cy) * MAP_W + cx];
if (here == NAV_FAR || here == 0) return goal; // вне поля или уже пришли
// Спуск по градиенту. Длина ограничена: дальше горизонта верёвка всё равно
// не натягивается, а лишние клетки — лишняя работа на каждого бойца.
Vec2 path[NAV_PATH_MAX];
int n = 0;
static const int DX[4] = {1, -1, 0, 0};
static const int DY[4] = {0, 0, 1, -1};
while (n < NAV_PATH_MAX && here > 0)
{
int bx = -1, by = -1;
uint16_t best = here;
for (int k = 0; k < 4; ++k)
{
const int x = cx + DX[k], y = cy + DY[k];
if (!Tilemap::InBounds(x, y)) continue;
const uint16_t d = field.cost[size_t(y) * MAP_W + x];
if (d >= best) continue;
best = d; bx = x; by = y;
}
if (bx < 0) break;
cx = bx; cy = by; here = best;
path[n++] = Vec2{float(cx) + 0.5f, float(cy) + 0.5f};
}
if (n == 0) return goal;
// Натягиваем верёвку: самая дальняя точка пути, которую боец ещё видит.
for (int i = n - 1; i >= 1; --i)
if (!map.RaycastBlocked(from, path[i])) return path[i];
return path[0];
}
Vec2 Squad::Centroid() const Vec2 Squad::Centroid() const
{ {
// Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд // Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд
@ -318,6 +437,7 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения. // Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
const float baseSpeed = MoveSpeed(); const float baseSpeed = MoveSpeed();
for (int i = 0; i < NUM_AGENTS; ++i) for (int i = 0; i < NUM_AGENTS; ++i)
{ {
Agent& a = agents[i]; Agent& a = agents[i];
@ -328,9 +448,47 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
// из чужой линии. В этом вся цена захвата — боец выпал из геометрии // из чужой линии. В этом вся цена захвата — боец выпал из геометрии
// отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт. // отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт.
// Оружие при этом работает: захват держит ноги, а не руки. // Оружие при этом работает: захват держит ноги, а не руки.
if (a.Grabbed()) { a.vel = {}; continue; } if (a.Grabbed()) { a.vel = {}; a.stuckFor = 0.0f; continue; }
const Vec2 rawGoal = GoalFor(i);
// Упёрся — веди по геометрии. Боец хочет идти (цель не под ногами), но
// скорость почти нулевая: значит прямая упирается в стену, и обход
// стены толкает обратно. Именно так отставший за углом замирал навсегда.
//
// Счётчик сбрасывается НЕ по факту «поехал», а по факту «цель видно».
// Разница принципиальная: сброс по скорости давал дребезг — обход
// трогал бойца с места, поле тут же выключалось, боец упирался в ту же
// стену, и так по кругу со средней скоростью в шаг за две секунды.
// Пока цель за стеной, режим обхода ДЕРЖИТСЯ, и боец доходит по дуге.
const bool wantsMove = DistanceSq(a.pos, rawGoal) > g_tune.navGoalReached *
g_tune.navGoalReached;
if (!wantsMove || !map.RaycastBlocked(a.pos, rawGoal))
a.stuckFor = 0.0f;
else if (Length(a.vel) < g_tune.navStuckSpeed * baseSpeed)
a.stuckFor += dt;
// ЧТО именно держит бойца — вот вся развилка. Стена держит навсегда, и
// из-под неё надо выводить по геометрии. Спина соседа держит на секунду,
// и обходить её нельзя: в проходе очередь стоит именно так, и отряд,
// отправленный в обход своей же колонны, входит в горловину шеренгой
// (критерий 11.6 ловит это сразу).
//
// Пробовал развилку по расстоянию до цели — это угадайка: у очереди в
// проходе и у отставшего одиночки дистанции пересекаются, и любой порог
// ломал либо строй, либо проходимость.
// Кому обход положен: тому, кто УПЁРСЯ и при этом ПОТЕРЯЛ СТРОЙ.
// Второе условие важно не меньше первого — боец, стоящий на своём месте,
// обходить ничего не должен: он не потерялся, его держит бой.
Vec2 goal = rawGoal;
if (a.stuckFor > g_tune.navStuckTime &&
Distance(a.pos, SlotWorld(i)) > g_tune.straggleDist)
{
++navUses;
RebuildNav(nav[i], map, rawGoal);
goal = NavWaypoint(nav[i], map, a.pos, rawGoal);
}
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE; const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
// --- рывок клинка -------------------------------------------------- // --- рывок клинка --------------------------------------------------

View File

@ -123,8 +123,45 @@ struct Squad
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init. // Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init.
int pistolCount = DEFAULT_PISTOL_COUNT; int pistolCount = DEFAULT_PISTOL_COUNT;
// -------------------------------------------------------------------------
// ПОЛЕ ПУТЕЙ. Отряд ходит по геометрии уровня, а не по прямой линии.
//
// Без него отставший за углом не возвращался в строй НИКОГДА: seek тянул
// его в стену, обход стены толкал обратно, и боец замирал в углу навсегда.
// Снаружи это выглядело как «пятеро не проходят там, где одиночка проходит»
// — и никакая ширина коридоров этого не лечила, потому что дело было не в
// ширине.
//
// Устройство простое: BFS по проходимым клеткам от клетки цели. Боец,
// которому цель не видна, спускается по градиенту и берёт самую дальнюю
// точку пути, которую ещё видит, — это натянутая верёвка, и идёт он по ней
// как по прямой.
//
// Поле У КАЖДОГО СВОЁ, и это не расточительство. Одно общее поле до якоря
// сводило всех обходящих в одну точку: в горловине прохода отряд входил
// лужей вместо колонны, и критерий 11.6 падал. Боец идёт к СВОЕЙ цели —
// тогда строй сохраняется и в обходе.
//
// Платим за это только когда обход реально включён: поле пересчитывается
// на смену клетки цели и только у того, кто сейчас идёт в обход, а таких
// обычно ноль.
struct NavField
{
std::vector<uint16_t> cost;
int goalX = -1;
int goalY = -1;
};
NavField nav[NUM_AGENTS];
// Сколько раз за прогон включался обход. Чистая диагностика: по ней видно,
// работает поле путей или молчит, — без неё пришлось бы гадать.
int navUses = 0;
void Init(ecs::World& world, Vec2 at); void Init(ecs::World& world, Vec2 at);
void RebuildNav(NavField& field, const Tilemap& map, Vec2 goal) const;
Vec2 NavWaypoint(const NavField& field, const Tilemap& map, Vec2 from, Vec2 goal) const;
Vec2 SlotLocal(int i) const; // локальные координаты слота (x = вперёд) Vec2 SlotLocal(int i) const; // локальные координаты слота (x = вперёд)
Vec2 SlotWorld(int i) const; // слот в мире Vec2 SlotWorld(int i) const; // слот в мире
Vec2 GoalFor(int i) const; // куда агент реально идёт Vec2 GoalFor(int i) const; // куда агент реально идёт

View File

@ -84,6 +84,17 @@ struct Tuning
// Как быстро строй доворачивается к направлению хода. Мгновенный доворот // Как быстро строй доворачивается к направлению хода. Мгновенный доворот
// заставлял бойцов бегать вбок на каждом повороте (sim/squad.cpp). // заставлял бойцов бегать вбок на каждом повороте (sim/squad.cpp).
float formTurnRate = 7.0f; float formTurnRate = 7.0f;
// Насколько боец может отстать от своего слота, прежде чем бросит всё и
// вернётся в строй. Ниже 2 отряд перестаёт разбредаться по огневым
// позициям вообще, выше 4 — снова застревает цепочкой (sim/squad.cpp).
float straggleDist = 2.8f;
// Когда включается поле путей. Не по расстоянию до цели, а по факту упора:
// боец хочет идти и не едет. Расстоянием этот порог не задать — на подходе
// к проходу цель не видна никому, и по «не видно» все пятеро получили бы
// одну путевую точку и вошли бы в горловину шеренгой (критерий 11.6).
float navStuckTime = 0.35f; // секунд упора до обхода
float navStuckSpeed = 0.25f; // «не едет» — это доля от скорости хода
float navGoalReached = 0.35f; // ближе этого цель считается достигнутой
float wSeparation = 1.0f; float wSeparation = 1.0f;
float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7) float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7)
float wWallAvoid = 1.2f; float wWallAvoid = 1.2f;
@ -455,6 +466,10 @@ inline const TunableParam TUNABLES[] = {
{"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f}, {"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f},
{"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f}, {"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f},
{"formTurnRate", &Tuning::formTurnRate, 0.5f, 0.5f, 30.0f}, {"formTurnRate", &Tuning::formTurnRate, 0.5f, 0.5f, 30.0f},
{"straggleDist", &Tuning::straggleDist, 0.2f, 1.0f, 12.0f},
{"navStuckTime", &Tuning::navStuckTime, 0.05f, 0.05f, 3.0f},
{"navStuckSpeed", &Tuning::navStuckSpeed, 0.05f, 0.02f, 1.0f},
{"navGoalReached", &Tuning::navGoalReached, 0.05f, 0.05f, 2.0f},
{"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f}, {"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f},
{"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f}, {"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f},
{"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f}, {"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f},

View File

@ -151,8 +151,11 @@ constexpr int SQ_HINT_Y = 250, SQ_HINT_STEP = 10;
// Схрон: то, что лежит на базе между вылазками. Двумя рядами во всю ширину // Схрон: то, что лежит на базе между вылазками. Двумя рядами во всю ширину
// внизу, а не колонкой сбоку: в схроне десятки предметов, и подпись каждого // внизу, а не колонкой сбоку: в схроне десятки предметов, и подпись каждого
// должна читаться — иначе выбор снаряжения превращается в перебор иконок. // должна читаться — иначе выбор снаряжения превращается в перебор иконок.
constexpr int SQ_STASH_X = 22, SQ_STASH_Y = 208, SQ_STASH_W = 52, SQ_STASH_H = 18; // Ячейка схрона шире, а колонок меньше: в 52 пикселя не влезало даже слово
constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 8, SQ_STASH_ROWS = 2; // BATTERY, и половина имён обрезалась на середине. Читаемость важнее, чем ещё
// две ячейки в ряду: прокрутка есть, а угадывать вещь по обрубку — нет.
constexpr int SQ_STASH_X = 16, SQ_STASH_Y = 206, SQ_STASH_W = 74, SQ_STASH_H = 18;
constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 6, SQ_STASH_ROWS = 2;
constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS; constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS;
// Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка // Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка
@ -385,9 +388,16 @@ void DrawTip(const Layout& l, const Tip& t, int mx, int my)
int wide = 0; int wide = 0;
for (int i = 0; i < t.count; ++i) wide = std::max(wide, TextW(l, t.lines[i], 8)); for (int i = 0; i < t.count; ++i) wide = std::max(wide, TextW(l, t.lines[i], 8));
UiRect box{mx + 8, my + 8, wide + pad * 2, t.count * step + pad * 2 - 2}; // Подсказка встаёт ВЫШЕ курсора, а не ниже.
// Подсказка не должна вылезать за кадр: у края экрана она переезжает на //
// другую сторону курсора, а не обрезается. // Ниже она ложилась ровно на строки характеристик — те самые, которые
// объясняет: игрок наводил на броню, чтобы понять, откуда 28%, и подсказка
// закрывала строку ARMOR. Сверху под ней только пустое место.
UiRect box{mx + 8, my - 6 - (t.count * step + pad * 2 - 2), wide + pad * 2,
t.count * step + pad * 2 - 2};
// И не должна вылезать за кадр: у края экрана она переезжает на другую
// сторону курсора, а не обрезается.
if (box.x + box.w > INTERNAL_W - 2) box.x = mx - 8 - box.w; if (box.x + box.w > INTERNAL_W - 2) box.x = mx - 8 - box.w;
if (box.y + box.h > INTERNAL_H - 2) box.y = INTERNAL_H - 2 - box.h; if (box.y + box.h > INTERNAL_H - 2) box.y = INTERNAL_H - 2 - box.h;
if (box.x < 2) box.x = 2; if (box.x < 2) box.x = 2;
@ -545,11 +555,13 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out)
} }
// Сумка ОБЩАЯ: она не зависит от выбранного бойца, и переложить // Сумка ОБЩАЯ: она не зависит от выбранного бойца, и переложить
// из неё может любой (sim/items.h). // из неё может любой (sim/items.h). Вне вылазки она недоступна:
// сумка принадлежит забегу и очищается при сборке этажа.
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{ {
Widget& w = out.Add(UiPart::BAG, s, BagRect(s)); Widget& w = out.Add(UiPart::BAG, s, BagRect(s));
w.item = game.squad.bag.slots[s]; w.item = game.squad.bag.slots[s];
w.enabled = ui.started;
} }
// Строки характеристик тоже наводимы. Читать «SPEED 95%» и гадать, // Строки характеристик тоже наводимы. Читать «SPEED 95%» и гадать,
@ -1364,7 +1376,10 @@ void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widg
} }
} }
Text(l, "BAG", SQ_BAG_X, SQ_BAG_Y - 14, 10, COL_TEXT); // Подпись честно говорит, почему сумка не берёт вещи вне забега. Молчащий
// отказ читается как поломка — а это правило: сумка принадлежит вылазке.
if (ui.started) Text(l, "BAG", SQ_BAG_X, SQ_BAG_Y - 14, 10, COL_TEXT);
else Text(l, "BAG - RAID ONLY", SQ_BAG_X, SQ_BAG_Y - 14, 8, COL_DEAD);
if (game.profile) if (game.profile)
{ {