diff --git a/CLAUDE.md b/CLAUDE.md index c85908e..6e0d2a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,12 @@ вместо открытия экрана: в обработчике стоял `default: quit`, а новый пункт добавили четвёртым. Все прогоны меряли симуляцию, меню не проверял никто — теперь проверяет `--ui-check`, и новый экран обязан появиться в нём. -- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё, - достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)). +- **Ставка — на ВЕЩИ, а не на опыт.** Опыт платится за убийства и сразу; вынести + надо добычу. Вайп оставляет около половины (редкое роняется чаще), достигнутая + глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)). +- **Оперативников НАХОДЯТ, а не покупают.** Капсула на этаже — примерно раз в сто + этажей и не чаще раза в двадцать. Кнопки «нанять» в меню нет намеренно + ([17-extraction.md](docs/17-extraction.md)). - **`NUM_AGENTS` — это вместимость, а не размер отряда.** Сколько слотов занято, говорит `Squad::roster`, а занят ли конкретный — `Agent::present`. Правила боя читают только `alive` (пустой слот и павший для них одно), а рендер, HUD и diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 2ef91f1..822a1de 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -46,6 +46,8 @@ | **M28** | триггеры: четыре условных эффекта (ON_HIT поджигает, ON_KILL даёт рывок стаками, ON_HURT обжигает ударившего, ON_DOWN даёт ярость отряду) отдельным тегом аффикса; три ограничителя — откат по видам, бюджет предмета, запрет рекурсии | дизайн предлагал вешать триггеры на журнал событий — так делать нельзя: журнал это канал ЭФФЕКТОВ, в headless его нет вовсе, и правила боя стали бы зависеть от наличия окна; сделано срабатыванием в месте урона; проверка в `--descend`: аффикс доезжает до бойца, ON_HIT кладёт стак, внутри отката 0, ON_KILL даёт 1.06 скорости, за шаг не больше одного стака; проверка сперва брала `targets[0]` и падала на hollow, где первым в пуле стоял ящик; шаг симуляции 3.7 мкс; `--accept` PASS ([21-damage.md](21-damage.md)) | +| **M29** | правки по игре: первый этаж вдвое тише и светлее (бюджет 2.2, бегуны с 4-го, своя яркость этажа), опыт платится за убийства сразу, вайп оставляет ~половину добычи с поправкой на редкость, аномалия возврата переехала к спуску, чекпоинты через 50 этажей, оперативники НАХОДЯТСЯ в капсулах вместо покупки за опыт | замер на сотне вайпов: доезжает 52% (обычное 69%, реликты 25%) — первая версия проверки считала одну сумку и получила «обычного доехало 112%», потому что спасение забирает ещё и надетое; опыт 558 -> 578 за убийство без эвакуации; капсула поднимает отряд до двух и даёт наёмнику свои числа; `--accept` PASS ([17-extraction.md](17-extraction.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/16-descent.md b/docs/16-descent.md index 369a806..3dafa68 100644 --- a/docs/16-descent.md +++ b/docs/16-descent.md @@ -150,6 +150,18 @@ entity plate class=loot tier=3 depth=9 radius=0.30 color=140,1 патронов, переноска, обзор, живучесть. Заглушкой это было три вехи — ровно до тех пор, пока не нашлось, во что вкладываться так, чтобы не сломать жанр. +## Первый этаж обязан быть скучным + +Бюджет опасности начинается с 2.2 — ровно на двух бредущих, и бегуны подключаются +только с четвёртого этажа. До этого было 3.0 и бегуны с первого: одинокий +оперативник с ножом встречал троих, из которых один быстрый. Это не «страшно», +это «непроходимо». + +Свет там тоже другой: `sightStart` 1.30 и `ambient` 0.42 против 0.60 и 0.13 на +дне. Наверху ещё работает освещение, и хоррор первого этажа — в том, что игрок +один, а не в том, что не видно стен. Темнота это инструмент ГЛУБИНЫ, и тратить +её на первый этаж значит не иметь запаса дальше. + ## Свет как главный признак глубины Окружение становится страшнее не новыми спрайтами, а тем, что видно всё меньше. diff --git a/docs/17-extraction.md b/docs/17-extraction.md index 4ac060d..40c098a 100644 --- a/docs/17-extraction.md +++ b/docs/17-extraction.md @@ -9,10 +9,22 @@ Три правила, из которых следует всё остальное: 1. **Унести можно только то, что на выживших.** Павший забирает своё с собой. -2. **Полный вайп не возвращает ничего.** Снаряжение сбрасывается к стартовому. -3. **Достигнутая глубина остаётся навсегда.** Она открывает чекпоинты — и это +2. **Полный вайп оставляет около половины добычи**, и чем вещь реже, тем охотнее + она теряется: обычное доезжает в 68% случаев, реликт — в 24%. +3. **Опыт платится за убийства и сразу.** Выносить надо ВЕЩИ. +4. **Достигнутая глубина остаётся навсегда.** Она открывает чекпоинты — и это единственное, чего смерть не отнимает. +Второе и третье правила заменили прежние «вайп не возвращает ничего» и «опыт +зачисляется при эвакуации». Причина одна на оба: вылазка, кончившаяся вайпом на +тридцатом этаже, не оставляла ни следа, и единственным разумным поведением +становилось уходить наверх при первой царапине. Половина сохраняет ставку +(терять по-прежнему больно) и оставляет смысл идти глубже, а отнимать за смерть +ещё и понимание того, что игрок уже сделал, незачем. + +Замерено на сотне вайпов: доезжает 52% добычи. На одном вайпе это не считается — +«у меня уцелел реликт» и «у меня не уцелел» одинаково ничего не значат. + Третье правило — ответ на «а если этажей десять тысяч». Проходить их заново каждую вылазку не нужно; терять при этом можно только снаряжение. @@ -21,11 +33,16 @@ | | Где | Клавиша | Что делает | |---|---|---|---| | **спуск** | самая дальняя достижимая точка | `F` | следующий этаж, отряд как есть | -| **эвакуация** | там, где отряд вошёл на этаж | `E` | забег окончен, добыча в схроне | +| **аномалия возврата** | рядом со спуском | `E` | забег окончен, добыча в схроне | -Эвакуация совпадает с точкой входа не из экономии: это лестница наверх, и в этом -вся суть напряжения. Спуск лежит на дальнем конце этажа — решив уйти, отряд идёт -обратно через всё, что успел растревожить, раненый и с полными руками. +Оба выхода стоят В КОНЦЕ этажа, рядом. Раньше эвакуация была на входе, и отряд, +решивший уйти, шёл обратно через весь растревоженный этаж. Идея красивая, +играется плохо: обратный путь ничего не решает и ничего не даёт — это ещё раз тот +же этаж. + +Теперь развилка там же, где этаж кончился: **вниз или наверх**. Это и есть +решение, ради которого всё затевалось, а не «дойди обратно, чтобы им +воспользоваться». Кольца на полу разного цвета: зелёное — вниз, голубое — наверх. Оба видны даже в полной темноте. Спутать их означает уйти не туда, и цена ошибки здесь — весь @@ -62,8 +79,12 @@ stash saber plate vest smg bow charm lantern spurs ## Чекпоинты -Каждые `CHECKPOINT_STEP` (5) этажей. Достигнутая глубина открывает все чекпоинты -не глубже неё: дошёл до 13-го — доступны D1, D6, D11. +Каждые `CHECKPOINT_STEP` (50) этажей. Достигнутая глубина открывает все чекпоинты +не глубже неё: дошёл до 120-го — доступны D1, D51, D101. + +Пятьдесят, а не пять: забег — это ДОЛГИЙ спуск, а не пятиэтажная петля. С шагом +в пять вылазка превращалась в перебежку между сохранениями, и весь риск умещался +в пять этажей. ![выбор глубины](ui-depth.png) @@ -200,6 +221,28 @@ stash saber plate vest smg bow charm lantern spurs Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом. Иначе «сел» означало бы «стал слабее», а не «кончился». +## Откуда берутся оперативники + +**Их находят.** Капсула лежит на этаже, как находка, только внутри человек: +подошёл — он вышел и встал в строй. С этого момента он такой же боец, как все, и +теряется так же. + +Редкость двойная (`sim/profile.h`): в среднем раз в `RESCUE_EVERY` (100) этажей и +не чаще, чем раз в `RESCUE_COOLDOWN` (20). Редко намеренно — пятый оперативник +обязан быть событием забега. + +У каждого свои числа: три небольшие прибавки (здоровье, скорость, обзор) из +одного бюджета, розданные случайно. Не «класс» и не «ранг»: разница в характере, +а не в силе, иначе находка превратилась бы в лотерею, где плохой наёмник хуже +пустого слота. + +**Кнопку «нанять за опыт» убрали.** Она была первой версией и оказалась скучной +ровно в том смысле, в каком скучна любая кнопка «стать сильнее»: ничего не +рассказывает и никуда не зовёт. + +Чего ещё нет и что обсуждалось: ростер сверх пятерых, отправка свободных на +задания, пассивные бонусы от сидящих на базе. + ## Отряд из одного ![экран улучшений](ui-upgrades.png) diff --git a/engine/level_gen.cpp b/engine/level_gen.cpp index e237885..79902d1 100644 --- a/engine/level_gen.cpp +++ b/engine/level_gen.cpp @@ -529,9 +529,6 @@ bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const F lvl.squadStart = Vec2{float(sx) + 0.5f, float(sy) + 0.5f}; } - // Вошли и выйдем наверх в одном месте: лестница одна. - lvl.extractPos = lvl.squadStart; - std::vector dist; DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist); @@ -556,6 +553,33 @@ bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const F lvl.exitDistance = dist[size_t(exitTile)]; } + // --- аномалия возврата ---------------------------------------------------- + // Стоит В КОНЦЕ этажа, рядом со спуском, а не на входе. Раньше выход наверх + // совпадал с точкой входа: отряд, решивший уйти, шёл обратно через весь + // растревоженный этаж. Идея красивая, играется плохо — обратный путь ничего + // не решает и ничего не даёт, он просто ещё раз тот же этаж. + // + // Теперь оба выхода в одном месте, и решение принимается ТАМ ЖЕ, где этаж + // кончился: вниз или наверх. Это и есть развилка, ради которой всё + // затевалось, — а не «дойти обратно, чтобы ей воспользоваться». + lvl.extractPos = lvl.exitPos; + if (exitTile >= 0) + { + // Отодвигаем на пару клеток: два кольца в одной точке не различить, и + // спуск бы срабатывал вместо возврата. + const int ex = exitTile % MAP_W, ey = exitTile / MAP_W; + const int off[8][2] = {{2, 0}, {-2, 0}, {0, 2}, {0, -2}, + {2, 2}, {-2, 2}, {2, -2}, {-2, -2}}; + for (const auto& o : off) + { + const int nx = ex + o[0], ny = ey + o[1]; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + if (dist[size_t(ny * MAP_W + nx)] < 0) continue; // недостижимо + lvl.extractPos = Vec2{float(nx) + 0.5f, float(ny) + 0.5f}; + break; + } + } + // Занятые клетки: на спуск и друг на друга ничего не кладём. std::vector taken(size_t(MAP_W) * MAP_H, 0); if (exitTile >= 0) taken[size_t(exitTile)] = 1; diff --git a/engine/progression.cpp b/engine/progression.cpp index dd8a4fe..d42e439 100644 --- a/engine/progression.cpp +++ b/engine/progression.cpp @@ -52,7 +52,12 @@ FloorSpec DescribeFloor(int depth) // Обзор сжимается до упора и там встаёт. Ниже — это уже не «страшно», а // «не видно, куда идти»: игрок перестаёт различать своих, и хоррор // превращается в неудобство. - s.sightScale = std::max(c.sightFloorAt, 1.0f - c.sightPerFloor * over); + s.sightScale = std::max(c.sightFloorAt, c.sightStart - c.sightPerFloor * over); + + // Свет этажа гаснет своей кривой: обзор — это «далеко ли видно», а ambient — + // «светло ли там, где уже прошли». Две разные вещи, и первый этаж обязан + // быть светлым по обеим. + s.ambient = std::max(c.ambientFloorAt, c.ambientStart - c.ambientPerFloor * over); // Опыт растёт медленнее опасности намеренно: спускаться должно быть // выгодно, но не настолько, чтобы прыгать через этажи было единственной diff --git a/engine/progression.h b/engine/progression.h index a5f8340..3000dd7 100644 --- a/engine/progression.h +++ b/engine/progression.h @@ -50,6 +50,9 @@ struct FloorSpec // видно всё меньше. float sightScale = 1.0f; + // Насколько ярко помнится пройденное. Меньше — темнее позади отряда. + float ambient = 0.42f; + // Опыт за этаж — множитель к цене убитого. Растёт медленнее опасности: // спускаться должно быть выгодно, но не настолько, чтобы пропуск этажей // стал единственной разумной игрой. @@ -69,7 +72,12 @@ struct FloorSpec struct FloorCurve { // Опасность: base + linear*(d-1) + quad*(d-1)^2. - float dangerBase = 3.0f; + // + // База 1.6, а не 3.0: первый этаж — это «полупустая лаборатория, две вялых + // твари» (docs/20-gdd.md §1), а не три штуки на одинокого оперативника с + // ножом. Три и были: бюджета 3.0 хватало ровно на трёх бредущих, и стартовый + // забег превращался в отбивание от толпы. + float dangerBase = 2.2f; float dangerLinear = 2.2f; float dangerQuad = 0.10f; @@ -82,10 +90,23 @@ struct FloorCurve int floorsPerDrop = 4; int floorsPerTier = 3; - // Обзор: 1 - perFloor*(d-1), но не ниже floorAt. + // Обзор: sightStart - perFloor*(d-1), но не ниже floorAt. + // + // Начинается ВЫШЕ единицы: наверху ещё работает освещение, и хоррор там не + // из темноты, а из того, что игрок один. Темнота — инструмент глубины, и + // тратить её на первый этаж значит не иметь запаса дальше. + float sightStart = 1.30f; float sightPerFloor = 0.035f; float sightFloorAt = 0.60f; + // Освещение самого этажа: насколько ярко видно УЖЕ ПРОЙДЕННОЕ. Наверху свет + // ещё горит (0.42), внизу остаётся только память о том, где ты был (0.13). + // Это и есть «атмосфера становится страшнее» — не спрайтами, а тем, что + // сзади темнеет. + float ambientStart = 0.42f; + float ambientPerFloor = 0.022f; + float ambientFloorAt = 0.13f; + // Опыт: 1 + perFloor*(d-1). float xpPerFloor = 0.15f; diff --git a/engine/project.cpp b/engine/project.cpp index 93a361d..6e1b94c 100644 --- a/engine/project.cpp +++ b/engine/project.cpp @@ -102,6 +102,13 @@ bool Project::Load(const std::string& projectDir, std::string* err) { Take(parts, 1, curve.sightPerFloor); Take(parts, 2, curve.sightFloorAt); + Take(parts, 3, curve.sightStart); // необязательный третий: с чего начинать + } + else if (key == "ambient") + { + Take(parts, 1, curve.ambientStart); + Take(parts, 2, curve.ambientPerFloor); + Take(parts, 3, curve.ambientFloorAt); } else if (key == "xp") Take(parts, 1, curve.xpPerFloor); else if (key == "themes") diff --git a/projects/descent/catalog/entities.txt b/projects/descent/catalog/entities.txt index c394083..14aed26 100644 --- a/projects/descent/catalog/entities.txt +++ b/projects/descent/catalog/entities.txt @@ -24,7 +24,7 @@ # намеренно: на двадцатом этаже одиночный бредущий занимал бы место, которое # должно достаться чему-то, чего игрок ещё не видел. entity shambler class=monster depth=1 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER -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=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 entity brute class=monster depth=7 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE diff --git a/projects/descent/project.txt b/projects/descent/project.txt index 38f961b..b8b4cf0 100644 --- a/projects/descent/project.txt +++ b/projects/descent/project.txt @@ -15,7 +15,10 @@ startdepth 1 # Опасность: база, линейный рост, квадратичный хвост. # Чистая линейка читается как «то же самое, только больше»; чистая экспонента # обрывает забег там, где игрок начал понимать правила. -danger 3.0 2.2 0.10 +# +# База 2.2 — ровно на двух бредущих: первый этаж — полупустая лаборатория с парой вялых тварей, а не +# три штуки на одинокого оперативника с ножом. +danger 2.2 2.2 0.10 # Плотность точек спавна: прирост за этаж и упор. Выше упора все точки # срабатывают всегда, то есть этажи становятся одинаковыми. @@ -26,9 +29,14 @@ density 0.05 1.5 # опережать снаряжение. loot 2 4 3 -# Обзор: сколько сжимается за этаж и ниже чего не опускается. Ниже упора это -# уже не «страшно», а «не видно, куда идти». -sight 0.035 0.60 +# Обзор: сколько сжимается за этаж, ниже чего не опускается и с чего начинает. +# Ниже упора это уже не «страшно», а «не видно, куда идти», а начинать надо +# ВЫШЕ единицы: наверху ещё работает освещение. +sight 0.035 0.60 1.30 + +# Свет этажа: с чего начинает, сколько гаснет за этаж, ниже чего не падает. +# Это яркость УЖЕ ПРОЙДЕННОГО — то, из-за чего внизу темнеет позади отряда. +ambient 0.42 0.022 0.13 # Опыт за этаж. Медленнее опасности: спускаться выгодно, но не настолько, # чтобы прыгать через этажи стало единственной разумной игрой. diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 78376cb..e05ee5f 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -10,6 +10,7 @@ #include "game.h" #include "sim/craft.h" +#include "sim/loot.h" #include "sim/trigger.h" #include "ui/menu.h" @@ -1831,26 +1832,35 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t } } - // Найм: стоит опыта, и опыт списывается. - pr.bankedXp = RecruitCost(1) + RecruitCost(2); - const bool hired = pr.Recruit(); - const float leftAfterHire = pr.bankedXp; - game.NewRun(1); + // Наёмник НАХОДИТСЯ, а не покупается: подкладываем капсулу под ноги + // бойцу и смотрим, встал ли человек в строй и со своими ли числами. + SpawnFloorItem(game.world, ItemInstance(ItemId::CAPSULE), game.squad.agents[0].pos, 0.0f); + for (int k = 0; k < 30 && mannedNow() < 2; ++k) game.Step(FIXED_DT); + const int mannedPair = mannedNow(); + const bool rescued = mannedPair == 2; + const MercStats got = pr.merc[1]; + const bool hasStats = got.Any(); + const bool inProfile = pr.squadSize == 2; - // Потолок: шестого не нанять ни за какие деньги. + // Потолок: шестого не втиснуть никакой капсулой. pr.squadSize = NUM_AGENTS; - pr.bankedXp = 1e9f; - const bool overHired = pr.Recruit(); + const bool overFull = pr.CanRescue(); - std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), после найма %d, " - "цена %.0f (осталось %.0f), сверх потолка %s\n" + std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), капсула подняла до %d\n" + " числа наёмника: hp +%.0f, скорость +%.2f, обзор +%.2f;" + " сверх потолка %s\n" " одиночка с ножом на первом этаже: %s, здоровье %.0f -> %.0f, " "тварей подошло %d, убил %d\n", - mannedSolo, aliveSolo, mannedPair, double(RecruitCost(1)), - double(leftAfterHire), overHired ? "НАНЯЛСЯ" : "отказ", + mannedSolo, aliveSolo, mannedPair, double(got.hp), double(got.speed), + double(got.sight), overFull ? "ВЛЕЗ" : "отказ", soloLived ? "выжил" : "ПОГИБ", double(hpStart), double(hpLeft), engaged, killed); + if (!inProfile && rescued) + { + std::printf(" [FAIL] найденный не записался в профиль\n"); + ++bad; + } if (!soloLived) { @@ -1876,25 +1886,174 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t aliveSolo); ++bad; } - if (!hired || mannedPair != 2) + if (!rescued) { - std::printf(" [FAIL] найм не довёл бойца до этажа: занято %d\n", mannedPair); + std::printf(" [FAIL] капсула не поставила человека в строй: занято %d\n", + mannedPair); ++bad; } - if (leftAfterHire >= RecruitCost(1) + RecruitCost(2)) + if (rescued && !hasStats) { - std::printf(" [FAIL] найм бесплатный: опыт не списался\n"); + std::printf(" [FAIL] у найденного нет своих чисел: это слот, а не человек\n"); ++bad; } - if (overHired) + if (overFull) { - std::printf(" [FAIL] нанят шестой — потолок отряда не держит\n"); + std::printf(" [FAIL] шестой влезает — потолок отряда не держит\n"); ++bad; } pr.squadSize = sizeBefore; } + // ------------------------------------------------------------------------- + // Вайп: половина добычи уцелевает, и редкое теряется чаще. + // + // Меряется на СОТНЕ вайпов, а не на одном: «у меня уцелел реликт» и «у меня + // не уцелел» — одинаково бессмысленные наблюдения. Проверяются два разных + // утверждения, и их легко перепутать: + // + // 1. в среднем доезжает около половины; + // 2. обычное доезжает ЧАЩЕ реликта. + // + // Второе важнее: без него правило вырождается в «теряется половина бинтов». + // ------------------------------------------------------------------------- + { + Profile& pr = *game.profile; + const Profile savedProfile = pr; + + int commonKept = 0, commonAll = 0; + int relicKept = 0, relicAll = 0; + + for (int round = 0; round < 100; ++round) + { + pr.stash.clear(); + + // Сумка отряда: поровну обычного и реликтов, чтобы сравнивать + // доли, а не количества. + game.squad.bag.Clear(); + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) + { + ItemInstance it(ItemId::JACKET); + if (i % 2 == 1) + { + it.rarity = Rarity::RELIC; + it.affix[it.count++] = Affix{AffixTag::HEALTH, 5, 0, 20.0f}; + ++relicAll; + } + else + { + ++commonAll; + } + game.squad.bag.slots[i] = it; + } + + // Сид вайпа считается от сида уровня и глубины, поэтому крутим их: + // иначе сто вайпов дали бы сто одинаковых итогов. + game.levelSeed = uint32_t(round) * 2654435761u + 17u; + game.depth = 10 + round % 7; + + // Считаем ВСЁ, что отряд нёс, а не только сумку: спасение забирает + // и надетое. Первая версия проверки считала одну сумку и получила + // «обычного доехало 112%» — то есть больше, чем клала. + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Agent& ag = game.squad.agents[i]; + if (!ag.present) continue; + if (const Inventory* inv = game.world.Get(ag.self)) + { + const ItemInstance worn[3] = {inv->weapon, inv->armor, inv->trinket}; + for (const ItemInstance& w : worn) + { + if (w.Empty()) continue; + if (w.rarity == Rarity::RELIC) ++relicAll; + else ++commonAll; + } + } + } + + for (int i = 0; i < NUM_AGENTS; ++i) game.squad.agents[i].alive = false; + game.WipeRun(); + + for (const ItemInstance& it : pr.stash) + { + if (it.rarity == Rarity::RELIC) ++relicKept; + else ++commonKept; + } + } + + const float commonRate = float(commonKept) / float(std::max(1, commonAll)); + const float relicRate = float(relicKept) / float(std::max(1, relicAll)); + const float total = float(commonKept + relicKept) / float(std::max(1, commonAll + relicAll)); + + std::printf("\nвайп: доезжает %.0f%% добычи (обычное %.0f%%, реликты %.0f%%)\n", + double(total * 100.0f), double(commonRate * 100.0f), + double(relicRate * 100.0f)); + + if (total < 0.35f || total > 0.65f) + { + std::printf(" [FAIL] после вайпа доезжает %.0f%% — это не «половина»\n", + double(total * 100.0f)); + ++bad; + } + if (relicRate >= commonRate) + { + std::printf(" [FAIL] реликты теряются не чаще обычного: %.0f%% против %.0f%%\n", + double(relicRate * 100.0f), double(commonRate * 100.0f)); + ++bad; + } + + pr = savedProfile; + game.squad.bag.Clear(); + } + + // ------------------------------------------------------------------------- + // Опыт платится за УБИЙСТВА и сразу. Раньше он зачислялся только при + // эвакуации, то есть вайп отнимал ещё и понимание того, что игрок уже + // сделал. Теперь выносить надо ВЕЩИ, а не опыт. + // ------------------------------------------------------------------------- + { + Profile& pr = *game.profile; + game.NewRun(1); + + const float bankBefore = pr.bankedXp; + ClearField(game); + + // Убиваем тварь руками: прогон спуска ни с кем не воюет, и ждать + // естественной смерти тут нечего. + int victim = -1; + for (int t = 0; t < int(game.targets.size()); ++t) + if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy()) + { + victim = t; + break; + } + + bool killed = false; + if (victim >= 0) + { + Target& t = game.targets[size_t(victim)]; + t.alive = true; + t.countedDead = false; + t.hp = 1.0f; + game.Step(FIXED_DT); + t.hp = 0.0f; + t.alive = false; + game.Step(FIXED_DT); + killed = true; + } + + const float bankAfter = pr.bankedXp; + std::printf("опыт: банк %.0f -> %.0f за убийство БЕЗ эвакуации\n", double(bankBefore), + double(bankAfter)); + + if (!killed || bankAfter <= bankBefore) + { + std::printf(" [FAIL] опыт за убийство не зачислен — его снова надо выносить\n"); + ++bad; + } + } + // ------------------------------------------------------------------------- // Опыт тратится. Проверяется цепочка целиком: купили — подействовало на // отряд — пережило запись профиля. Число в профиле, которое ни на что не @@ -2018,17 +2177,12 @@ int RunUiCheck() // Мало открыть его: покупка обязана работать с той же кнопки. { Ui ui; - const int sizeBefore = profile.squadSize; - UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::COUNT)); // строка найма - const bool hired = profile.squadSize > sizeBefore; - const int lvlBefore = profile.UpgradeLevel(Upgrade::AMMO); UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::AMMO)); const bool bought = profile.UpgradeLevel(Upgrade::AMMO) > lvlBefore; - std::printf("\nулучшения: найм %s, покупка %s\n", hired ? "работает" : "НЕ работает", - bought ? "работает" : "НЕ работает"); - if (!hired || !bought) + std::printf("\nулучшения: покупка %s\n", bought ? "работает" : "НЕ работает"); + if (!bought) { std::printf(" [FAIL] кнопки экрана улучшений не покупают\n"); ++bad; diff --git a/src/game.cpp b/src/game.cpp index ff67598..d3e061e 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -75,13 +75,79 @@ void Game::Extract() void Game::WipeRun() { - // Ничего не уносится. Снаряжение сбрасывается к стартовому, но схрон и - // чекпоинты остаются: потерять вылазку — не то же самое, что потерять - // всё нажитое, и второе прекращало бы игру, а не наказывало. - if (profile) profile->ResetLoadouts(); + // Отряд полёг — но выносится ПОЛОВИНА добычи, а не ноль. + // + // Ноль был честным правилом и плохой игрой: вылазка, кончившаяся вайпом на + // тридцатом этаже, не оставляла ни следа, и единственным разумным поведением + // становилось уходить наверх при первой царапине. Половина сохраняет ставку + // (терять по-прежнему больно) и оставляет смысл идти глубже. + // + // ЧТО ИМЕННО теряется — решает редкость: чем вещь лучше, тем охотнее она + // роняется. Иначе половина всегда состояла бы из бинтов, а реликты доезжали + // бы домой сами. + if (profile) + { + SalvageAfterWipe(); + profile->ResetLoadouts(); + } FinishRun(RunOutcome::WIPED); } +// Шанс УЦЕЛЕТЬ по редкости. В среднем половина, но обычное доезжает часто, +// а реликт — редко: 0.68 / 0.52 / 0.38 / 0.24. +static float SalvageChance(Rarity r) +{ + switch (r) + { + case Rarity::MARKED: return 0.52f; + case Rarity::PROTOTYPE: return 0.38f; + case Rarity::RELIC: return 0.24f; + default: return 0.68f; + } +} + +void Game::SalvageAfterWipe() +{ + if (!profile) return; + + // Сид от состояния забега, а не от времени: тот же прогон обязан давать тот + // же итог, иначе ни воспроизвести жалобу, ни проверить правило. + uint32_t rng = uint32_t(levelSeed) * 2654435761u + uint32_t(depth) * 9176u + 7u; + auto roll = [&rng]() { + rng = rng * 1664525u + 1013904223u; + return float((rng >> 8) & 0xFFFFu) / 65536.0f; + }; + + report.itemsBrought = 0; + report.itemsLost = 0; + + auto trySave = [&](const ItemInstance& it) { + if (it.Empty()) return; + if (roll() < SalvageChance(it.rarity) && profile->StashPush(it)) ++report.itemsBrought; + else ++report.itemsLost; + }; + + // Считается ВСЁ, что отряд нёс: и надетое павшими, и общая сумка. Разделять + // их тут незачем — выживших нет ни одного, и «на ком именно» была вещь, + // роли уже не играет. + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Agent& a = squad.agents[i]; + if (!a.present) continue; + if (const Inventory* inv = world.Get(a.self)) + { + trySave(inv->weapon); + trySave(inv->armor); + trySave(inv->trinket); + } + } + for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) trySave(squad.bag.slots[s]); + squad.bag.Clear(); + + // Материалы уходят целиком: они валюта, и делить их «по редкости» не по + // чему. Вайп обязан стоить чего-то безусловно. +} + void Game::FinishRun(RunOutcome outcome) { report.outcome = outcome; @@ -95,8 +161,8 @@ void Game::FinishRun(RunOutcome outcome) if (outcome == RunOutcome::EXTRACTED) { ++profile->extractions; - profile->bankedXp += xp; - // Материалы — та же ставка, что и добыча: донёс или потерял. + // Опыт здесь НЕ начисляется: он уже зачислен по убийствам (AwardKillXp). + // Материалы — по-прежнему ставка: донёс или потерял. for (int m = 0; m < MATERIAL_COUNT; ++m) profile->material[m] += runMaterials[m]; } else @@ -190,6 +256,7 @@ void Game::ApplyProfileLoadout() if (!inv) continue; *inv = profile->loadout[i]; + a.merc = profile->merc[i]; // Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого // боец выходит с карабином по умолчанию, что бы ни лежало в профиле. EquipWeapon(world, a, inv->weapon); @@ -302,6 +369,7 @@ void Game::BuildFloor(const SquadCarry* carry) SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns); SpawnLootFromLevel(world, level.loot, depth, levelSeed, &unknownLoot); levelUnknownSpawns += unknownLoot; + MaybeSpawnCapsule(); } else { @@ -697,8 +765,14 @@ void Game::Step(float dt) // --- 10. находки ----------------------------------------------------------- // После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге, // обязан быть подбираемым сразу, а не через кадр. + int rescuedBy = -1; lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag, - squad.Carriers(), squad.carryBonus, &events, dt); + squad.Carriers(), squad.carryBonus, &events, dt, &rescuedBy); + + // Найденный оперативник встаёт в строй ТУТ ЖЕ, посреди этажа: капсула — + // это место, до которого дошли, а не покупка (sim/profile.h). + if (rescuedBy >= 0) RescueOperative(rescuedBy); + // --- 11. опыт за убитых ---------------------------------------------------- AwardKillXp(); @@ -749,6 +823,12 @@ void Game::AwardKillXp() xp += danger * scale; + // И СРАЗУ в профиль. Опыт больше не надо выносить: он платится за + // работу, а не за удачный выход. Вынос — про ВЕЩИ, и ставка осталась + // на них; заставлять игрока терять ещё и понимание того, что он уже + // сделал, значит наказывать дважды за одну смерть. + if (profile) profile->bankedXp += danger * scale; + // Ядра падают только со «своих» этажей — тема и глубина решают, что // именно (sim/craft.h). Это и есть причина возвращаться на средние // глубины, когда рекорд ушёл вниз. @@ -757,6 +837,80 @@ void Game::AwardKillXp() } } +// ----------------------------------------------------------------------------- +// Капсула вскрыта: свободный слот занимает человек. +// +// Он приходит СО СВОИМИ числами (Profile::merc) и со стартовым снаряжением: +// найденный в капсуле не таскает с собой плиту и магнум. Числа сохранятся в +// профиле только если он ДОЙДЁТ до выхода живым — как и всё остальное. +// ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// Капсула на этаже. Редко и с двумя границами (sim/profile.h): +// +// * в среднем раз в RESCUE_EVERY этажей — иначе пятый оперативник перестаёт +// быть событием забега; +// * не чаще раза в RESCUE_COOLDOWN — иначе две подряд закрывают полотряда за +// один спуск, и вся редкость оказывается на бумаге. +// +// Кладётся В ТОЧКУ ЛУТА: там гарантированно достижимый пол. Своя раскладка тут +// не нужна — капсула и есть находка, просто вместо вещи в ней человек. +// ----------------------------------------------------------------------------- +void Game::MaybeSpawnCapsule() +{ + if (!profile || !combatMode || !profile->CanRescue()) return; + if (level.loot.empty()) return; + + const int since = depth - profile->lastRescueDepth; + if (profile->lastRescueDepth > 0 && since < RESCUE_COOLDOWN) return; + + // Бросок от сида уровня: тот же этаж обязан давать ту же встречу. + uint32_t rng = uint32_t(levelSeed) * 2246822519u + uint32_t(depth) * 3266489917u; + rng = rng * 1664525u + 1013904223u; + const float roll = float((rng >> 8) & 0xFFFFu) / 65536.0f; + if (roll >= 1.0f / float(RESCUE_EVERY)) return; + + const size_t at = size_t(rng % uint32_t(level.loot.size())); + SpawnFloorItem(world, ItemInstance(ItemId::CAPSULE), level.loot[at].pos); +} + +void Game::RescueOperative(int finder) +{ + (void)finder; + if (!profile || !profile->CanRescue()) return; + + // Первый незанятый слот. Слоты по порядку, а не «любой»: строй читается по + // номерам, и дыра в середине означала бы, что клин собран не по правилам. + int slot = -1; + for (int i = 0; i < NUM_AGENTS && slot < 0; ++i) + if (!squad.agents[i].present) slot = i; + if (slot < 0) return; + + uint32_t rng = uint32_t(levelSeed) * 40503u + uint32_t(depth) * 7919u + uint32_t(slot); + profile->merc[slot] = RollMerc(rng); + ++profile->squadSize; + profile->lastRescueDepth = depth; + + Agent& a = squad.agents[slot]; + a.present = true; + a.alive = true; + squad.roster = std::max(squad.roster, slot + 1); + + // Снаряжение — стартовое: человек выходит из капсулы, а не из оружейной. + Profile fresh; + fresh.ResetLoadouts(); + if (Inventory* inv = world.Get(a.self)) + { + *inv = fresh.loadout[slot]; + EquipWeapon(world, a, inv->weapon); + } + a.merc = profile->merc[slot]; + SyncLoadout(world, a); + a.hp = a.maxHp; + a.ammoReserve = combatMode ? int(g_tune.startAmmo) : -1; + + events.Push(CombatEventKind::LOOT_TAKEN, slot, a.pos, Vec2{}); +} + void Game::UpdateExit() { if (!levelGenerated) { atExit = false; atExtract = false; return; } diff --git a/src/game.h b/src/game.h index 7adb56b..4cd6201 100644 --- a/src/game.h +++ b/src/game.h @@ -215,6 +215,12 @@ private: // Собрать снаряжение бойцов в профиль. onlySurvivors — то, что унесли; // иначе всё разом (нужно между этажами, там ничего не теряется). void HarvestToProfile(bool onlySurvivors); + // Вайп: половина добычи уцелевает, редкое теряется чаще (sim/affix.h). + void SalvageAfterWipe(); + // Капсула вскрыта: найденный оперативник встаёт в строй (sim/profile.h). + void RescueOperative(int finder); + // Раз в сотню этажей и не чаще раза в двадцать: капсула на полу. + void MaybeSpawnCapsule(); // Раздать бойцам снаряжение из профиля. Пустой профиль — стартовый набор. void ApplyProfileLoadout(); // Расходники применяются сами: ручных действий в игре нет. diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp index 2f1bb2d..3b7dd53 100644 --- a/src/render/lighting.cpp +++ b/src/render/lighting.cpp @@ -215,7 +215,7 @@ void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float for (int i = 0; i < MAP_W * MAP_H; ++i) { const float seen = vis[i]; - if (seen > 0.02f) mem[i] = std::max(mem[i], g_tune.fogMemoryLevel); + if (seen > 0.02f) mem[i] = std::max(mem[i], std::max(g_tune.fogMemoryLevel, ambient)); float lvl = std::max(mem[i], seen * g_tune.sightBrightness) + dyn[i]; tile[i] = Clampf(lvl, 0.0f, 1.0f); diff --git a/src/render/lighting.h b/src/render/lighting.h index a362747..74edad0 100644 --- a/src/render/lighting.h +++ b/src/render/lighting.h @@ -62,6 +62,11 @@ struct Lighting // и первый этаж, и песочница приёмки. float sightScale = 1.0f; + // Освещение этажа: насколько ярко видно уже пройденное (engine/progression.h). + // Не «яркость картинки»: непройденное остаётся чёрным, иначе туман войны + // перестал бы быть туманом и твари были бы видны сквозь стены. + float ambient = 0.42f; + void Reset(); // Забрать вспышки из журнала боя (то, что накопилось за кадр). diff --git a/src/render/view.cpp b/src/render/view.cpp index 961a05f..cffe622 100644 --- a/src/render/view.cpp +++ b/src/render/view.cpp @@ -177,6 +177,7 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa // View переживает Reset, и забытое значение уехало бы вниз вместе с // отрядом и осталось бы там навсегда. view.lighting.sightScale = game.level.floor.sightScale; + view.lighting.ambient = game.level.floor.ambient; view.lighting.Update(squad.agents.data(), NUM_AGENTS, game.map, frameDt); // --- сцена ----------------------------------------------------------------- diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 0a201e3..5aa95e6 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -234,13 +234,15 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& // пришлось бы везде, где скорость читается, а смысла в этом ноль. a.load = inv->Load() + bagShare; a.speedMul = inv->SpeedMul() * a.mods.speedMul * a.trig.SpeedMul() * + (1.0f + a.merc.speed) * CarryMul(a.load, buffs.carry + a.mods.carry); // Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и // купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел // фонарь — светить нечем, сколько бы отряд ни вложил в глаза. - a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight + a.mods.sightMul - 1.0f) * + a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight + a.mods.sightMul - 1.0f + + a.merc.sight) * (buffs.lightOut ? DARK_SIGHT_MUL : 1.0f); - a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp + a.mods.hp; + a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp + a.mods.hp + a.merc.hp; a.held = inv->weapon; if (a.hp > a.maxHp) a.hp = a.maxHp; } diff --git a/src/sim/agent.h b/src/sim/agent.h index 7268d65..477e0d4 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -17,6 +17,7 @@ #include "sim/items.h" #include "sim/loadout.h" #include "sim/target.h" +#include "sim/merc.h" #include "sim/trigger.h" #include "tuning.h" #include "engine/tilemap.h" @@ -114,6 +115,10 @@ struct Agent // считают откаты каждый свой. TriggerState trig; + // Личные числа найденного оперативника (sim/profile.h). У первого нули: он + // не найден, он и есть начало игры. + MercStats merc; + // --- служебное (рендер/отладка/метрики), на правила не влияет --- Vec2 prevPos{}; // для интерполяции кадра bool hasLane = false; // есть валидная линия огня прямо сейчас diff --git a/src/sim/items.h b/src/sim/items.h index 8099501..0f675a6 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -32,6 +32,7 @@ enum class ItemKind : uint8_t ARMOR, // на корпус TRINKET, // в карман: свет, чутьё, живучесть CONSUMABLE, // расходник + RESCUE, // капсула с оперативником: не в сумку, а в отряд }; enum class ItemId : uint8_t @@ -56,6 +57,7 @@ enum class ItemId : uint8_t STIMPACK, AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем BATTERY, // батарея: уходит в фонарь, когда тот сел + CAPSULE, // капсула: внутри живой оперативник COUNT, }; @@ -364,6 +366,12 @@ inline ItemDef ItemDefOf(ItemId id) d.name = "BATTERY"; d.kind = ItemKind::CONSUMABLE; d.charge = LIGHT_FULL; d.weight = 0.3f; d.r = 150; d.g = 210; d.b = 200; break; + // Капсула не поднимается в сумку и не весит: подошёл — человек вышел и + // встал в строй (sim/profile.h). Вес нулевой не по забывчивости: то, + // что идёт своими ногами, отряд не несёт. + case ItemId::CAPSULE: + d.name = "CAPSULE"; d.kind = ItemKind::RESCUE; + d.r = 210; d.g = 235; d.b = 255; break; default: break; } diff --git a/src/sim/loot.cpp b/src/sim/loot.cpp index d22cc3f..5c5dabf 100644 --- a/src/sim/loot.cpp +++ b/src/sim/loot.cpp @@ -15,7 +15,8 @@ void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos, float p } int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag, - int carriers, float carryBonus, CombatEventLog* events, float dt) + int carriers, float carryBonus, CombatEventLog* events, float dt, + int* rescued) { std::vector& items = world.All(); if (items.empty()) return 0; @@ -44,6 +45,17 @@ int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& // Не влезает — предмет остаётся лежать. Причин две: кончились // ячейки или кончилась переноска (sim/items.h). Обе одинаково // обязаны быть видны как вещь под ногами, а не как пропажа. + // Капсула — не добыча: её не кладут в сумку и она не занимает + // места. Подошёл боец — оперативник вышел, и отряд стал больше. + if (ItemDefOf(it.id).kind == ItemKind::RESCUE) + { + if (rescued) *rescued = i; + it.taken = true; + ++taken; + if (events) events->Push(CombatEventKind::LOOT_TAKEN, i, it.pos, Vec2{}); + break; + } + const Inventory* inv = world.Get(a.self); const float weaponLoad = inv ? inv->Load() : 0.0f; // TakeItem, а не Take: через ItemId в сумку легла бы вещь без diff --git a/src/sim/loot.h b/src/sim/loot.h index df42e80..722bb88 100644 --- a/src/sim/loot.h +++ b/src/sim/loot.h @@ -54,5 +54,9 @@ void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos, // под ногами предмет, а не как молча пропавшая находка. // Подбирают В ОБЩУЮ СУМКУ: кто прошёл по предмету, тот его и поднял, но // достанется он всему отряду. carriers — на скольких живых делится ноша. +// rescued — сюда пишется индекс бойца, наткнувшегося на капсулу, или -1. +// Саму капсулу подбор не «поднимает»: оперативник встаёт в строй, а это уже +// дело отряда, и знать про него sim/loot.cpp не обязан. int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag, - int carriers, float carryBonus, CombatEventLog* events, float dt); + int carriers, float carryBonus, CombatEventLog* events, float dt, + int* rescued = nullptr); diff --git a/src/sim/merc.h b/src/sim/merc.h new file mode 100644 index 0000000..a02bdf7 --- /dev/null +++ b/src/sim/merc.h @@ -0,0 +1,22 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Личные числа оперативника. +// +// Крошечный отдельный заголовок по той же причине, что и engine/theme.h: их +// знают двое и с разных сторон. Профиль (sim/profile.h) их ХРАНИТ между +// вылазками, боец (sim/agent.h) их ПРИМЕНЯЕТ. Свести оба в один файл значило бы +// затащить профиль игрока в компонент ECS. +// +// Три прибавки, и намеренно небольшие. Найденный человек должен отличаться от +// соседа — но отличаться характером, а не рангом: «крепкий», «быстрый», +// «глазастый». Разница в полтора раза превратила бы находку в лотерею, где +// плохой наёмник хуже пустого слота. +// ----------------------------------------------------------------------------- +struct MercStats +{ + float hp = 0.0f; // прибавка к запасу здоровья + float speed = 0.0f; // прибавка к скорости, долей + float sight = 0.0f; // прибавка к обзору, долей + + bool Any() const { return hp != 0.0f || speed != 0.0f || sight != 0.0f; } +}; diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index 01e12a0..aad90c7 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -147,6 +147,25 @@ bool Profile::StashRemoveAt(int index) return true; } +MercStats RollMerc(uint32_t& rng) +{ + auto roll = [&rng]() { + rng = rng * 1664525u + 1013904223u; + return float((rng >> 8) & 0xFFFFu) / 65536.0f; + }; + + // Три доли одного бюджета: сумма примерно постоянна, раскладка случайна. + // Именно поэтому найденный человек ощущается человеком, а не уровнем. + float w[3] = {roll(), roll(), roll()}; + const float sum = std::max(0.01f, w[0] + w[1] + w[2]); + + MercStats m; + m.hp = 26.0f * (w[0] / sum); + m.speed = 0.16f * (w[1] / sum); + m.sight = 0.22f * (w[2] / sum); + return m; +} + void Profile::ResetLoadouts() { for (int i = 0; i < NUM_AGENTS; ++i) @@ -226,6 +245,23 @@ bool Profile::Load(const std::string& path, std::string* err) if (t[0] == "maxdepth" && t.size() >= 2) { p.maxDepth = std::max(1, text::ToInt(t[1], 1)); continue; } // mat <лом> <био> <крио> <осколок> <шлак> + // merc <слот> <скорость> <обзор> + if (t[0] == "merc" && t.size() >= 5) + { + const int slot = text::ToInt(t[1], -1); + if (slot >= 0 && slot < NUM_AGENTS) + { + p.merc[slot].hp = text::ToFloat(t[2], 0.0f); + p.merc[slot].speed = text::ToFloat(t[3], 0.0f); + p.merc[slot].sight = text::ToFloat(t[4], 0.0f); + } + continue; + } + if (t[0] == "rescued" && t.size() >= 2) + { + p.lastRescueDepth = std::max(0, text::ToInt(t[1], 0)); + continue; + } if (t[0] == "mat") { for (int m = 0; m < MATERIAL_COUNT && size_t(1 + m) < t.size(); ++m) diff --git a/src/sim/profile.h b/src/sim/profile.h index c8f14b7..d41c7f1 100644 --- a/src/sim/profile.h +++ b/src/sim/profile.h @@ -24,13 +24,14 @@ #include #include "sim/craft.h" +#include "sim/merc.h" #include "sim/items.h" #include "tuning.h" -// Через сколько этажей ставится чекпоинт. Пять — это «достаточно редко, чтобы -// дойти до следующего было событием, и достаточно часто, чтобы потеря забега -// не откатывала в самое начало». -constexpr int CHECKPOINT_STEP = 5; +// Через сколько этажей ставится чекпоинт. Пятьдесят: забег — это ДОЛГИЙ спуск, +// а не пятиэтажная петля. Чекпоинт через каждые пять превращал вылазку в +// перебежку между сохранениями, и весь риск умещался в пять этажей. +constexpr int CHECKPOINT_STEP = 50; // Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без // потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает. @@ -71,28 +72,28 @@ enum class Upgrade : uint8_t constexpr int UPGRADE_MAX_LEVEL = 5; // ----------------------------------------------------------------------------- -// Найм. Стоит ОТДЕЛЬНО от улучшений и считается по своей кривой. +// Наёмники. НАХОДЯТСЯ, а не покупаются. // -// Оперативник — не пятый уровень характеристики, а ещё один человек: он несёт -// своё снаряжение, занимает слот строя, стреляет по своей линии и гибнет -// отдельно. Поставь его в общий ряд с «+20 патронов» — и он стал бы покупкой -// того же порядка, хотя меняет игру целиком. +// Покупка за опыт была первой версией и оказалась скучной ровно в том смысле, +// в каком скучна любая кнопка «стать сильнее»: она ничего не рассказывает и +// никуда не зовёт. Найденный человек — это место, до которого надо дойти, +// и риск, который надо пережить вместе с ним. // -// Кривая вчетверо на человека: 600 / 2400 / 9600 / 38400, всего 51000. Против -// дохода (`--tool economy`: этаж 10 даёт 726, накопленным к 20 — 21671) это -// значит примерно так: второй приходит через две-три ранние вылазки, третий — -// когда отряд уверенно берёт десятый этаж, пятый — ближе к двадцать пятому. +// Встречается редко: примерно раз в сто этажей и не чаще, чем раз в двадцать. +// Редко намеренно — пятый оперативник обязан быть событием забега, а не +// строкой в списке покупок. // -// Дорого намеренно. Одинокий оперативник с ножом — это ПЕРВОЕ впечатление от -// игры, и продать его за одну вылазку значило бы выбросить весь хоррор начала: -// вдвоём уже не страшно. +// У каждого свои числа. Не «класс» и не «профессия»: три небольшие прибавки, +// которые видно на экране отряда и которые делают ЭТОГО человека отличным от +// соседа. Роль и специализация — следующая веха, тут нарочно минимум. // ----------------------------------------------------------------------------- -inline float RecruitCost(int currentSize) -{ - float c = 600.0f; - for (int i = 1; i < currentSize; ++i) c *= 4.0f; - return c; -} +constexpr int RESCUE_EVERY = 100; // средняя редкость встречи, в этажах +constexpr int RESCUE_COOLDOWN = 20; // ближе этого две капсулы не встречаются + +// Бросок наёмника. Сумма прибавок примерно одинакова, а распределение — нет: +// один крепкий, другой быстрый, третий глазастый. Так двое найденных не +// оказываются одним и тем же человеком с другим именем. +MercStats RollMerc(uint32_t& rng); // Шаг улучшения за уровень. constexpr float UPGRADE_AMMO_STEP = 20.0f; @@ -120,13 +121,14 @@ const char* UpgradeEffect(Upgrade u); // 3 — размер отряда (найм): раньше бойцов всегда было пять // 4 — экземпляры предметов: редкость и аффиксы в токене после '@' // 5 — материалы: строка mat +// 6 — наёмники: строка merc и глубина последней встречи // // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // читается вовсе. Второе важнее первого: молча обнулить профиль, который // записала более новая сборка, — это стереть десятки часов, а отказ читать // всего лишь просит не запускать старую сборку. // ----------------------------------------------------------------------------- -constexpr int PROFILE_VERSION = 5; +constexpr int PROFILE_VERSION = 6; struct Profile { @@ -156,17 +158,16 @@ struct Profile // теряет только снаряжение, как и все. int squadSize = 1; - bool CanRecruit() const - { - return squadSize < NUM_AGENTS && bankedXp >= RecruitCost(squadSize); - } - bool Recruit() - { - if (!CanRecruit()) return false; - bankedXp -= RecruitCost(squadSize); - ++squadSize; - return true; - } + // Числа найденных оперативников по слотам. У первого их нет: он и есть + // тот, с кого игра начинается. + MercStats merc[NUM_AGENTS]; + + // На какой глубине последний раз встретилась капсула. Нужна редкости: + // «не чаще раза в двадцать этажей» считается отсюда. + int lastRescueDepth = 0; + + // Место в отряде ещё есть? + bool CanRescue() const { return squadSize < NUM_AGENTS; } // Снаряжение бойцов ровно в том виде, в каком они вернулись. Массив всегда // на все слоты: незанятые просто пустые. diff --git a/src/sim/spawn_catalog.cpp b/src/sim/spawn_catalog.cpp index 3bec53b..3732a6f 100644 --- a/src/sim/spawn_catalog.cpp +++ b/src/sim/spawn_catalog.cpp @@ -49,6 +49,7 @@ const LootRow LOOT_ROWS[] = { {"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM}, {"spurs", ItemId::SPURS}, {"ammo", ItemId::AMMO}, {"battery", ItemId::BATTERY}, + {"capsule", ItemId::CAPSULE}, {"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK}, }; diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 7c4aaba..87ae6f2 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -447,7 +447,7 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) return; case Screen::UPGRADE: - out.Add(UiPart::UPGRADE_ITEM, UP_RECRUIT, UpRowRect(0)); + out.Add(UiPart::UPGRADE_ITEM, UP_RECRUIT, UpRowRect(0)).enabled = false; for (int i = 0; i < int(Upgrade::COUNT); ++i) out.Add(UiPart::UPGRADE_ITEM, i, UpRowRect(i + 1)); return; @@ -739,8 +739,9 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) // Покупка молча не проваливается: не хватило опыта — уровень // остался прежним, и это видно на той же строке. if (!game.profile) return; - if (index == UP_RECRUIT) game.profile->Recruit(); - else game.profile->Buy(Upgrade(index)); + // Нулевая строка — состав отряда, и она НЕ кнопка: оперативники + // находятся на этажах (sim/profile.h). + if (index != UP_RECRUIT) game.profile->Buy(Upgrade(index)); return; case UiPart::MENU_ITEM: @@ -1337,31 +1338,23 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const std::snprintf(line, sizeof(line), "BANKED XP %d", int(banked)); TextCentered(l, line, 58, 8, COL_FAINT); - // --- найм: первая строка и единственная покупка, которая приводит - // ЧЕЛОВЕКА, а не число. + // --- состав отряда: показ, а не покупка ------------------------- + // Раньше здесь стояла строка «нанять оперативника» за опыт. Убрана + // намеренно: наёмники теперь НАХОДЯТСЯ в капсулах на этажах + // (sim/profile.h), а кнопка «стать сильнее» никуда не звала и + // ничего не рассказывала. { const int size = game.profile ? game.profile->squadSize : NUM_AGENTS; - const bool full = size >= NUM_AGENTS; - const bool afford = game.profile && game.profile->CanRecruit(); - const UiRect r = UpRowRect(0); - const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == UP_RECRUIT); - if (hover && afford) Box(l, r, COL_HOVER); - BoxLines(l, r, afford ? COL_EDGE : COL_DEAD); - - Text(l, "RECRUIT OPERATIVE", r.x + 8, r.y + 4, 10, afford ? COL_TEXT : COL_FAINT); - std::snprintf(line, sizeof(line), "SQUAD OF %d, ONE MORE GUN AND ONE MORE MOUTH", - size); - Text(l, line, r.x + 8, r.y + 16, 8, COL_FAINT); - + BoxLines(l, r, COL_DEAD); + Text(l, "SQUAD", r.x + 8, r.y + 4, 10, COL_FAINT); + Text(l, "OPERATIVES ARE FOUND IN CAPSULES, NOT HIRED", r.x + 8, r.y + 16, 8, + COL_FAINT); for (int k = 0; k < NUM_AGENTS; ++k) Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5}, (k < size) ? Color{200, 180, 120, 255} : COL_DEAD); - - if (full) std::snprintf(line, sizeof(line), "FULL"); - else std::snprintf(line, sizeof(line), "%d XP", int(RecruitCost(size))); - Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, - full ? COL_FAINT : (afford ? COL_PICK : COL_DEAD)); + std::snprintf(line, sizeof(line), "%d / %d", size, NUM_AGENTS); + Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, COL_FAINT); } for (int i = 0; i < int(Upgrade::COUNT); ++i)