Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую. НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями, из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2 (ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного (0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и хоррор первого этажа в том, что игрок один, а не в том, что не видно стен. Темнота это инструмент глубины, и тратить её на первый этаж значит не иметь запаса дальше. СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп больше не отнимает всё - доезжает около половины добычи, и чем вещь реже, тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало единственным разумным поведением уход наверх при первой царапине. ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка "вниз или наверх" теперь там же, где этаж кончился. Обратный путь через растревоженный этаж ничего не решал - это был ещё раз тот же этаж. ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не перебежка между сохранениями. НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в двадцать. У каждого свои три прибавки из одного бюджета - разница в характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не зовёт. ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%). Первая версия проверки считала одну сумку и выдала "обычного доехало 112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику его числа. Формат профиля 6: строки merc и rescued. Не сделано и обсуждается: ростер сверх пятерых, задания для свободных, пассивные бонусы от сидящих на базе. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
28a9e5b6e4
commit
ef103050b1
@ -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 и
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -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 на
|
||||
дне. Наверху ещё работает освещение, и хоррор первого этажа — в том, что игрок
|
||||
один, а не в том, что не видно стен. Темнота это инструмент ГЛУБИНЫ, и тратить
|
||||
её на первый этаж значит не иметь запаса дальше.
|
||||
|
||||
## Свет как главный признак глубины
|
||||
|
||||
Окружение становится страшнее не новыми спрайтами, а тем, что видно всё меньше.
|
||||
|
||||
@ -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.
|
||||
|
||||
Пятьдесят, а не пять: забег — это ДОЛГИЙ спуск, а не пятиэтажная петля. С шагом
|
||||
в пять вылазка превращалась в перебежку между сохранениями, и весь риск умещался
|
||||
в пять этажей.
|
||||
|
||||

|
||||
|
||||
@ -200,6 +221,28 @@ stash saber plate vest smg bow charm lantern spurs
|
||||
Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом.
|
||||
Иначе «сел» означало бы «стал слабее», а не «кончился».
|
||||
|
||||
## Откуда берутся оперативники
|
||||
|
||||
**Их находят.** Капсула лежит на этаже, как находка, только внутри человек:
|
||||
подошёл — он вышел и встал в строй. С этого момента он такой же боец, как все, и
|
||||
теряется так же.
|
||||
|
||||
Редкость двойная (`sim/profile.h`): в среднем раз в `RESCUE_EVERY` (100) этажей и
|
||||
не чаще, чем раз в `RESCUE_COOLDOWN` (20). Редко намеренно — пятый оперативник
|
||||
обязан быть событием забега.
|
||||
|
||||
У каждого свои числа: три небольшие прибавки (здоровье, скорость, обзор) из
|
||||
одного бюджета, розданные случайно. Не «класс» и не «ранг»: разница в характере,
|
||||
а не в силе, иначе находка превратилась бы в лотерею, где плохой наёмник хуже
|
||||
пустого слота.
|
||||
|
||||
**Кнопку «нанять за опыт» убрали.** Она была первой версией и оказалась скучной
|
||||
ровно в том смысле, в каком скучна любая кнопка «стать сильнее»: ничего не
|
||||
рассказывает и никуда не зовёт.
|
||||
|
||||
Чего ещё нет и что обсуждалось: ростер сверх пятерых, отправка свободных на
|
||||
задания, пассивные бонусы от сидящих на базе.
|
||||
|
||||
## Отряд из одного
|
||||
|
||||

|
||||
|
||||
@ -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<int> 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<uint8_t> taken(size_t(MAP_W) * MAP_H, 0);
|
||||
if (exitTile >= 0) taken[size_t(exitTile)] = 1;
|
||||
|
||||
@ -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);
|
||||
|
||||
// Опыт растёт медленнее опасности намеренно: спускаться должно быть
|
||||
// выгодно, но не настолько, чтобы прыгать через этажи было единственной
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
# Опыт за этаж. Медленнее опасности: спускаться выгодно, но не настолько,
|
||||
# чтобы прыгать через этажи стало единственной разумной игрой.
|
||||
|
||||
@ -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<Inventory>(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;
|
||||
|
||||
168
src/game.cpp
168
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<Inventory>(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<Inventory>(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; }
|
||||
|
||||
@ -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();
|
||||
// Расходники применяются сами: ручных действий в игре нет.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -62,6 +62,11 @@ struct Lighting
|
||||
// и первый этаж, и песочница приёмки.
|
||||
float sightScale = 1.0f;
|
||||
|
||||
// Освещение этажа: насколько ярко видно уже пройденное (engine/progression.h).
|
||||
// Не «яркость картинки»: непройденное остаётся чёрным, иначе туман войны
|
||||
// перестал бы быть туманом и твари были бы видны сквозь стены.
|
||||
float ambient = 0.42f;
|
||||
|
||||
void Reset();
|
||||
|
||||
// Забрать вспышки из журнала боя (то, что накопилось за кадр).
|
||||
|
||||
@ -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);
|
||||
|
||||
// --- сцена -----------------------------------------------------------------
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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; // есть валидная линия огня прямо сейчас
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<FloorItem>& items = world.All<FloorItem>();
|
||||
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<Inventory>(a.self);
|
||||
const float weaponLoad = inv ? inv->Load() : 0.0f;
|
||||
// TakeItem, а не Take: через ItemId в сумку легла бы вещь без
|
||||
|
||||
@ -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);
|
||||
|
||||
22
src/sim/merc.h
Normal file
22
src/sim/merc.h
Normal file
@ -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; }
|
||||
};
|
||||
@ -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 <слот> <hp> <скорость> <обзор>
|
||||
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)
|
||||
|
||||
@ -24,13 +24,14 @@
|
||||
#include <vector>
|
||||
|
||||
#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; }
|
||||
|
||||
// Снаряжение бойцов ровно в том виде, в каком они вернулись. Массив всегда
|
||||
// на все слоты: незанятые просто пустые.
|
||||
|
||||
@ -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},
|
||||
};
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user