From e3294cffd482460d4248cf86232a0f763f66b16a Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Sun, 16 Aug 2026 05:04:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=B8=D1=87=D0=B8=D0=BD=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20--wall=20(M30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке. Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление тварей и живёт полторы минуты без управления. Первая стена: одиночка D7, трое D30, пятеро D30 - петля работает. И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в том числе незанятые. Строка писалась, когда отряд всегда был впятером; после появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже, 100 -> 41" был боем впятером. ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом проигрывает двум бредущим: 210 здоровья против ножа это девять секунд работы под ответным уроном. Первые этажи населены личинками (55 здоровья, 7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый. Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у одного и того же. ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка. Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь. Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары. Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это его удар, просто по летящей цели. Проверка меряет размен целиком, включая обратную половину правила: "стоя столбом не отбивает". Без неё условие про соприкосновение существовало бы только в комментарии. Co-Authored-By: Claude Opus 5 --- docs/01-build-and-run.md | 1 + docs/06-debug-and-tuning.md | 29 +++ docs/07-milestones.md | 2 + docs/11-melee.md | 44 +++++ docs/16-descent.md | 11 +- projects/descent/catalog/entities.txt | 3 +- projects/hollow/catalog/entities.txt | 3 +- src/debug/harness.cpp | 254 ++++++++++++++++++++++++++ src/debug/harness.h | 5 + src/game.cpp | 9 +- src/main.cpp | 10 +- src/render/sprites.cpp | 3 + src/sim/affix.cpp | 6 +- src/sim/affix.h | 2 + src/sim/agent.cpp | 1 + src/sim/bullet.cpp | 26 +++ src/sim/enemy.cpp | 38 ++++ src/sim/enemy.h | 9 + src/sim/items.h | 1 + src/sim/loadout.h | 1 + src/sim/profile.cpp | 2 +- src/sim/spawn_catalog.cpp | 1 + src/sim/target.cpp | 20 ++ src/sim/target.h | 1 + src/sim/weapon_defs.h | 11 ++ 25 files changed, 484 insertions(+), 9 deletions(-) diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index 71c25a7..4a0eea9 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -111,6 +111,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 |---|---| | `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL | | `--ui-check` | пункты меню без окна: каждый ведёт куда-то, выходит только QUIT | +| `--wall N` | до какой глубины доходит отряд из 1/3/5 бойцов (нужен `--rooms`) | | `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с | | `--probe` | к `--headless`: подробности по каждому агенту на стоянках | | `--no-solver` | отключить решатель огневых позиций (A/B сравнение) | diff --git a/docs/06-debug-and-tuning.md b/docs/06-debug-and-tuning.md index c5659ec..7ca66b2 100644 --- a/docs/06-debug-and-tuning.md +++ b/docs/06-debug-and-tuning.md @@ -148,3 +148,32 @@ squad_proto.exe --headless 300 --set safetyMargin=0.25 --set laneReserve=2.0 `sim`/`render` в оверлее показывают время шага симуляции и время кадра рендера раздельно: 2 мкс против 1.4–3.5 мс при бюджете 16.6 мс. + + +## `--wall`: где обрывается забег + +```bash +squad_proto.exe --rooms --wall 50 +``` + +Отвечает на вопрос, который появился вместе с чекпоинтами через пятьдесят +этажей: **растёт ли достижимая глубина от вылазки к вылазке**. Отряд из одного, +трёх и пяти бойцов, одетый по глубине, ставится в самое скопление тварей и живёт +там полторы минуты без управления. + +``` +первая стена: одиночка D7, трое D30, пятеро D30 +``` + +Это НИЖНЯЯ оценка: игрок уводит бойцов, выбирает цели и отступает — проверка не +умеет ничего из этого. Поэтому «стена» здесь не конец игры, а глубина, с которой +игра начинает требовать умения. + +Смотреть надо на первую стену, а не на «глубже всего дожили»: на глубоких этажах +твари разбросаны, и отряд иногда доживает просто потому, что до него дошли не все. + +Этот же прогон нашёл настоящую ошибку: `PlaceSquad` воскрешал ВСЕ пять слотов, в +том числе незанятые. Строка писалась, когда отряд всегда был впятером, и после +появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями. +Из-за них прежний замер «одиночка на первом этаже» показывал уверенную победу — +это был бой впятером. diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 822a1de..7d75f54 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -48,6 +48,8 @@ | **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)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/11-melee.md b/docs/11-melee.md index 993405a..2d8a7cf 100644 --- a/docs/11-melee.md +++ b/docs/11-melee.md @@ -220,3 +220,47 @@ spin 15` — финиш доходит реже, и это видно в циф сектора и ширина лезвия, длительности всех трёх фаз, интервал, урон, отбрасывание, скорость и ускорение рывка, множители скорости на замахе и восстановлении, радиус своего круга. + + +## Парирование + +Клинок умеет отбивать. Шанс — отдельная характеристика оружия (`BladeDef::parry`), +и **чем оружие легче, тем он выше**: нож 34%, сабля 20%, тесак 8%. Тесаком не +защищаются, им бьют первым. + +Характеристика именно отдельная, а не выведенная из веса: её двигают аффиксы +(`PARRY`), и «лёгкий тесак» обязан быть возможен как находка, а не запрещён +формулой. + +**Отбить можно, только если удары соприкоснулись.** Боец должен сам быть в замахе +или в ударе (`MeleeWeapon::Committed()`). Парирование «просто так» превратило бы +клинок в щит, который работает, пока стоишь, — и ближний бой перестал бы быть +разменом. + +Удавшийся отбив: + +| | | +|---|---| +| урон бойцу | не проходит | +| тварь | получает удар бойца целиком, со стихиями и пробитием | +| боец | бьёт ЗАНОВО с начала фазы удара | +| откат | **не сбрасывается** | + +Последняя строка — то, ради чего всё и делалось. Сбрасывай отбив откат, это была +бы бесплатная атака, и выгоднее стало бы подставляться под удары. Не сбрасывая, +парирование даёт ровно один лишний удар в текущем размене. + +**Пуля тоже отбивается.** Плевок при удачном парировании летит назад и несёт урон +БОЙЦА, а не твари: это его удар, просто по летящей цели. Отражённый снаряд +становится дружественным, поэтому дальше по коду работает как обычный выстрел и +в своих не попадёт. + +Проверяется в `--descend` — размен целиком, а не факт срабатывания: + +``` +парирование: нож 34%, тесак 8%; отбив снял урон, ответ прошёл, + откат не сброшен, стоя столбом не отбивает +``` + +Последняя половина строки важна не меньше первой: без неё правило «удары должны +соприкоснуться» существовало бы только в комментарии. diff --git a/docs/16-descent.md b/docs/16-descent.md index 3dafa68..b6f3a1d 100644 --- a/docs/16-descent.md +++ b/docs/16-descent.md @@ -152,8 +152,15 @@ entity plate class=loot tier=3 depth=9 radius=0.30 color=140,1 ## Первый этаж обязан быть скучным -Бюджет опасности начинается с 2.2 — ровно на двух бредущих, и бегуны подключаются -только с четвёртого этажа. До этого было 3.0 и бегуны с первого: одинокий +Бюджет опасности начинается с 2.2, и на первых этажах его тратят **личинки** — +55 здоровья, 7 урона, медленные. Бредущий (210 здоровья) выходит с третьего +этажа, бегун с четвёртого. + +Личинка заведена не ради разнообразия. `--wall` показал, что одиночка с ножом +проигрывает ДВУМ бредущим, даже когда их всего двое: 210 здоровья против ножа — +это девять секунд работы под ответным уроном. Новый ВИД, а не ослабленный +бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у +одного и того же. До этого было 3.0 и бегуны с первого: одинокий оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно», это «непроходимо». diff --git a/projects/descent/catalog/entities.txt b/projects/descent/catalog/entities.txt index 14aed26..f85884b 100644 --- a/projects/descent/catalog/entities.txt +++ b/projects/descent/catalog/entities.txt @@ -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 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 diff --git a/projects/hollow/catalog/entities.txt b/projects/hollow/catalog/entities.txt index 324b792..591c439 100644 --- a/projects/hollow/catalog/entities.txt +++ b/projects/hollow/catalog/entities.txt @@ -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 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 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 diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index e05ee5f..152a8e9 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -9,7 +9,9 @@ #include #include "game.h" +#include "sim/affix.h" #include "sim/craft.h" +#include "sim/enemy.h" #include "sim/loot.h" #include "sim/trigger.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(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); 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; +} diff --git a/src/debug/harness.h b/src/debug/harness.h index b8b43f0..a1066e7 100644 --- a/src/debug/harness.h +++ b/src/debug/harness.h @@ -37,3 +37,8 @@ int RunAccept(); // что из игры выходит ровно один — QUIT. Появилась после того, как UPGRADES // четыре вехи молча закрывал игру (ui/menu.h). int RunUiCheck(); + +// --wall N: до какой глубины доходит отряд разной силы. Отвечает на вопрос, +// который появился вместе с чекпоинтами через 50 этажей: растёт ли достижимая +// глубина от вылазки к вылазке (docs/16-descent.md). +int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth); diff --git a/src/game.cpp b/src/game.cpp index d3e061e..77bb24b 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -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; // Захват — тоже состояние сценария: боец, переехавший в новый сценарий // в лапах хвата, просто не сдвинулся бы с места. diff --git a/src/main.cpp b/src/main.cpp index 85280a8..119e2cb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -145,7 +145,8 @@ int main(int argc, char** argv) const char* viewOverride = nullptr; uint32_t levelSeed = 1; 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; bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны) HarnessOptions harness; @@ -185,6 +186,8 @@ int main(int argc, char** argv) else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc) profilePath = argv[++i]; 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) { descendFloors = TextToInteger(argv[++i]); @@ -292,6 +295,11 @@ int main(int argc, char** argv) if (accept) return RunAccept(); if (uiCheck) return RunUiCheck(); 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 (!contentOk) { std::printf("прогон забега невозможен без набора комнат\n"); return 1; } diff --git a/src/render/sprites.cpp b/src/render/sprites.cpp index 0631fa9..eaa6ccb 100644 --- a/src/render/sprites.cpp +++ b/src/render/sprites.cpp @@ -688,6 +688,9 @@ void SpriteSet::Generate() switch (EnemyKind(k)) { + // Личинка рисуется тем же силуэтом, что и бредущий, но мельче: + // отдельная форма — работа художника, а не программиста. + case EnemyKind::LARVA: enemy[k] = MakeShambler(col); break; case EnemyKind::SHAMBLER: enemy[k] = MakeShambler(col); break; case EnemyKind::RUSHER: enemy[k] = MakeRusher(col); break; case EnemyKind::SPITTER: enemy[k] = MakeSpitter(col); break; diff --git a/src/sim/affix.cpp b/src/sim/affix.cpp index cfa0976..a75ce67 100644 --- a/src/sim/affix.cpp +++ b/src/sim/affix.cpp @@ -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, - AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER}; + AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER, + AffixTag::PARRY}; const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED, AffixTag::CARRY, AffixTag::AMMO}; 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::CARRY: return "CARRY"; case AffixTag::LIGHT: return "LIGHT"; + case AffixTag::PARRY: return "PARRY"; case AffixTag::TRIGGER: return "TRIGGER"; default: return "-"; } @@ -69,6 +71,7 @@ void AffixLabel(const Affix& a, char* out, int cap) case AffixTag::ARMOR: case AffixTag::SPEED: case AffixTag::SIGHT: + case AffixTag::PARRY: std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag), int(a.value * 100.0f + 0.5f), int(a.tier)); break; @@ -127,6 +130,7 @@ void AffixMods::Add(const Affix& a) case AffixTag::AMMO: ammo += a.value; break; case AffixTag::CARRY: carry += a.value; break; case AffixTag::LIGHT: light += a.value; break; + case AffixTag::PARRY: parry += a.value; break; case AffixTag::TRIGGER: { const int k = int(a.param); diff --git a/src/sim/affix.h b/src/sim/affix.h index 6acfc7e..91ad0cf 100644 --- a/src/sim/affix.h +++ b/src/sim/affix.h @@ -106,6 +106,7 @@ inline AffixRange AffixBase(AffixTag t) case AffixTag::LIGHT: return {18.0f, 30.0f}; // Триггер — это ШАНС сработать. Дорогой аффикс с маленькими числами: // «изредка поджигает» меняет бой сильнее, чем «+7% урона». + case AffixTag::PARRY: return {0.03f, 0.05f}; case AffixTag::TRIGGER: return {0.06f, 0.10f}; default: return {0.0f, 0.0f}; } @@ -137,6 +138,7 @@ struct AffixMods float ammo = 0.0f; float carry = 0.0f; float light = 0.0f; + float parry = 0.0f; // Сила по видам триггеров. Ноль — такого триггера на бойце нет // (sim/trigger.h). Массивом, а не флагами: два предмета с одним видом diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 5aa95e6..7a9e9cc 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -275,6 +275,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& a.melee.damage = item->blade.damage; a.melee.elem = item->blade.elem; a.melee.knockback = item->blade.knockback; + a.melee.parry = std::min(0.75f, item->blade.parry + a.mods.parry); // --- аффиксы оружия ------------------------------------------------------- // Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь: diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index ed4aa6c..3018c3b 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -131,6 +131,32 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, { 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 не идёт. const bool down = DamageAgent(agents[hitAgent], b.damage); if (events) diff --git a/src/sim/enemy.cpp b/src/sim/enemy.cpp index 5d7b5ca..427fb54 100644 --- a/src/sim/enemy.cpp +++ b/src/sim/enemy.cpp @@ -1,5 +1,6 @@ #include "sim/enemy.h" +#include "sim/element.h" #include "sim/status.h" #include "sim/trigger.h" @@ -66,6 +67,35 @@ bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even std::vector* targets = nullptr, int attacker = -1, 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); // --- ON_HURT: обжечь того, кто ударил ----------------------------------- @@ -107,6 +137,14 @@ bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even } // namespace +bool HurtAgentForTest(Agent& a, int index, Target& attacker, int attackerIndex, + std::vector& 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, std::vector& targets, float dt, CombatEventLog* events, float aggroMul, float* squadRage) diff --git a/src/sim/enemy.h b/src/sim/enemy.h index 39a41a5..acccb35 100644 --- a/src/sim/enemy.h +++ b/src/sim/enemy.h @@ -42,5 +42,14 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age std::vector& targets, float dt, CombatEventLog* events, float aggroMul = 1.0f, float* squadRage = nullptr); +// Ударить бойца ТЕМ ЖЕ путём, каким бьёт тварь. Наружу вынесено ради одной +// проверки — размена при парировании (--descend): собрать его через настоящую +// атаку означало бы ждать, пока тварь сама попадёт в нужную фазу удара, то есть +// мерить везение вместо правила. +// +// Возвращает true, если боец упал. +bool HurtAgentForTest(Agent& a, int index, Target& attacker, int attackerIndex, + std::vector& targets, float damage); + // Сколько тварей сейчас идёт к отряду. Для напряжения и HUD. int CountAggroEnemies(const std::vector& targets); diff --git a/src/sim/items.h b/src/sim/items.h index 0f675a6..b26817b 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -130,6 +130,7 @@ enum class AffixTag : uint8_t AMMO, // +патроны на вылазку CARRY, // +норма переноски LIGHT, // +заряд фонаря + PARRY, // +шанс отбить чужой удар (только клинки) TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h COUNT, }; diff --git a/src/sim/loadout.h b/src/sim/loadout.h index a747c85..90ef230 100644 --- a/src/sim/loadout.h +++ b/src/sim/loadout.h @@ -100,6 +100,7 @@ struct MeleeWeapon float interval = 0.62f; // между началами замахов float damage = 110.0f; float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу + float parry = 0.0f; // шанс отбить чужой удар (sim/weapon_defs.h) // --- состояние --- MeleeState state = MeleeState::IDLE; diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index aad90c7..4cf612b 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -44,7 +44,7 @@ const StarterKit STARTER[NUM_AGENTS] = { // и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет. // ----------------------------------------------------------------------------- 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), "у каждого тега аффикса обязан быть свой токен в профиле"); diff --git a/src/sim/spawn_catalog.cpp b/src/sim/spawn_catalog.cpp index 3732a6f..4fbdae8 100644 --- a/src/sim/spawn_catalog.cpp +++ b/src/sim/spawn_catalog.cpp @@ -18,6 +18,7 @@ struct Row const Row ROWS[] = { {"dummy", EnemyKind::DUMMY}, + {"larva", EnemyKind::LARVA}, {"shambler", EnemyKind::SHAMBLER}, {"rusher", EnemyKind::RUSHER}, {"spitter", EnemyKind::SPITTER}, diff --git a/src/sim/target.cpp b/src/sim/target.cpp index ab5e3d9..702e4f8 100644 --- a/src/sim/target.cpp +++ b/src/sim/target.cpp @@ -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: d.name = "SHAMBLER"; d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f; diff --git a/src/sim/target.h b/src/sim/target.h index 6dfac90..ca5628d 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -28,6 +28,7 @@ enum class EnemyKind : uint8_t { DUMMY = 0, // мишень приёмки: не ходит, не бьёт + LARVA, // личинка: медленная, дохлая, почти не бьёт — первые этажи SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную RUSHER, // бегун: тонкий, быстрый, налетает первым SPITTER, // плевок: держится поодаль и стреляет diff --git a/src/sim/weapon_defs.h b/src/sim/weapon_defs.h index 2d1d1df..5ab3006 100644 --- a/src/sim/weapon_defs.h +++ b/src/sim/weapon_defs.h @@ -119,6 +119,14 @@ struct BladeDef float damage = 130.0f; float knockback = 2.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.knockback = g_tune.meleeKnockback; d.lungeSpeed = g_tune.meleeLungeSpeed; + d.parry = 0.20f; // сабля: середина между ножом и тесаком return d; } @@ -417,6 +426,7 @@ inline BladeDef KnifeDef() d.recover = 0.13f; d.interval = 0.34f; d.damage = 62.0f; + d.parry = 0.34f; // нож: почти всегда успевает подставиться d.knockback = 0.9f; d.lungeSpeed = 4.2f; return d; @@ -435,6 +445,7 @@ inline BladeDef CleaverDef() d.recover = 0.42f; d.interval = 0.95f; d.damage = 240.0f; + d.parry = 0.08f; // тесак: им бьют, а не защищаются d.knockback = 4.6f; d.lungeSpeed = 2.8f;