Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке. Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление тварей и живёт полторы минуты без управления. Первая стена: одиночка D7, трое D30, пятеро D30 - петля работает. И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в том числе незанятые. Строка писалась, когда отряд всегда был впятером; после появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже, 100 -> 41" был боем впятером. ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом проигрывает двум бредущим: 210 здоровья против ножа это девять секунд работы под ответным уроном. Первые этажи населены личинками (55 здоровья, 7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый. Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у одного и того же. ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка. Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь. Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары. Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это его удар, просто по летящей цели. Проверка меряет размен целиком, включая обратную половину правила: "стоя столбом не отбивает". Без неё условие про соприкосновение существовало бы только в комментарии. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ef103050b1
commit
e3294cffd4
@ -111,6 +111,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL |
|
| `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL |
|
||||||
| `--ui-check` | пункты меню без окна: каждый ведёт куда-то, выходит только QUIT |
|
| `--ui-check` | пункты меню без окна: каждый ведёт куда-то, выходит только QUIT |
|
||||||
|
| `--wall N` | до какой глубины доходит отряд из 1/3/5 бойцов (нужен `--rooms`) |
|
||||||
| `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с |
|
| `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с |
|
||||||
| `--probe` | к `--headless`: подробности по каждому агенту на стоянках |
|
| `--probe` | к `--headless`: подробности по каждому агенту на стоянках |
|
||||||
| `--no-solver` | отключить решатель огневых позиций (A/B сравнение) |
|
| `--no-solver` | отключить решатель огневых позиций (A/B сравнение) |
|
||||||
|
|||||||
@ -148,3 +148,32 @@ squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
|
|||||||
|
|
||||||
`sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера
|
`sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера
|
||||||
раздельно: 2 мкс против 1.4–3.5 мс при бюджете 16.6 мс.
|
раздельно: 2 мкс против 1.4–3.5 мс при бюджете 16.6 мс.
|
||||||
|
|
||||||
|
|
||||||
|
## `--wall`: где обрывается забег
|
||||||
|
|
||||||
|
```bash
|
||||||
|
squad_proto.exe --rooms --wall 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Отвечает на вопрос, который появился вместе с чекпоинтами через пятьдесят
|
||||||
|
этажей: **растёт ли достижимая глубина от вылазки к вылазке**. Отряд из одного,
|
||||||
|
трёх и пяти бойцов, одетый по глубине, ставится в самое скопление тварей и живёт
|
||||||
|
там полторы минуты без управления.
|
||||||
|
|
||||||
|
```
|
||||||
|
первая стена: одиночка D7, трое D30, пятеро D30
|
||||||
|
```
|
||||||
|
|
||||||
|
Это НИЖНЯЯ оценка: игрок уводит бойцов, выбирает цели и отступает — проверка не
|
||||||
|
умеет ничего из этого. Поэтому «стена» здесь не конец игры, а глубина, с которой
|
||||||
|
игра начинает требовать умения.
|
||||||
|
|
||||||
|
Смотреть надо на первую стену, а не на «глубже всего дожили»: на глубоких этажах
|
||||||
|
твари разбросаны, и отряд иногда доживает просто потому, что до него дошли не все.
|
||||||
|
|
||||||
|
Этот же прогон нашёл настоящую ошибку: `PlaceSquad` воскрешал ВСЕ пять слотов, в
|
||||||
|
том числе незанятые. Строка писалась, когда отряд всегда был впятером, и после
|
||||||
|
появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями.
|
||||||
|
Из-за них прежний замер «одиночка на первом этаже» показывал уверенную победу —
|
||||||
|
это был бой впятером.
|
||||||
|
|||||||
@ -48,6 +48,8 @@
|
|||||||
|
|
||||||
| **M29** | правки по игре: первый этаж вдвое тише и светлее (бюджет 2.2, бегуны с 4-го, своя яркость этажа), опыт платится за убийства сразу, вайп оставляет ~половину добычи с поправкой на редкость, аномалия возврата переехала к спуску, чекпоинты через 50 этажей, оперативники НАХОДЯТСЯ в капсулах вместо покупки за опыт | замер на сотне вайпов: доезжает 52% (обычное 69%, реликты 25%) — первая версия проверки считала одну сумку и получила «обычного доехало 112%», потому что спасение забирает ещё и надетое; опыт 558 -> 578 за убийство без эвакуации; капсула поднимает отряд до двух и даёт наёмнику свои числа; `--accept` PASS ([17-extraction.md](17-extraction.md)) |
|
| **M29** | правки по игре: первый этаж вдвое тише и светлее (бюджет 2.2, бегуны с 4-го, своя яркость этажа), опыт платится за убийства сразу, вайп оставляет ~половину добычи с поправкой на редкость, аномалия возврата переехала к спуску, чекпоинты через 50 этажей, оперативники НАХОДЯТСЯ в капсулах вместо покупки за опыт | замер на сотне вайпов: доезжает 52% (обычное 69%, реликты 25%) — первая версия проверки считала одну сумку и получила «обычного доехало 112%», потому что спасение забирает ещё и надетое; опыт 558 -> 578 за убийство без эвакуации; капсула поднимает отряд до двух и даёт наёмнику свои числа; `--accept` PASS ([17-extraction.md](17-extraction.md)) |
|
||||||
|
|
||||||
|
| **M30** | личинка для первых этажей (бредущий уехал на третий), парирование клинком с ответным ударом и отбиванием плевков, инструмент `--wall` | `--wall` нашёл, что `PlaceSquad` воскрешал незанятые слоты: прежний замер «одиночка выжил на первом этаже» был боем впятером, а на самом деле одиночка с ножом проигрывал двум бредущим (210 здоровья против ножа); стена растёт с силой отряда — D7 у одиночки против D30 у пятерых; парирование проверено разменом целиком: нож 34% против тесака 8%, отбив снимает урон, ответ проходит, откат не сбрасывается, стоя столбом не отбивает; `--accept` PASS ([11-melee.md](11-melee.md)) |
|
||||||
|
|
||||||
## Что находилось по ходу и чинилось
|
## Что находилось по ходу и чинилось
|
||||||
|
|
||||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||||
|
|||||||
@ -220,3 +220,47 @@ spin 15` — финиш доходит реже, и это видно в циф
|
|||||||
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
|
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
|
||||||
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
|
скорость и ускорение рывка, множители скорости на замахе и восстановлении,
|
||||||
радиус своего круга.
|
радиус своего круга.
|
||||||
|
|
||||||
|
|
||||||
|
## Парирование
|
||||||
|
|
||||||
|
Клинок умеет отбивать. Шанс — отдельная характеристика оружия (`BladeDef::parry`),
|
||||||
|
и **чем оружие легче, тем он выше**: нож 34%, сабля 20%, тесак 8%. Тесаком не
|
||||||
|
защищаются, им бьют первым.
|
||||||
|
|
||||||
|
Характеристика именно отдельная, а не выведенная из веса: её двигают аффиксы
|
||||||
|
(`PARRY`), и «лёгкий тесак» обязан быть возможен как находка, а не запрещён
|
||||||
|
формулой.
|
||||||
|
|
||||||
|
**Отбить можно, только если удары соприкоснулись.** Боец должен сам быть в замахе
|
||||||
|
или в ударе (`MeleeWeapon::Committed()`). Парирование «просто так» превратило бы
|
||||||
|
клинок в щит, который работает, пока стоишь, — и ближний бой перестал бы быть
|
||||||
|
разменом.
|
||||||
|
|
||||||
|
Удавшийся отбив:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| урон бойцу | не проходит |
|
||||||
|
| тварь | получает удар бойца целиком, со стихиями и пробитием |
|
||||||
|
| боец | бьёт ЗАНОВО с начала фазы удара |
|
||||||
|
| откат | **не сбрасывается** |
|
||||||
|
|
||||||
|
Последняя строка — то, ради чего всё и делалось. Сбрасывай отбив откат, это была
|
||||||
|
бы бесплатная атака, и выгоднее стало бы подставляться под удары. Не сбрасывая,
|
||||||
|
парирование даёт ровно один лишний удар в текущем размене.
|
||||||
|
|
||||||
|
**Пуля тоже отбивается.** Плевок при удачном парировании летит назад и несёт урон
|
||||||
|
БОЙЦА, а не твари: это его удар, просто по летящей цели. Отражённый снаряд
|
||||||
|
становится дружественным, поэтому дальше по коду работает как обычный выстрел и
|
||||||
|
в своих не попадёт.
|
||||||
|
|
||||||
|
Проверяется в `--descend` — размен целиком, а не факт срабатывания:
|
||||||
|
|
||||||
|
```
|
||||||
|
парирование: нож 34%, тесак 8%; отбив снял урон, ответ прошёл,
|
||||||
|
откат не сброшен, стоя столбом не отбивает
|
||||||
|
```
|
||||||
|
|
||||||
|
Последняя половина строки важна не меньше первой: без неё правило «удары должны
|
||||||
|
соприкоснуться» существовало бы только в комментарии.
|
||||||
|
|||||||
@ -152,8 +152,15 @@ entity plate class=loot tier=3 depth=9 radius=0.30 color=140,1
|
|||||||
|
|
||||||
## Первый этаж обязан быть скучным
|
## Первый этаж обязан быть скучным
|
||||||
|
|
||||||
Бюджет опасности начинается с 2.2 — ровно на двух бредущих, и бегуны подключаются
|
Бюджет опасности начинается с 2.2, и на первых этажах его тратят **личинки** —
|
||||||
только с четвёртого этажа. До этого было 3.0 и бегуны с первого: одинокий
|
55 здоровья, 7 урона, медленные. Бредущий (210 здоровья) выходит с третьего
|
||||||
|
этажа, бегун с четвёртого.
|
||||||
|
|
||||||
|
Личинка заведена не ради разнообразия. `--wall` показал, что одиночка с ножом
|
||||||
|
проигрывает ДВУМ бредущим, даже когда их всего двое: 210 здоровья против ножа —
|
||||||
|
это девять секунд работы под ответным уроном. Новый ВИД, а не ослабленный
|
||||||
|
бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у
|
||||||
|
одного и того же. До этого было 3.0 и бегуны с первого: одинокий
|
||||||
оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно»,
|
оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно»,
|
||||||
это «непроходимо».
|
это «непроходимо».
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,8 @@
|
|||||||
# дальше подключаются плевки, громилы и хваты. Верхняя граница у мелочи есть
|
# дальше подключаются плевки, громилы и хваты. Верхняя граница у мелочи есть
|
||||||
# намеренно: на двадцатом этаже одиночный бредущий занимал бы место, которое
|
# намеренно: на двадцатом этаже одиночный бредущий занимал бы место, которое
|
||||||
# должно достаться чему-то, чего игрок ещё не видел.
|
# должно достаться чему-то, чего игрок ещё не видел.
|
||||||
entity shambler class=monster depth=1 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
|
entity larva class=monster depth=1..4 danger=0.7 radius=0.28 color=132,120,78 sprite=shambler_walk name=LARVA
|
||||||
|
entity shambler class=monster depth=3 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
|
||||||
entity rusher class=monster depth=4 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
|
entity rusher class=monster depth=4 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
|
||||||
entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,132,72 sprite=spitter_walk name=SPITTER
|
entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,132,72 sprite=spitter_walk name=SPITTER
|
||||||
entity lurker class=monster depth=5 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
|
entity lurker class=monster depth=5 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
# --- твари -------------------------------------------------------------------
|
# --- твари -------------------------------------------------------------------
|
||||||
entity rusher class=monster depth=1 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
|
entity rusher class=monster depth=1 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
|
||||||
entity shambler class=monster depth=1 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
|
entity larva class=monster depth=1..4 danger=0.7 radius=0.28 color=132,120,78 sprite=shambler_walk name=LARVA
|
||||||
|
entity shambler class=monster depth=3 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
|
||||||
entity lurker class=monster depth=2 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
|
entity lurker class=monster depth=2 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
|
||||||
entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,132,72 sprite=spitter_walk name=SPITTER
|
entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,132,72 sprite=spitter_walk name=SPITTER
|
||||||
entity brute class=monster depth=4 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE
|
entity brute class=monster depth=4 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE
|
||||||
|
|||||||
@ -9,7 +9,9 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "game.h"
|
#include "game.h"
|
||||||
|
#include "sim/affix.h"
|
||||||
#include "sim/craft.h"
|
#include "sim/craft.h"
|
||||||
|
#include "sim/enemy.h"
|
||||||
#include "sim/loot.h"
|
#include "sim/loot.h"
|
||||||
#include "sim/trigger.h"
|
#include "sim/trigger.h"
|
||||||
#include "ui/menu.h"
|
#include "ui/menu.h"
|
||||||
@ -1559,6 +1561,123 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Парирование. Проверяется РАЗМЕН, а не факт: удался отбив — урона нет,
|
||||||
|
// тварь получила ответ, и боец ударил заново, не сбросив откат.
|
||||||
|
//
|
||||||
|
// Условие «удары соприкоснулись» проверяется отдельно и с двух сторон:
|
||||||
|
// в замахе отбить МОЖНО, стоя столбом — НЕЛЬЗЯ. Без второй половины клинок
|
||||||
|
// превратился бы в щит, который работает, пока стоишь.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
Agent& a = game.squad.agents[0];
|
||||||
|
Inventory* inv = game.world.Get<Inventory>(a.self);
|
||||||
|
if (!inv)
|
||||||
|
{
|
||||||
|
std::printf("\n [FAIL] у бойца нет инвентаря — парирование не проверить\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const Inventory savedInv = *inv;
|
||||||
|
|
||||||
|
// Нож: самое лёгкое оружие, у него шанс отбива выше всех.
|
||||||
|
EquipWeapon(game.world, a, ItemId::KNIFE);
|
||||||
|
inv->weapon = ItemInstance(ItemId::KNIFE);
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
const float knifeParry = a.melee.parry;
|
||||||
|
|
||||||
|
EquipWeapon(game.world, a, ItemId::CLEAVER);
|
||||||
|
inv->weapon = ItemInstance(ItemId::CLEAVER);
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
const float cleaverParry = a.melee.parry;
|
||||||
|
|
||||||
|
// Возвращаем нож и ставим гарантированный отбив: проверяется
|
||||||
|
// ПРОВОДКА размена, а не везение.
|
||||||
|
EquipWeapon(game.world, a, ItemId::KNIFE);
|
||||||
|
inv->weapon = ItemInstance(ItemId::KNIFE);
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
|
||||||
|
ClearField(game);
|
||||||
|
int foeIndex = -1;
|
||||||
|
for (int t = 0; t < int(game.targets.size()); ++t)
|
||||||
|
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
|
||||||
|
{
|
||||||
|
foeIndex = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parried = false, riposted = false, cooldownKept = false, standBlocked = true;
|
||||||
|
if (foeIndex >= 0)
|
||||||
|
{
|
||||||
|
Target& foe = game.targets[size_t(foeIndex)];
|
||||||
|
foe.alive = true;
|
||||||
|
foe.respawnTimer = 0.0f;
|
||||||
|
foe.hp = 100000.0f;
|
||||||
|
foe.pos = a.pos + Vec2{0.8f, 0.0f};
|
||||||
|
|
||||||
|
a.hp = a.maxHp;
|
||||||
|
a.melee.parry = 1.0f; // отбив гарантирован
|
||||||
|
a.melee.state = MeleeState::STRIKE; // удары «соприкоснулись»
|
||||||
|
a.melee.phase = a.melee.strike;
|
||||||
|
a.melee.cooldown = 0.42f;
|
||||||
|
|
||||||
|
const float hpBefore = a.hp;
|
||||||
|
const float foeBefore = foe.hp;
|
||||||
|
const float cdBefore = a.melee.cooldown;
|
||||||
|
|
||||||
|
// Бьём по бойцу напрямую тем же путём, каким бьёт тварь.
|
||||||
|
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
|
||||||
|
|
||||||
|
parried = a.hp >= hpBefore;
|
||||||
|
riposted = foe.hp < foeBefore;
|
||||||
|
cooldownKept = std::fabs(a.melee.cooldown - cdBefore) < 0.001f;
|
||||||
|
|
||||||
|
// А теперь то же самое, но боец НЕ в ударе.
|
||||||
|
a.melee.state = MeleeState::IDLE;
|
||||||
|
a.melee.phase = 0.0f;
|
||||||
|
const float hpIdle = a.hp;
|
||||||
|
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
|
||||||
|
standBlocked = a.hp < hpIdle; // урон обязан пройти
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\nпарирование: нож %.0f%%, тесак %.0f%%; отбив %s, ответ %s,"
|
||||||
|
" откат %s, стоя столбом %s\n",
|
||||||
|
double(knifeParry * 100.0f), double(cleaverParry * 100.0f),
|
||||||
|
parried ? "снял урон" : "НЕ СРАБОТАЛ",
|
||||||
|
riposted ? "прошёл" : "НЕ ПРОШЁЛ",
|
||||||
|
cooldownKept ? "не сброшен" : "СБРОШЕН",
|
||||||
|
standBlocked ? "не отбивает" : "ОТБИВАЕТ");
|
||||||
|
|
||||||
|
if (knifeParry <= cleaverParry)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] тесак парирует не хуже ножа — вес ни на что не влияет\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (!parried || !riposted)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] размен не состоялся: урон %s, ответ %s\n",
|
||||||
|
parried ? "снят" : "прошёл", riposted ? "есть" : "нет");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (!cooldownKept)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] повторный удар сбросил откат — это не размен,"
|
||||||
|
" а бесплатная атака\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (!standBlocked)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] отбивает, стоя столбом: клинок стал щитом\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
*inv = savedInv;
|
||||||
|
EquipWeapon(game.world, a, inv->weapon);
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
|
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
|
||||||
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и
|
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и
|
||||||
@ -2192,3 +2311,138 @@ int RunUiCheck()
|
|||||||
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
|
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
|
||||||
return bad == 0 ? 0 : 1;
|
return bad == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// --wall: где обрывается забег.
|
||||||
|
//
|
||||||
|
// Главный вопрос кривой после того, как чекпоинты разошлись на пятьдесят
|
||||||
|
// этажей: до какой глубины доходит отряд ДАННОЙ силы. Ответ на него нельзя
|
||||||
|
// получить ни из головы, ни из `--descend` — тот собирает этажи, но ни с кем не
|
||||||
|
// воюет. Здесь отряд именно дерётся.
|
||||||
|
//
|
||||||
|
// Меряется грубо и честно: отряд ставится в середину населённого этажа и живёт
|
||||||
|
// там полторы минуты без управления. Это НИЖНЯЯ оценка — игрок уводит бойцов,
|
||||||
|
// выбирает, кого бить первым, и отступает; проверка не умеет ничего из этого.
|
||||||
|
// Поэтому глубина, на которой отряд перестаёт выживать здесь, — это не «конец
|
||||||
|
// игры», а место, где игра начинает требовать умения.
|
||||||
|
//
|
||||||
|
// Смысл цифр: если граница НЕ растёт с силой отряда, значит вылазки не
|
||||||
|
// накапливаются, и петля «несколько заходов до чекпоинта» не работает.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth)
|
||||||
|
{
|
||||||
|
std::printf("%-7s %-8s %8s %8s %8s %9s\n", "DEPTH", "ОТРЯД", "ТВАРЕЙ", "ВЫЖИЛО", "УБИЛ",
|
||||||
|
"ЗДОРОВЬЕ");
|
||||||
|
|
||||||
|
const int sizes[] = {1, 3, 5};
|
||||||
|
int lastAlive[3] = {0, 0, 0};
|
||||||
|
int wall[3] = {0, 0, 0};
|
||||||
|
|
||||||
|
for (int si = 0; si < 3; ++si)
|
||||||
|
{
|
||||||
|
const int size = sizes[si];
|
||||||
|
|
||||||
|
for (int d = 1; d <= maxDepth; d += (d < 10) ? 3 : 10)
|
||||||
|
{
|
||||||
|
Profile profile;
|
||||||
|
profile.ResetLoadouts();
|
||||||
|
profile.squadSize = size;
|
||||||
|
// Глубина рекорда — заведомо большая: NewRun режет старт по
|
||||||
|
// открытым чекпоинтам, а те теперь через пятьдесят этажей. С
|
||||||
|
// maxDepth = 50 любой запрос глубже первого молча уезжал на первый
|
||||||
|
// этаж, и проверка мерила два бредущих на каждой строке таблицы.
|
||||||
|
profile.maxDepth = 100000;
|
||||||
|
|
||||||
|
// Снаряжение по глубине: отряд, дошедший до d, одет примерно в то,
|
||||||
|
// что на d и находят. Без этого проверка мерила бы нож против
|
||||||
|
// сорокового этажа и объявляла стеной первый же поворот.
|
||||||
|
uint32_t rng = seed * 7919u + uint32_t(d);
|
||||||
|
for (int i = 0; i < size; ++i)
|
||||||
|
{
|
||||||
|
Inventory& inv = profile.loadout[i];
|
||||||
|
inv.weapon = RollItem((i == 0) ? ItemId::SHOTGUN : ItemId::RIFLE, d, rng);
|
||||||
|
inv.armor = RollItem((d >= 9) ? ItemId::PLATE : ItemId::VEST, d, rng);
|
||||||
|
inv.trinket = RollItem(ItemId::CHARM, d, rng);
|
||||||
|
if (i > 0) profile.merc[i] = RollMerc(rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
Game game;
|
||||||
|
game.combatMode = true;
|
||||||
|
game.rooms = &rooms;
|
||||||
|
game.catalog = &catalog;
|
||||||
|
game.profile = &profile;
|
||||||
|
game.levelSeed = seed + uint32_t(d) * 17u;
|
||||||
|
game.Init();
|
||||||
|
game.NewRun(d);
|
||||||
|
|
||||||
|
// Отряд ставится в гущу: к точке, где стоит больше всего тварей.
|
||||||
|
// Иначе половина этажа до него просто не дойдёт, и проверка
|
||||||
|
// измерит планировку.
|
||||||
|
Vec2 crowd = game.squad.anchor;
|
||||||
|
float best = -1.0f;
|
||||||
|
for (const Target& t : game.targets)
|
||||||
|
{
|
||||||
|
if (!t.alive || t.IsProp() || t.IsDummy()) continue;
|
||||||
|
float near_ = 0.0f;
|
||||||
|
for (const Target& o : game.targets)
|
||||||
|
if (o.alive && !o.IsProp() && DistanceSq(o.pos, t.pos) < 36.0f) near_ += 1.0f;
|
||||||
|
if (near_ > best)
|
||||||
|
{
|
||||||
|
best = near_;
|
||||||
|
crowd = t.pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
game.PlaceSquad(crowd, false);
|
||||||
|
|
||||||
|
int monsters = 0;
|
||||||
|
for (const Target& t : game.targets)
|
||||||
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++monsters;
|
||||||
|
|
||||||
|
for (int k = 0; k < 5400 && game.AliveAgents() > 0; ++k) game.Step(FIXED_DT);
|
||||||
|
|
||||||
|
int aliveNow = 0;
|
||||||
|
for (const Target& t : game.targets)
|
||||||
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
|
||||||
|
|
||||||
|
float hp = 0.0f, maxHp = 0.0f;
|
||||||
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||||
|
{
|
||||||
|
const Agent& a = game.squad.agents[i];
|
||||||
|
if (!a.present) continue;
|
||||||
|
hp += std::max(0.0f, a.hp);
|
||||||
|
maxHp += a.maxHp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int survivors = game.AliveAgents();
|
||||||
|
std::printf("%-7d %-8d %8d %8d %8d %8.0f%%\n", d, size, monsters, survivors,
|
||||||
|
monsters - aliveNow, double(100.0f * hp / std::max(1.0f, maxHp)));
|
||||||
|
|
||||||
|
if (survivors > 0) lastAlive[si] = d;
|
||||||
|
else if (wall[si] == 0) wall[si] = d;
|
||||||
|
}
|
||||||
|
std::printf("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Смотреть надо на ПЕРВУЮ стену, а не на «глубже всего дожили»: на глубоких
|
||||||
|
// этажах твари разбросаны, и отряд иногда доживает полторы минуты просто
|
||||||
|
// потому, что до него дошли не все. Последний выживший — шум, первая
|
||||||
|
// стена — сигнал.
|
||||||
|
std::printf("первая стена: одиночка D%d, трое D%d, пятеро D%d\n", wall[0], wall[1], wall[2]);
|
||||||
|
std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0],
|
||||||
|
lastAlive[1], lastAlive[2]);
|
||||||
|
|
||||||
|
int bad = 0;
|
||||||
|
if (wall[2] <= wall[0])
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] стена пятерых не глубже стены одиночки — вылазки не\n"
|
||||||
|
" накапливаются, и петля «несколько заходов до чекпоинта»\n"
|
||||||
|
" не работает\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (lastAlive[0] < 1)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] одиночка не переживает даже первый этаж\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
return bad == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|||||||
@ -37,3 +37,8 @@ int RunAccept();
|
|||||||
// что из игры выходит ровно один — QUIT. Появилась после того, как UPGRADES
|
// что из игры выходит ровно один — QUIT. Появилась после того, как UPGRADES
|
||||||
// четыре вехи молча закрывал игру (ui/menu.h).
|
// четыре вехи молча закрывал игру (ui/menu.h).
|
||||||
int RunUiCheck();
|
int RunUiCheck();
|
||||||
|
|
||||||
|
// --wall N: до какой глубины доходит отряд разной силы. Отвечает на вопрос,
|
||||||
|
// который появился вместе с чекпоинтами через 50 этажей: растёт ли достижимая
|
||||||
|
// глубина от вылазки к вылазке (docs/16-descent.md).
|
||||||
|
int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth);
|
||||||
|
|||||||
@ -488,8 +488,13 @@ void Game::PlaceSquad(Vec2 at, bool clumped)
|
|||||||
|
|
||||||
// Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него
|
// Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него
|
||||||
// трупом, испортил бы и замеры, и игру.
|
// трупом, испортил бы и замеры, и игру.
|
||||||
a.alive = true;
|
//
|
||||||
a.hp = a.maxHp;
|
// Но ТОЛЬКО занятые слоты: пустой воскрешать нечего и некого. Строка
|
||||||
|
// писалась, когда отряд всегда был впятером, и после появления
|
||||||
|
// ростера (M24) оживляла четверых несуществующих. Поймано `--wall`:
|
||||||
|
// одиночка выходил на этаж впятером.
|
||||||
|
a.alive = a.present;
|
||||||
|
a.hp = a.present ? a.maxHp : 0.0f;
|
||||||
a.hurtTimer = 0.0f;
|
a.hurtTimer = 0.0f;
|
||||||
// Захват — тоже состояние сценария: боец, переехавший в новый сценарий
|
// Захват — тоже состояние сценария: боец, переехавший в новый сценарий
|
||||||
// в лапах хвата, просто не сдвинулся бы с места.
|
// в лапах хвата, просто не сдвинулся бы с места.
|
||||||
|
|||||||
10
src/main.cpp
10
src/main.cpp
@ -145,7 +145,8 @@ int main(int argc, char** argv)
|
|||||||
const char* viewOverride = nullptr;
|
const char* viewOverride = nullptr;
|
||||||
uint32_t levelSeed = 1;
|
uint32_t levelSeed = 1;
|
||||||
int startDepth = 1; // --depth N: начать спуск не с первого этажа
|
int startDepth = 1; // --depth N: начать спуск не с первого этажа
|
||||||
int descendFloors = 0; // --descend N: прогнать N этажей без окна и выйти
|
int descendFloors = 0;
|
||||||
|
int wallDepth = 0; // --wall N: до какой глубины доходит отряд // --descend N: прогнать N этажей без окна и выйти
|
||||||
const char* profilePath = nullptr;
|
const char* profilePath = nullptr;
|
||||||
bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны)
|
bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны)
|
||||||
HarnessOptions harness;
|
HarnessOptions harness;
|
||||||
@ -185,6 +186,8 @@ int main(int argc, char** argv)
|
|||||||
else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc)
|
else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc)
|
||||||
profilePath = argv[++i];
|
profilePath = argv[++i];
|
||||||
else if (std::strcmp(argv[i], "--no-profile") == 0) useProfile = false;
|
else if (std::strcmp(argv[i], "--no-profile") == 0) useProfile = false;
|
||||||
|
else if (std::strcmp(argv[i], "--wall") == 0 && i + 1 < argc)
|
||||||
|
wallDepth = TextToInteger(argv[++i]);
|
||||||
else if (std::strcmp(argv[i], "--descend") == 0 && i + 1 < argc)
|
else if (std::strcmp(argv[i], "--descend") == 0 && i + 1 < argc)
|
||||||
{
|
{
|
||||||
descendFloors = TextToInteger(argv[++i]);
|
descendFloors = TextToInteger(argv[++i]);
|
||||||
@ -292,6 +295,11 @@ int main(int argc, char** argv)
|
|||||||
if (accept) return RunAccept();
|
if (accept) return RunAccept();
|
||||||
if (uiCheck) return RunUiCheck();
|
if (uiCheck) return RunUiCheck();
|
||||||
if (harness.seconds > 0.0f) return RunHeadless(harness);
|
if (harness.seconds > 0.0f) return RunHeadless(harness);
|
||||||
|
if (wallDepth > 0)
|
||||||
|
{
|
||||||
|
if (!contentOk) { std::printf("проверка стены невозможна без набора комнат\n"); return 1; }
|
||||||
|
return RunWall(rooms, catalog, levelSeed, wallDepth);
|
||||||
|
}
|
||||||
if (descendFloors > 0)
|
if (descendFloors > 0)
|
||||||
{
|
{
|
||||||
if (!contentOk) { std::printf("прогон забега невозможен без набора комнат\n"); return 1; }
|
if (!contentOk) { std::printf("прогон забега невозможен без набора комнат\n"); return 1; }
|
||||||
|
|||||||
@ -688,6 +688,9 @@ void SpriteSet::Generate()
|
|||||||
|
|
||||||
switch (EnemyKind(k))
|
switch (EnemyKind(k))
|
||||||
{
|
{
|
||||||
|
// Личинка рисуется тем же силуэтом, что и бредущий, но мельче:
|
||||||
|
// отдельная форма — работа художника, а не программиста.
|
||||||
|
case EnemyKind::LARVA: enemy[k] = MakeShambler(col); break;
|
||||||
case EnemyKind::SHAMBLER: enemy[k] = MakeShambler(col); break;
|
case EnemyKind::SHAMBLER: enemy[k] = MakeShambler(col); break;
|
||||||
case EnemyKind::RUSHER: enemy[k] = MakeRusher(col); break;
|
case EnemyKind::RUSHER: enemy[k] = MakeRusher(col); break;
|
||||||
case EnemyKind::SPITTER: enemy[k] = MakeSpitter(col); break;
|
case EnemyKind::SPITTER: enemy[k] = MakeSpitter(col); break;
|
||||||
|
|||||||
@ -27,7 +27,8 @@ int RandRange(uint32_t& s, int lo, int hi) // [lo, hi]
|
|||||||
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
|
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
|
||||||
// таблицу, а не выводя из вида предмета.
|
// таблицу, а не выводя из вида предмета.
|
||||||
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
|
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
|
||||||
AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER};
|
AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER,
|
||||||
|
AffixTag::PARRY};
|
||||||
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
|
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
|
||||||
AffixTag::CARRY, AffixTag::AMMO};
|
AffixTag::CARRY, AffixTag::AMMO};
|
||||||
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
|
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
|
||||||
@ -49,6 +50,7 @@ const char* AffixName(AffixTag t)
|
|||||||
case AffixTag::AMMO: return "ROUNDS";
|
case AffixTag::AMMO: return "ROUNDS";
|
||||||
case AffixTag::CARRY: return "CARRY";
|
case AffixTag::CARRY: return "CARRY";
|
||||||
case AffixTag::LIGHT: return "LIGHT";
|
case AffixTag::LIGHT: return "LIGHT";
|
||||||
|
case AffixTag::PARRY: return "PARRY";
|
||||||
case AffixTag::TRIGGER: return "TRIGGER";
|
case AffixTag::TRIGGER: return "TRIGGER";
|
||||||
default: return "-";
|
default: return "-";
|
||||||
}
|
}
|
||||||
@ -69,6 +71,7 @@ void AffixLabel(const Affix& a, char* out, int cap)
|
|||||||
case AffixTag::ARMOR:
|
case AffixTag::ARMOR:
|
||||||
case AffixTag::SPEED:
|
case AffixTag::SPEED:
|
||||||
case AffixTag::SIGHT:
|
case AffixTag::SIGHT:
|
||||||
|
case AffixTag::PARRY:
|
||||||
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
|
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
|
||||||
int(a.value * 100.0f + 0.5f), int(a.tier));
|
int(a.value * 100.0f + 0.5f), int(a.tier));
|
||||||
break;
|
break;
|
||||||
@ -127,6 +130,7 @@ void AffixMods::Add(const Affix& a)
|
|||||||
case AffixTag::AMMO: ammo += a.value; break;
|
case AffixTag::AMMO: ammo += a.value; break;
|
||||||
case AffixTag::CARRY: carry += a.value; break;
|
case AffixTag::CARRY: carry += a.value; break;
|
||||||
case AffixTag::LIGHT: light += a.value; break;
|
case AffixTag::LIGHT: light += a.value; break;
|
||||||
|
case AffixTag::PARRY: parry += a.value; break;
|
||||||
case AffixTag::TRIGGER:
|
case AffixTag::TRIGGER:
|
||||||
{
|
{
|
||||||
const int k = int(a.param);
|
const int k = int(a.param);
|
||||||
|
|||||||
@ -106,6 +106,7 @@ inline AffixRange AffixBase(AffixTag t)
|
|||||||
case AffixTag::LIGHT: return {18.0f, 30.0f};
|
case AffixTag::LIGHT: return {18.0f, 30.0f};
|
||||||
// Триггер — это ШАНС сработать. Дорогой аффикс с маленькими числами:
|
// Триггер — это ШАНС сработать. Дорогой аффикс с маленькими числами:
|
||||||
// «изредка поджигает» меняет бой сильнее, чем «+7% урона».
|
// «изредка поджигает» меняет бой сильнее, чем «+7% урона».
|
||||||
|
case AffixTag::PARRY: return {0.03f, 0.05f};
|
||||||
case AffixTag::TRIGGER: return {0.06f, 0.10f};
|
case AffixTag::TRIGGER: return {0.06f, 0.10f};
|
||||||
default: return {0.0f, 0.0f};
|
default: return {0.0f, 0.0f};
|
||||||
}
|
}
|
||||||
@ -137,6 +138,7 @@ struct AffixMods
|
|||||||
float ammo = 0.0f;
|
float ammo = 0.0f;
|
||||||
float carry = 0.0f;
|
float carry = 0.0f;
|
||||||
float light = 0.0f;
|
float light = 0.0f;
|
||||||
|
float parry = 0.0f;
|
||||||
|
|
||||||
// Сила по видам триггеров. Ноль — такого триггера на бойце нет
|
// Сила по видам триггеров. Ноль — такого триггера на бойце нет
|
||||||
// (sim/trigger.h). Массивом, а не флагами: два предмета с одним видом
|
// (sim/trigger.h). Массивом, а не флагами: два предмета с одним видом
|
||||||
|
|||||||
@ -275,6 +275,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
|
|||||||
a.melee.damage = item->blade.damage;
|
a.melee.damage = item->blade.damage;
|
||||||
a.melee.elem = item->blade.elem;
|
a.melee.elem = item->blade.elem;
|
||||||
a.melee.knockback = item->blade.knockback;
|
a.melee.knockback = item->blade.knockback;
|
||||||
|
a.melee.parry = std::min(0.75f, item->blade.parry + a.mods.parry);
|
||||||
|
|
||||||
// --- аффиксы оружия -------------------------------------------------------
|
// --- аффиксы оружия -------------------------------------------------------
|
||||||
// Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь:
|
// Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь:
|
||||||
|
|||||||
@ -131,6 +131,32 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
|
|||||||
{
|
{
|
||||||
if (b.hostile)
|
if (b.hostile)
|
||||||
{
|
{
|
||||||
|
// --- ПАРИРОВАНИЕ ПУЛИ ------------------------------------
|
||||||
|
// Плевок можно отбить обратно. Условие то же, что у ближнего
|
||||||
|
// боя: боец должен быть В УДАРЕ — подставить клинок между
|
||||||
|
// взмахами нельзя (sim/weapon_defs.h).
|
||||||
|
//
|
||||||
|
// Отбитый снаряд летит НАЗАД и несёт урон БОЙЦА, а не твари:
|
||||||
|
// это его удар, просто по летящей цели. Отражённая пуля
|
||||||
|
// становится дружественной, поэтому дальше по коду она
|
||||||
|
// работает как обычный выстрел и в своих не попадёт.
|
||||||
|
Agent& hit = agents[hitAgent];
|
||||||
|
const bool canParry = hit.IsMelee() && hit.melee.Committed() &&
|
||||||
|
hit.melee.parry > 0.0f;
|
||||||
|
if (canParry && hit.trig.Chance(hit.melee.parry))
|
||||||
|
{
|
||||||
|
b.hostile = false;
|
||||||
|
b.owner = hitAgent;
|
||||||
|
b.damage = hit.melee.damage;
|
||||||
|
b.elem = hit.melee.elem;
|
||||||
|
b.vel = -b.vel;
|
||||||
|
b.pos = impact;
|
||||||
|
b.prevPos = impact;
|
||||||
|
b.life = std::max(b.life, 0.35f);
|
||||||
|
if (events) events->Push(CombatEventKind::MELEE_HIT, hitAgent, impact, dir);
|
||||||
|
continue; // пуля жива и летит обратно
|
||||||
|
}
|
||||||
|
|
||||||
// Урон от твари — не нарушение 6.9 и в FRIENDLY_HITS не идёт.
|
// Урон от твари — не нарушение 6.9 и в FRIENDLY_HITS не идёт.
|
||||||
const bool down = DamageAgent(agents[hitAgent], b.damage);
|
const bool down = DamageAgent(agents[hitAgent], b.damage);
|
||||||
if (events)
|
if (events)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "sim/enemy.h"
|
#include "sim/enemy.h"
|
||||||
|
|
||||||
|
#include "sim/element.h"
|
||||||
#include "sim/status.h"
|
#include "sim/status.h"
|
||||||
#include "sim/trigger.h"
|
#include "sim/trigger.h"
|
||||||
|
|
||||||
@ -66,6 +67,35 @@ bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even
|
|||||||
std::vector<Target>* targets = nullptr, int attacker = -1,
|
std::vector<Target>* targets = nullptr, int attacker = -1,
|
||||||
float* squadRage = nullptr)
|
float* squadRage = nullptr)
|
||||||
{
|
{
|
||||||
|
// --- ПАРИРОВАНИЕ БЛИЖНЕГО БОЯ -------------------------------------------
|
||||||
|
// Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец должен сам быть в
|
||||||
|
// замахе или в ударе. Парирование «просто так» превратило бы клинок в щит,
|
||||||
|
// который работает, пока стоишь.
|
||||||
|
//
|
||||||
|
// Удавшееся парирование: урона нет, тварь получает удар бойца, и боец бьёт
|
||||||
|
// ЗАНОВО, не сбрасывая откат начальной атаки, — из чего и получается
|
||||||
|
// размен «отбил и сразу ответил» (docs/11-melee.md).
|
||||||
|
if (targets && attacker >= 0 && attacker < int(targets->size()) && a.IsMelee() &&
|
||||||
|
a.melee.Committed() && a.melee.parry > 0.0f && a.trig.Chance(a.melee.parry))
|
||||||
|
{
|
||||||
|
Target& foe = (*targets)[size_t(attacker)];
|
||||||
|
if (foe.alive && !foe.IsProp())
|
||||||
|
{
|
||||||
|
foe.hp -= ResolveDamage(a.melee.damage, a.melee.elem, foe.resist,
|
||||||
|
ResistCutOf(foe.status) + a.pen) *
|
||||||
|
VulnerabilityOf(foe.status);
|
||||||
|
foe.hurtTimer = g_tune.targetHurtTime;
|
||||||
|
|
||||||
|
// Повторный удар: фаза начинается сначала, откат не трогается.
|
||||||
|
a.melee.state = MeleeState::STRIKE;
|
||||||
|
a.melee.phase = a.melee.strike;
|
||||||
|
a.melee.hitMask = 0; // тот же взмах бьёт заново, иначе цель уже «задета»
|
||||||
|
|
||||||
|
if (events) events->Push(CombatEventKind::MELEE_HIT, index, a.pos, dir);
|
||||||
|
return false; // урона бойцу нет
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
|
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
|
||||||
|
|
||||||
// --- ON_HURT: обжечь того, кто ударил -----------------------------------
|
// --- ON_HURT: обжечь того, кто ударил -----------------------------------
|
||||||
@ -107,6 +137,14 @@ bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
bool HurtAgentForTest(Agent& a, int index, Target& attacker, int attackerIndex,
|
||||||
|
std::vector<Target>& targets, float damage)
|
||||||
|
{
|
||||||
|
(void)attacker;
|
||||||
|
return HurtAgent(a, index, damage, FromAngle(a.facing), nullptr, &targets, attackerIndex,
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
||||||
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
||||||
float aggroMul, float* squadRage)
|
float aggroMul, float* squadRage)
|
||||||
|
|||||||
@ -42,5 +42,14 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
|
|||||||
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
||||||
float aggroMul = 1.0f, float* squadRage = nullptr);
|
float aggroMul = 1.0f, float* squadRage = nullptr);
|
||||||
|
|
||||||
|
// Ударить бойца ТЕМ ЖЕ путём, каким бьёт тварь. Наружу вынесено ради одной
|
||||||
|
// проверки — размена при парировании (--descend): собрать его через настоящую
|
||||||
|
// атаку означало бы ждать, пока тварь сама попадёт в нужную фазу удара, то есть
|
||||||
|
// мерить везение вместо правила.
|
||||||
|
//
|
||||||
|
// Возвращает true, если боец упал.
|
||||||
|
bool HurtAgentForTest(Agent& a, int index, Target& attacker, int attackerIndex,
|
||||||
|
std::vector<Target>& targets, float damage);
|
||||||
|
|
||||||
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
|
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
|
||||||
int CountAggroEnemies(const std::vector<Target>& targets);
|
int CountAggroEnemies(const std::vector<Target>& targets);
|
||||||
|
|||||||
@ -130,6 +130,7 @@ enum class AffixTag : uint8_t
|
|||||||
AMMO, // +патроны на вылазку
|
AMMO, // +патроны на вылазку
|
||||||
CARRY, // +норма переноски
|
CARRY, // +норма переноски
|
||||||
LIGHT, // +заряд фонаря
|
LIGHT, // +заряд фонаря
|
||||||
|
PARRY, // +шанс отбить чужой удар (только клинки)
|
||||||
TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h
|
TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h
|
||||||
COUNT,
|
COUNT,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -100,6 +100,7 @@ struct MeleeWeapon
|
|||||||
float interval = 0.62f; // между началами замахов
|
float interval = 0.62f; // между началами замахов
|
||||||
float damage = 110.0f;
|
float damage = 110.0f;
|
||||||
float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу
|
float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу
|
||||||
|
float parry = 0.0f; // шанс отбить чужой удар (sim/weapon_defs.h)
|
||||||
|
|
||||||
// --- состояние ---
|
// --- состояние ---
|
||||||
MeleeState state = MeleeState::IDLE;
|
MeleeState state = MeleeState::IDLE;
|
||||||
|
|||||||
@ -44,7 +44,7 @@ const StarterKit STARTER[NUM_AGENTS] = {
|
|||||||
// и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет.
|
// и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет.
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp",
|
const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp",
|
||||||
"spd", "sgt", "amo", "car", "lgt", "trg"};
|
"spd", "sgt", "amo", "car", "lgt", "par", "trg"};
|
||||||
static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT),
|
static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT),
|
||||||
"у каждого тега аффикса обязан быть свой токен в профиле");
|
"у каждого тега аффикса обязан быть свой токен в профиле");
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct Row
|
|||||||
|
|
||||||
const Row ROWS[] = {
|
const Row ROWS[] = {
|
||||||
{"dummy", EnemyKind::DUMMY},
|
{"dummy", EnemyKind::DUMMY},
|
||||||
|
{"larva", EnemyKind::LARVA},
|
||||||
{"shambler", EnemyKind::SHAMBLER},
|
{"shambler", EnemyKind::SHAMBLER},
|
||||||
{"rusher", EnemyKind::RUSHER},
|
{"rusher", EnemyKind::RUSHER},
|
||||||
{"spitter", EnemyKind::SPITTER},
|
{"spitter", EnemyKind::SPITTER},
|
||||||
|
|||||||
@ -93,6 +93,26 @@ EnemyDef EnemyDefOf(EnemyKind kind)
|
|||||||
{
|
{
|
||||||
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
|
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
|
||||||
// шагом, — и ровно поэтому страшен только гурьбой.
|
// шагом, — и ровно поэтому страшен только гурьбой.
|
||||||
|
// Личинка — то, чем первый этаж обязан быть населён: она почти не
|
||||||
|
// опасна и умирает от ножа за пару взмахов. Заведена НЕ ради
|
||||||
|
// разнообразия, а потому что «полупустая лаборатория» с бредущим на
|
||||||
|
// 210 здоровья непроходима для одиночки с ножом — это показал --wall.
|
||||||
|
//
|
||||||
|
// Именно новый ВИД, а не ослабленный бредущий с множителем: глубина в
|
||||||
|
// этой игре меняет, кто выходит, а не числа у одного и того же
|
||||||
|
// (docs/16-descent.md).
|
||||||
|
case EnemyKind::LARVA:
|
||||||
|
d.name = "LARVA";
|
||||||
|
d.hp = 55.0f; d.radius = 0.28f; d.speed = 0.72f; d.knockResist = 0.0f;
|
||||||
|
d.aggroRadius = 7.5f; d.attackRange = 0.70f; d.damage = 7.0f;
|
||||||
|
d.interval = 1.6f; d.windup = 0.55f;
|
||||||
|
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 80.0f;
|
||||||
|
d.lightPower = 0.03f;
|
||||||
|
d.r = 132; d.g = 120; d.b = 78;
|
||||||
|
// мягкая и сырая: жар берёт её сразу, дуга проходит плохо
|
||||||
|
d.resist = Resists(0.0f, -0.40f, 0.0f, 0.0f, 0.20f, 0.0f, 0.0f);
|
||||||
|
break;
|
||||||
|
|
||||||
case EnemyKind::SHAMBLER:
|
case EnemyKind::SHAMBLER:
|
||||||
d.name = "SHAMBLER";
|
d.name = "SHAMBLER";
|
||||||
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
|
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
enum class EnemyKind : uint8_t
|
enum class EnemyKind : uint8_t
|
||||||
{
|
{
|
||||||
DUMMY = 0, // мишень приёмки: не ходит, не бьёт
|
DUMMY = 0, // мишень приёмки: не ходит, не бьёт
|
||||||
|
LARVA, // личинка: медленная, дохлая, почти не бьёт — первые этажи
|
||||||
SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную
|
SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную
|
||||||
RUSHER, // бегун: тонкий, быстрый, налетает первым
|
RUSHER, // бегун: тонкий, быстрый, налетает первым
|
||||||
SPITTER, // плевок: держится поодаль и стреляет
|
SPITTER, // плевок: держится поодаль и стреляет
|
||||||
|
|||||||
@ -119,6 +119,14 @@ struct BladeDef
|
|||||||
float damage = 130.0f;
|
float damage = 130.0f;
|
||||||
float knockback = 2.4f; // импульс мишени, юнитов/сек
|
float knockback = 2.4f; // импульс мишени, юнитов/сек
|
||||||
float lungeSpeed = 3.4f; // рывок в момент удара
|
float lungeSpeed = 3.4f; // рывок в момент удара
|
||||||
|
|
||||||
|
// Шанс отбить чужой удар. ЧЕМ ЛЕГЧЕ ОРУЖИЕ, ТЕМ ВЫШЕ: нож успевает встать
|
||||||
|
// на пути, тесаком парировать нечем — им можно только бить первым.
|
||||||
|
//
|
||||||
|
// Это отдельная характеристика, а не производная от веса: её меняют
|
||||||
|
// аффиксы (sim/affix.h), и «лёгкий тесак» должен быть возможен как
|
||||||
|
// находка, а не запрещён формулой.
|
||||||
|
float parry = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- компонент сущности-оружия ------------------------------------------------
|
// --- компонент сущности-оружия ------------------------------------------------
|
||||||
@ -401,6 +409,7 @@ inline BladeDef DefaultBladeDef()
|
|||||||
d.damage = g_tune.meleeDamage;
|
d.damage = g_tune.meleeDamage;
|
||||||
d.knockback = g_tune.meleeKnockback;
|
d.knockback = g_tune.meleeKnockback;
|
||||||
d.lungeSpeed = g_tune.meleeLungeSpeed;
|
d.lungeSpeed = g_tune.meleeLungeSpeed;
|
||||||
|
d.parry = 0.20f; // сабля: середина между ножом и тесаком
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,6 +426,7 @@ inline BladeDef KnifeDef()
|
|||||||
d.recover = 0.13f;
|
d.recover = 0.13f;
|
||||||
d.interval = 0.34f;
|
d.interval = 0.34f;
|
||||||
d.damage = 62.0f;
|
d.damage = 62.0f;
|
||||||
|
d.parry = 0.34f; // нож: почти всегда успевает подставиться
|
||||||
d.knockback = 0.9f;
|
d.knockback = 0.9f;
|
||||||
d.lungeSpeed = 4.2f;
|
d.lungeSpeed = 4.2f;
|
||||||
return d;
|
return d;
|
||||||
@ -435,6 +445,7 @@ inline BladeDef CleaverDef()
|
|||||||
d.recover = 0.42f;
|
d.recover = 0.42f;
|
||||||
d.interval = 0.95f;
|
d.interval = 0.95f;
|
||||||
d.damage = 240.0f;
|
d.damage = 240.0f;
|
||||||
|
d.parry = 0.08f; // тесак: им бьют, а не защищаются
|
||||||
d.knockback = 4.6f;
|
d.knockback = 4.6f;
|
||||||
d.lungeSpeed = 2.8f;
|
d.lungeSpeed = 2.8f;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user