Личинки, парирование и инструмент --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:
z.kirill 2026-08-16 05:04:35 +03:00
parent ef103050b1
commit e3294cffd4
25 changed files with 484 additions and 9 deletions

View File

@ -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 сравнение) |

View File

@ -148,3 +148,32 @@ squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0
`sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера `sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера
раздельно: 2 мкс против 1.43.5 мс при бюджете 16.6 мс. раздельно: 2 мкс против 1.43.5 мс при бюджете 16.6 мс.
## `--wall`: где обрывается забег
```bash
squad_proto.exe --rooms --wall 50
```
Отвечает на вопрос, который появился вместе с чекпоинтами через пятьдесят
этажей: **растёт ли достижимая глубина от вылазки к вылазке**. Отряд из одного,
трёх и пяти бойцов, одетый по глубине, ставится в самое скопление тварей и живёт
там полторы минуты без управления.
```
первая стена: одиночка D7, трое D30, пятеро D30
```
Это НИЖНЯЯ оценка: игрок уводит бойцов, выбирает цели и отступает — проверка не
умеет ничего из этого. Поэтому «стена» здесь не конец игры, а глубина, с которой
игра начинает требовать умения.
Смотреть надо на первую стену, а не на «глубже всего дожили»: на глубоких этажах
твари разбросаны, и отряд иногда доживает просто потому, что до него дошли не все.
Этот же прогон нашёл настоящую ошибку: `PlaceSquad` воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером, и после
появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями.
Из-за них прежний замер «одиночка на первом этаже» показывал уверенную победу —
это был бой впятером.

View File

@ -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)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -220,3 +220,47 @@ spin 15` — финиш доходит реже, и это видно в циф
сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание, сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание,
скорость и ускорение рывка, множители скорости на замахе и восстановлении, скорость и ускорение рывка, множители скорости на замахе и восстановлении,
радиус своего круга. радиус своего круга.
## Парирование
Клинок умеет отбивать. Шанс — отдельная характеристика оружия (`BladeDef::parry`),
и **чем оружие легче, тем он выше**: нож 34%, сабля 20%, тесак 8%. Тесаком не
защищаются, им бьют первым.
Характеристика именно отдельная, а не выведенная из веса: её двигают аффиксы
(`PARRY`), и «лёгкий тесак» обязан быть возможен как находка, а не запрещён
формулой.
**Отбить можно, только если удары соприкоснулись.** Боец должен сам быть в замахе
или в ударе (`MeleeWeapon::Committed()`). Парирование «просто так» превратило бы
клинок в щит, который работает, пока стоишь, — и ближний бой перестал бы быть
разменом.
Удавшийся отбив:
| | |
|---|---|
| урон бойцу | не проходит |
| тварь | получает удар бойца целиком, со стихиями и пробитием |
| боец | бьёт ЗАНОВО с начала фазы удара |
| откат | **не сбрасывается** |
Последняя строка — то, ради чего всё и делалось. Сбрасывай отбив откат, это была
бы бесплатная атака, и выгоднее стало бы подставляться под удары. Не сбрасывая,
парирование даёт ровно один лишний удар в текущем размене.
**Пуля тоже отбивается.** Плевок при удачном парировании летит назад и несёт урон
БОЙЦА, а не твари: это его удар, просто по летящей цели. Отражённый снаряд
становится дружественным, поэтому дальше по коду работает как обычный выстрел и
в своих не попадёт.
Проверяется в `--descend` — размен целиком, а не факт срабатывания:
```
парирование: нож 34%, тесак 8%; отбив снял урон, ответ прошёл,
откат не сброшен, стоя столбом не отбивает
```
Последняя половина строки важна не меньше первой: без неё правило «удары должны
соприкоснуться» существовало бы только в комментарии.

View File

@ -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 и бегуны с первого: одинокий
оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно», оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно»,
это «непроходимо». это «непроходимо».

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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);

View File

@ -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;
// Захват — тоже состояние сценария: боец, переехавший в новый сценарий // Захват — тоже состояние сценария: боец, переехавший в новый сценарий
// в лапах хвата, просто не сдвинулся бы с места. // в лапах хвата, просто не сдвинулся бы с места.

View File

@ -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; }

View File

@ -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;

View File

@ -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);

View File

@ -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). Массивом, а не флагами: два предмета с одним видом

View File

@ -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);
// --- аффиксы оружия ------------------------------------------------------- // --- аффиксы оружия -------------------------------------------------------
// Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь: // Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь:

View File

@ -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)

View File

@ -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)

View File

@ -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);

View File

@ -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,
}; };

View File

@ -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;

View File

@ -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),
"у каждого тега аффикса обязан быть свой токен в профиле"); "у каждого тега аффикса обязан быть свой токен в профиле");

View File

@ -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},

View File

@ -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;

View File

@ -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, // плевок: держится поодаль и стреляет

View File

@ -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;