Отряд растёт с одного оперативника до пяти (M24)

Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.

NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.

Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:

  отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
         одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2

Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.

Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.

Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.

--accept PASS без изменений: без профиля отряд по-прежнему впятером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-15 22:29:19 +03:00
parent 0fb6e31c2b
commit ece906cbb3
15 changed files with 487 additions and 36 deletions

View File

@ -54,6 +54,11 @@
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
- **`NUM_AGENTS` — это вместимость, а не размер отряда.** Сколько слотов занято,
говорит `Squad::roster`, а занят ли конкретный — `Agent::present`. Правила боя
читают только `alive` (пустой слот и павший для них одно), а рендер, HUD и
профиль обязаны смотреть на `present`: иначе у одинокого оперативника вокруг
появятся четыре чужих трупа ([17-extraction.md](docs/17-extraction.md)).
- **Таймера в игре нет и не заводить.** Фонарь тратит заряд за ПРОЙДЕННЫЙ ПУТЬ,
а не за время: стоять и думать бесплатно, дорого — обшарить этаж целиком.
Расход со `dt` вместо пути — это таймер, и `--descend` его ловит

View File

@ -36,6 +36,8 @@
| **M23** | свет как расходник: заряд фонаря общий на отряд и тратится за ПРОЙДЕННЫЙ ПУТЬ, а не за время; погас — обзор 35% и твари замечают в 1.35 раза дальше; батарея встаёт сама, как бинт | проверка в `--descend`: стоянка стоит 0.2 из 420 (то есть таймера нет), ход 1.10 за тайл при 1.10 в каталоге, заряда на 4.0 прямых пути до спуска, обзор 1.00 → 0.35, батарея сработала; первая версия расхода была ПОСЕКУНДНОЙ — то есть таймером, которого в игре нет по требованию к жанру, и переписана на путь; измеритель тоже был неправ (делил на смещение, а не на путь) и показывал 1.55 против 1.10; `--accept` PASS без изменений — в песочнице свет бесконечен ([17-extraction.md](17-extraction.md)) |
| **M24** | отряд растёт с ОДНОГО оперативника до пяти: размер в профиле, найм за опыт по кривой 600/2400/9600/38400, стартовый комплект одиночки — нож, фонарь, куртка; пустой слот отличается от павшего (`present` против `alive`) | проверка в `--descend`: в профиле 1 → на этаже 1, найм доводит второго, шестой не нанимается ни за какие деньги; одиночка с ножом на первом этаже выживает и убивает двух тварей, теряя 59 из 100 здоровья; первая версия этой проверки была ПУСТОЙ — боец стоял минуту, к нему не подошёл никто, и «выжил» ничего не значило; попутно нашлось, что `Profile::Save` три вехи не писала ни версию, ни улучшения, а круговой прогон сверял три поля из десяти и потому этого не видел; `--accept` PASS без изменений — без профиля отряд по-прежнему впятером ([17-extraction.md](17-extraction.md)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -188,6 +188,11 @@
не даёт ничего. Заряд общий на отряд, батарея встаёт сама
([17-extraction.md](17-extraction.md)).
В забеге с профилем всё иначе: отряд выходит в том, в чём вернулся, и начинается
он **одним оперативником** с ножом, фонарём и курткой — остальные четверо
нанимаются за опыт ([17-extraction.md](17-extraction.md)). Комплекты ниже — это
dev-режим без профиля и песочница приёмки.
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`):
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
отряда читается прямо по строю.

View File

@ -195,6 +195,42 @@ stash saber plate vest smg bow charm lantern spurs
Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом.
Иначе «сел» означало бы «стал слабее», а не «кончился».
## Отряд из одного
![экран улучшений](ui-upgrades.png)
Игра начинается **одним оперативником-исследователем**: тактический нож, слабый
фонарь, куртка. Не отрядом — человеком. Остальные четверо нанимаются за опыт,
и это самая дорогая покупка в игре: 600 / 2400 / 9600 / 38400, всего 51000
против дохода в 726 за этаж на десятом (`--tool economy`).
Дорого намеренно. Одиночка с ножом — это первое впечатление от игры, и продать
второго бойца за одну вылазку значило бы выбросить весь хоррор начала: вдвоём
уже не страшно. По кривой второй приходит через две-три ранние вылазки, третий
— когда отряд уверенно берёт десятый этаж, пятый — ближе к двадцать пятому.
Замерено в `--descend`: одинокий оперативник с ножом на первом этаже **выживает
и убивает двух тварей, теряя 59 из 100 здоровья**. Это ровно та цена, ради
которой всё и затевалось: он справляется, но после второй схватки решение
«ещё этаж или наверх» принимается всерьёз.
Внутри кода размер отряда — это ДВА разных понятия, и путать их нельзя:
| | что значит | кто читает |
|---|---|---|
| `NUM_AGENTS` | сколько слотов БЫВАЕТ (5, время сборки) | все циклы, массивы, метрики |
| `Squad::roster` | сколько слотов ЗАНЯТО сейчас | сборка этажа |
| `Agent::present` | этот слот занят человеком | рендер, HUD, экраны, профиль |
| `Agent::alive` | этот человек ещё жив | правила боя, движение, твари |
Правила боя читают только `alive`: для них пустой слот и павший неразличимы, и
это верно. Разница нужна там, где показывают ЧЕЛОВЕКА — у павшего есть тело на
полу и перечёркнутая карточка, у пустого слота нет ни того, ни другого. Иначе
игрок с одним оперативником видел бы вокруг него четыре чужих трупа.
Делать `NUM_AGENTS` динамическим не стали: это сто с лишним мест ради числа,
которое всё равно упирается в пять.
## Во что вкладывается опыт
![экран улучшений](ui-upgrades.png)
@ -303,6 +339,22 @@ squad_proto.exe --descend 7 --no-profile
которым игра может стереть всё нажитое, и ошибка в нём выглядит как «схрон
куда-то делся», а не как падение.
### Как круговой прогон три вехи подтверждал то, чего не было
Он сверял три поля из десяти — глубину, схрон и снаряжение — и всё это время
`Profile::Save` **не писала ни версию формата, ни улучшения**. То есть купленный
запас патронов действовал до конца запуска и молча пропадал при следующем
чтении, а профиль без строки `version` читался как формат 1 и на каждом выходе
прогонялся через все миграции. Пока миграции были пустыми, это ничего не портило
и потому не всплывало; с приходом размера отряда такая запись начала бы
возвращать игроку пятерых бойцов после каждой вылазки.
Отсюда правило: **круговой прогон сверяет поля списком, и список растёт вместе с
форматом**. Выборочная сверка проверяет не формат, а память того, кто её писал.
И второе: прогон делается на НАБИТОМ профиле. Пустое поле сходится с пустым при
любой поломке записи — «0 == 0» подтверждало работу того, чего в файле не было.
## Чего ещё нет
- **Схватки за добычу.** В настоящем экстракшене выход можно не отдать —

View File

@ -16,6 +16,11 @@
работает. Не страшно — и это НАМЕРЕННО: игроку показывают дно шкалы, чтобы
через сто этажей он мог оглянуться.
> Сделано в M24. С поправкой на то, что есть: костюма радиозащиты в игре нет —
> сопротивления пока только у тварей, поэтому одиночка выходит в куртке.
> Замерено: он переживает первую схватку и убивает двух тварей, оставаясь с 41
> здоровьем из 100 ([17-extraction.md](17-extraction.md)).
Последний экран, которого нет: пятеро в силовой броне идут сквозь Бездну,
поджигают, замораживают и расщепляют всё в радиусе, и всё равно однажды не
возвращаются.

View File

@ -1016,6 +1016,13 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
}
}
// Итог забега снимается ЗДЕСЬ, пока он ещё правда. Ниже идут проверки,
// которые начинают новую вылазку (найм, улучшения) и обнуляют счётчик
// этажей: отчитываться цифрой, которую испортил измеряющий, уже приходилось
// дважды — сперва с улучшениями, потом с размером отряда.
const int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp;
// Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не
// значит: вся суть экстракшена в том, что происходит на выходе.
game.Extract();
@ -1051,6 +1058,17 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
const std::string tmp =
(std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string();
// Прогон делается на НАБИТОМ профиле, а не на том, что получился сам.
// Пустое поле сходится с пустым при любой поломке записи: улучшения
// стояли в нулях весь прогон, и проверка «0 == 0» три вехи подряд
// подтверждала работу того, чего в файле не было вовсе.
const Profile saved = profile;
for (int u = 0; u < int(Upgrade::COUNT); ++u) profile.upgrade[u] = 1 + u % UPGRADE_MAX_LEVEL;
profile.squadSize = 3;
profile.runs = 11;
profile.extractions = 7;
profile.wipes = 4;
std::string why;
Profile back;
if (!profile.Save(tmp, &why))
@ -1065,24 +1083,52 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
}
else
{
// Сверяется ВСЁ, что есть в профиле, а не три поля из десяти.
//
// Прежняя версия проверки смотрела глубину, схрон и снаряжение — и
// ровно поэтому три вехи не замечала, что `Save` не писала ни
// версию, ни улучшения. Купленное действовало до конца запуска и
// молча пропадало, а круговой прогон при этом бодро сходился.
//
// Правило на будущее: круговой прогон обязан сверять поля СПИСКОМ,
// а список — расти вместе с форматом. Выборочная сверка проверяет
// не формат, а память того, кто её писал.
int diff = 0;
if (back.maxDepth != profile.maxDepth) ++diff;
if (back.StashCount() != profile.StashCount()) ++diff;
auto same = [&](bool ok, const char* what) {
if (ok) return;
std::printf(" [FAIL] не пережило запись: %s\n", what);
++diff;
};
same(back.version == PROFILE_VERSION, "версия формата");
same(back.maxDepth == profile.maxDepth, "глубина");
same(back.squadSize == profile.squadSize, "размер отряда");
same(back.runs == profile.runs, "счётчик вылазок");
same(back.extractions == profile.extractions, "счётчик эвакуаций");
same(back.wipes == profile.wipes, "счётчик вайпов");
same(int(back.bankedXp) == int(profile.bankedXp), "опыт в банке");
same(back.StashCount() == profile.StashCount(), "схрон");
for (int u = 0; u < int(Upgrade::COUNT); ++u)
same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u)));
for (int a = 0; a < NUM_AGENTS; ++a)
{
const Inventory& x = profile.loadout[a];
const Inventory& y = back.loadout[a];
if (x.weapon != y.weapon || x.armor != y.armor || x.trinket != y.trinket) ++diff;
same(x.weapon == y.weapon && x.armor == y.armor && x.trinket == y.trinket,
"снаряжение бойца");
}
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
else std::printf("профиль: запись и чтение сходятся\n");
else std::printf("профиль: запись и чтение сходятся по всем полям\n");
}
// Профиль возвращается как был: дальше он ещё нужен настоящим.
profile = saved;
std::error_code ec;
std::filesystem::remove(tmp, ec);
}
// -------------------------------------------------------------------------
// -------------------------------------------------------------------------
// Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл
// читался молча и частично. Проверяются оба направления: старый читается с
@ -1131,13 +1177,159 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
std::printf(" [FAIL] профиль новее игры прочитан — запись поверх сотрёт чужое\n");
++bad;
}
// Старый профиль играл впятером, и строки squad в нём нет. Дать такому
// игроку одного оперативника значило бы отобрать четверых, которых он
// не терял, — а выглядело бы это как «игра съела отряд».
if (readOld && v1.squadSize != NUM_AGENTS)
{
std::printf(" [FAIL] миграция отобрала бойцов: %d вместо %d\n", v1.squadSize,
NUM_AGENTS);
++bad;
}
}
// Итог забега снимается ДО проверки улучшений: та начинает новую вылазку и
// обнуляет счётчик этажей. Печатать его после значило бы отчитаться
// цифрой, которую испортил измеряющий.
const int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp;
// -------------------------------------------------------------------------
// Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько
// человек в профиле, столько и выходит, и ни одного лишнего тела рядом.
//
// Стоит ПЕРЕД проверкой улучшений, потому что та начинает новую вылазку и
// сама же пересобирает отряд.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const int sizeBefore = pr.squadSize;
auto mannedNow = [&]() {
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (game.squad.agents[i].present) ++n;
return n;
};
pr.squadSize = 1;
game.NewRun(1);
const int mannedSolo = mannedNow();
const int aliveSolo = game.AliveAgents();
// Переживает ли одиночка встречу с первой тварью. Первый этаж заявлен
// как «полупустая лаборатория, не страшно» (docs/20-gdd.md), и человек
// с ножом обязан её пережить — иначе начало игры непроходимо.
//
// Отряд СТАВИТСЯ рядом с тварью, а не ждёт её. Первая версия проверки
// просто шагала минуту на месте и рапортовала «выжил» — при том, что к
// бойцу не вышел никто: твари замечают с восьми-двенадцати тайлов, а
// высадка от них далеко. Проверка, в которой ничего не происходит,
// проходит всегда.
int engaged = 0;
bool soloLived = true;
float hpStart = 0.0f, hpLeft = 0.0f;
int killed = 0;
{
int victim = -1;
for (int t = 0; t < int(game.targets.size()); ++t)
if (game.targets[size_t(t)].alive && !game.targets[size_t(t)].IsProp())
{
victim = t;
break;
}
if (victim < 0) std::printf("\n [FAIL] на первом этаже нет ни одной твари\n"), ++bad;
else
{
const int aliveBefore = [&]() {
int n = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp()) ++n;
return n;
}();
game.PlaceSquad(game.targets[size_t(victim)].pos + Vec2{3.0f, 0.0f}, true);
game.Step(FIXED_DT); // SyncLoadout: здоровье по надетому
game.squad.agents[0].hp = game.squad.agents[0].maxHp;
hpStart = game.squad.agents[0].hp;
for (int k = 0; k < 3600; ++k) // минута
{
game.Step(FIXED_DT);
if (!game.squad.agents[0].alive) break;
}
int aliveAfter = 0;
for (const Target& t : game.targets)
{
if (t.IsProp()) continue;
if (t.alive) ++aliveAfter;
if (t.aggro) ++engaged;
}
killed = aliveBefore - aliveAfter;
soloLived = game.squad.agents[0].alive;
hpLeft = game.squad.agents[0].hp;
}
}
// Найм: стоит опыта, и опыт списывается.
pr.bankedXp = RecruitCost(1) + RecruitCost(2);
const bool hired = pr.Recruit();
const float leftAfterHire = pr.bankedXp;
game.NewRun(1);
const int mannedPair = mannedNow();
// Потолок: шестого не нанять ни за какие деньги.
pr.squadSize = NUM_AGENTS;
pr.bankedXp = 1e9f;
const bool overHired = pr.Recruit();
std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), после найма %d, "
"цена %.0f (осталось %.0f), сверх потолка %s\n"
" одиночка с ножом на первом этаже: %s, здоровье %.0f -> %.0f, "
"тварей подошло %d, убил %d\n",
mannedSolo, aliveSolo, mannedPair, double(RecruitCost(1)),
double(leftAfterHire), overHired ? "НАНЯЛСЯ" : "отказ",
soloLived ? "выжил" : "ПОГИБ", double(hpStart), double(hpLeft), engaged,
killed);
if (!soloLived)
{
std::printf(" [FAIL] первая же тварь убивает одинокого оперативника — начало"
" игры непроходимо\n");
++bad;
}
if (engaged <= 0)
{
std::printf(" [FAIL] к бойцу никто не подошёл: проверка ничего не измерила\n");
++bad;
}
if (soloLived && killed <= 0)
{
std::printf(" [FAIL] нож не убил ни одной твари за минуту — первый этаж не"
" пройти в принципе\n");
++bad;
}
if (mannedSolo != 1 || aliveSolo != 1)
{
std::printf(" [FAIL] одиночка вышел не один: занято %d, живых %d\n", mannedSolo,
aliveSolo);
++bad;
}
if (!hired || mannedPair != 2)
{
std::printf(" [FAIL] найм не довёл бойца до этажа: занято %d\n", mannedPair);
++bad;
}
if (leftAfterHire >= RecruitCost(1) + RecruitCost(2))
{
std::printf(" [FAIL] найм бесплатный: опыт не списался\n");
++bad;
}
if (overHired)
{
std::printf(" [FAIL] нанят шестой — потолок отряда не держит\n");
++bad;
}
pr.squadSize = sizeBefore;
}
// -------------------------------------------------------------------------
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на

View File

@ -119,6 +119,8 @@ void Game::HarvestToProfile(bool onlySurvivors)
const Agent& a = squad.agents[i];
const Inventory* inv = world.Get<Inventory>(a.self);
if (!inv) continue;
// Пустой слот ничего не выносит и ничего не теряет: там некому.
if (!a.present) continue;
auto countCarried = [&](const Inventory& v) {
int n = 0;
@ -161,7 +163,8 @@ void Game::HarvestToProfile(bool onlySurvivors)
// Пустой слот павшего добивается стартовым комплектом. Делается через
// ResetLoadouts только для тех, у кого ничего нет: иначе выжившие
// потеряли бы найденное.
// потеряли бы найденное. Ненанятые слоты набивать нечем и незачем — но и
// мешать они не мешают: их снаряжение никто не наденет.
Profile fresh;
fresh.ResetLoadouts();
for (int i = 0; i < NUM_AGENTS; ++i)
@ -273,6 +276,11 @@ void Game::BuildFloor(const SquadCarry* carry)
level.floor = spec;
}
// Сколько слотов занято. Из профиля, потому что размер отряда — это
// накопленный результат, а не состояние вылазки. Нет профиля (приёмка,
// dev-прогоны) — все пять, как было всегда: критерии меряются на пятерых.
squad.roster = profile ? std::max(1, std::min(NUM_AGENTS, profile->squadSize)) : NUM_AGENTS;
world.Clear(); // бойцы, оружие, мишени, пули — всё разом
squad.Init(world, start);
if (carry) RestoreSquad(*carry);
@ -321,8 +329,14 @@ void Game::BuildFloor(const SquadCarry* carry)
// Запас патронов выдаётся вместе со снаряжением и только в БОЮ.
// В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют
// правила стрельбы, а не то, на сколько минут хватило патронов.
// Патроны выдаются ЛЮДЯМ. Пустому слоту запас не нужен, и выдай его
// туда — отряд из одного оперативника выходил бы с пятикратным
// боезапасом, разложенным по четырём никому.
for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
{
Agent& a = squad.agents[i];
a.ammoReserve = combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus) : 0) : -1;
}
// Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь

View File

@ -168,14 +168,24 @@ void DrawHud(Framebuffer& fb, const Game& game)
{
DrawRunBar(fb, game);
const int total = NUM_AGENTS * CARD_W + (NUM_AGENTS - 1) * CARD_GAP;
// Карточки только на ЗАНЯТЫЕ слоты, и ряд считается по ним же. Отряд растёт
// с одного до пяти, и четыре пустые рамки рядом с одиноким оперативником
// читались бы как «тут кто-то был» — а там никого и не было.
int manned = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (game.squad.agents[i].present) ++manned;
if (manned <= 0) return;
const int total = manned * CARD_W + (manned - 1) * CARD_GAP;
const int x0 = (fb.w - total) / 2;
const int y0 = fb.h - CARD_H - 4;
int card = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
const int x = x0 + i * (CARD_W + CARD_GAP);
if (!a.present) continue;
const int x = x0 + (card++) * (CARD_W + CARD_GAP);
const uint32_t col = SlotColor(i);
// Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда,

View File

@ -296,6 +296,7 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.present) continue; // слот не занят: рисовать некого, и трупа тут нет
const Vec2 p = Lerp(a.prevPos, a.pos, alpha);
if (!a.alive)

View File

@ -57,6 +57,19 @@ struct Agent
// и по ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его
// из пула значило бы переиндексировать полмира на каждой смерти. Поэтому
// смерть — это флаг, а не удаление; все системы обязаны его проверять.
//
// Флагов ДВА, и путать их нельзя.
//
// present — слот вообще занят человеком. Отряд растёт с одного до пяти
// (sim/squad.h), и в незанятом слоте никого нет и не было.
// alive — этот человек ещё жив.
//
// Правила боя, движение и решатель читают только alive: для них пустой слот
// и павший неразличимы, и это верно. Разница видна там, где показывают
// ЧЕЛОВЕКА: у павшего есть тело на полу и перечёркнутая карточка в HUD, а у
// пустого слота нет ни того, ни другого — иначе игрок с одним оперативником
// видел бы вокруг него четыре чужих трупа.
bool present = true;
bool alive = true;
float hp = 0.0f;
float maxHp = 0.0f;

View File

@ -12,14 +12,20 @@ namespace fs = std::filesystem;
namespace
{
// Стартовые комплекты по слотам строя. Те же, что раздаёт Game::EquipKits, и
// намеренно скромные: это дно, с которого начинают заново, а не награда.
// Стартовые комплекты по слотам строя. Намеренно скромные: это дно, с которого
// начинают заново, а не награда.
//
// Первый слот отличается от остальных, и это ПЕРВОЕ ВПЕЧАТЛЕНИЕ ОТ ИГРЫ
// (docs/20-gdd.md): игра начинается одним оперативником-исследователем с
// тактическим ножом, слабым фонарём и курткой. Не отрядом — человеком.
// Остальные слоты открываются наймом и приходят вооружённее: к тому времени,
// когда игрок может их себе позволить, он уже не на первом этаже.
const StarterKit STARTER[NUM_AGENTS] = {
{ItemId::SABER, ItemId::VEST},
{ItemId::SABER, ItemId::JACKET},
{ItemId::RIFLE, ItemId::JACKET},
{ItemId::RIFLE, ItemId::JACKET},
{ItemId::PISTOL, ItemId::SHROUD},
{ItemId::KNIFE, ItemId::JACKET, ItemId::LANTERN},
{ItemId::SABER, ItemId::JACKET, ItemId::NONE},
{ItemId::RIFLE, ItemId::JACKET, ItemId::NONE},
{ItemId::RIFLE, ItemId::JACKET, ItemId::NONE},
{ItemId::PISTOL, ItemId::SHROUD, ItemId::NONE},
};
ItemId ItemFromToken(const std::string& token)
@ -64,6 +70,7 @@ void Profile::ResetLoadouts()
loadout[i] = Inventory{};
loadout[i].weapon = STARTER[i].weapon;
loadout[i].armor = STARTER[i].armor;
loadout[i].trinket = STARTER[i].trinket;
}
}
@ -134,6 +141,11 @@ 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; }
if (t[0] == "squad" && t.size() >= 2)
{
p.squadSize = std::max(1, std::min(NUM_AGENTS, text::ToInt(t[1], 1)));
continue;
}
if (t[0] == "runs" && t.size() >= 2) { p.runs = text::ToInt(t[1], 0); continue; }
if (t[0] == "extractions" && t.size() >= 2) { p.extractions = text::ToInt(t[1], 0); continue; }
if (t[0] == "wipes" && t.size() >= 2) { p.wipes = text::ToInt(t[1], 0); continue; }
@ -185,6 +197,11 @@ bool Profile::Load(const std::string& path, std::string* err)
return false;
}
// Миграция на формат 3: до него отряд всегда был впятером, и строки squad в
// файле нет. Давать такому профилю одного оперативника значило бы отобрать
// четверых у того, кто их не терял.
if (p.version < 3) p.squadSize = NUM_AGENTS;
// Прочитанный файл мог быть старее — записан он будет уже текущей версией.
p.version = PROFILE_VERSION;
*this = std::move(p);
@ -204,12 +221,28 @@ bool Profile::Save(const std::string& path, std::string* err) const
}
f << "# профиль: всё, что переживает вылазку. Формат — sim/profile.h\n";
// Версия ПЕРВОЙ строкой. Её отсутствие — не мелочь: файл без неё читается
// как формат 1, то есть каждая запись объявляла бы профиль древним и
// прогоняла его через все миграции. Пока миграция была пустой, это ничего
// не портило и потому не замечалось; с появлением размера отряда такая
// запись начала бы возвращать игроку пятерых бойцов после каждого выхода.
f << "version " << PROFILE_VERSION << "\n";
f << "maxdepth " << maxDepth << "\n";
f << "squad " << squadSize << "\n";
f << "runs " << runs << "\n";
f << "extractions " << extractions << "\n";
f << "wipes " << wipes << "\n";
f << "xp " << int(bankedXp) << "\n";
// Улучшения не писались вовсе: купленное действовало до конца запуска и
// пропадало при следующем чтении. Проверка кругового прогона этого не
// ловила, потому что сверяла три поля из десяти — глубину, схрон и
// снаряжение. Теперь она сверяет всё.
f << "upgrades";
for (int u = 0; u < int(Upgrade::COUNT); ++u) f << " " << upgrade[u];
f << "\n";
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Inventory& inv = loadout[i];

View File

@ -42,6 +42,7 @@ struct StarterKit
{
ItemId weapon = ItemId::RIFLE;
ItemId armor = ItemId::JACKET;
ItemId trinket = ItemId::NONE;
};
// -----------------------------------------------------------------------------
@ -68,6 +69,30 @@ 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 float UPGRADE_AMMO_STEP = 20.0f;
constexpr float UPGRADE_CARRY_STEP = 1.5f;
@ -91,13 +116,14 @@ const char* UpgradeEffect(Upgrade u);
//
// 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent
// 2 — общая сумка, улучшения отряда
// 3 — размер отряда (найм): раньше бойцов всегда было пять
//
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку.
// -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 2;
constexpr int PROFILE_VERSION = 3;
struct Profile
{
@ -121,7 +147,26 @@ struct Profile
return true;
}
// Снаряжение пяти бойцов ровно в том виде, в каком они вернулись.
// Сколько оперативников в отряде. Игра начинается ОДНИМ (docs/20-gdd.md),
// потолок — NUM_AGENTS. Живёт в профиле, потому что это накопленный
// результат, а не состояние вылазки: нанятый не теряется при вайпе, он
// теряет только снаряжение, как и все.
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;
}
// Снаряжение бойцов ровно в том виде, в каком они вернулись. Массив всегда
// на все слоты: незанятые просто пустые.
Inventory loadout[NUM_AGENTS];
// Общий запас сверх надетого.

View File

@ -99,8 +99,13 @@ void Squad::Init(ecs::World& world, Vec2 at)
SyncLoadout(world, a);
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
a.alive = true;
a.hp = a.maxHp;
// Незанятый слот: сущность есть (по ней ходят все циклы), человека нет.
// Не alive — значит, ни решатель, ни твари, ни правила огня его не
// видят; не present — значит, его не видит и игрок.
a.present = (i < roster);
a.alive = a.present;
a.hp = a.alive ? a.maxHp : 0.0f;
}
}

View File

@ -95,6 +95,21 @@ struct Squad
float formSpacing = 1.0f; // общий множитель интервала
float formStretch = 1.0f; // вытяжение вдоль направления движения
// -------------------------------------------------------------------------
// Сколько слотов ЗАНЯТО. Отряд растёт с одного оперативника до пяти, и это
// не косметика: игра начинается одиночкой с ножом, а к лейтгейму
// превращается в управление пятёркой (docs/20-gdd.md).
//
// NUM_AGENTS при этом остаётся ВЕЛИЧИНОЙ ВРЕМЕНИ СБОРКИ и означает теперь
// «сколько слотов бывает», а не «сколько бойцов в отряде». По ней
// по-прежнему ходят все циклы, массивы, метрики и слоты строя, и делать её
// динамической значило бы переписать сотню мест ради числа, которое всё
// равно упирается в пять.
//
// По умолчанию все пять: песочница приёмки профиля не подключает, и там
// отряд обязан остаться таким же, каким мерялись критерии.
int roster = NUM_AGENTS;
// Сколько первых слотов держат клинок. Ставится ДО Init.
int meleeCount = DEFAULT_MELEE_COUNT;
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина. Ставится ДО Init.

View File

@ -117,7 +117,17 @@ constexpr int SQ_STAT_X = 40, SQ_STAT_Y = 158, SQ_STAT_STEP = 9;
// Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому
// что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы
// покупкой кота в мешке.
constexpr int UP_X = 60, UP_Y = 70, UP_W = 360, UP_H = 26, UP_STEP = 32;
// Строк на экране улучшений пять: найм и четыре характеристики. Найм ПЕРВЫЙ
// строкой — он и стоит на порядок дороже, и меняет игру сильнее всех остальных
// покупок вместе взятых (sim/profile.h).
constexpr int UP_X = 60, UP_Y = 66, UP_W = 360, UP_H = 26, UP_STEP = 30;
// Индекс строки найма в списке виджетов. Стоит за последним улучшением, чтобы
// не переиндексировать Upgrade ради UI.
constexpr int UP_RECRUIT = int(Upgrade::COUNT);
// Строка i-го улучшения. Найм занимает нулевую, характеристики идут следом.
inline UiRect UpRowRect(int row) { return UiRect{UP_X, UP_Y + row * UP_STEP, UP_W, UP_H}; }
constexpr int SQ_STAT_LINES = 4;
// Сумка общая, и ячеек в ней шестнадцать вместо шести на бойца. В два столбца
@ -400,8 +410,9 @@ 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));
for (int i = 0; i < int(Upgrade::COUNT); ++i)
out.Add(UiPart::UPGRADE_ITEM, i, UiRect{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H});
out.Add(UiPart::UPGRADE_ITEM, i, UpRowRect(i + 1));
return;
case Screen::DEPTH:
@ -428,11 +439,15 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out)
case Screen::SQUAD:
{
// Карточки только на занятых бойцов: пустой слот — это не боец без
// снаряжения, это никто.
int card = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!game.squad.agents[i].present) continue;
Widget& w = out.Add(UiPart::AGENT, i,
UiRect{SQ_CARD_X + i * SQ_CARD_STEP, SQ_CARD_Y, SQ_CARD_W,
SQ_CARD_H});
UiRect{SQ_CARD_X + (card++) * SQ_CARD_STEP, SQ_CARD_Y,
SQ_CARD_W, SQ_CARD_H});
w.item = game.squad.agents[i].held;
}
@ -660,7 +675,9 @@ void Activate(Ui& ui, Game& game, UiPart part, int index)
case UiPart::UPGRADE_ITEM:
// Покупка молча не проваливается: не хватило опыта — уровень
// остался прежним, и это видно на той же строке.
if (game.profile) game.profile->Buy(Upgrade(index));
if (!game.profile) return;
if (index == UP_RECRUIT) game.profile->Recruit();
else game.profile->Buy(Upgrade(index));
return;
case UiPart::MENU_ITEM:
@ -917,10 +934,17 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt)
// из инвентаря этого бойца, а лёг бы в инвентарь другого.
if (!dragging)
{
if (Pressed(KEY_RIGHT) || Pressed(KEY_D))
agentIndex = (agentIndex + 1) % NUM_AGENTS;
if (Pressed(KEY_LEFT) || Pressed(KEY_A))
agentIndex = (agentIndex + NUM_AGENTS - 1) % NUM_AGENTS;
// Листается только по ЗАНЯТЫМ слотам: пустые пролистываются
// насквозь, иначе половина нажатий показывала бы пустоту.
const int step = (Pressed(KEY_RIGHT) || Pressed(KEY_D)) ? 1
: (Pressed(KEY_LEFT) || Pressed(KEY_A)) ? -1
: 0;
if (step != 0)
for (int k = 0; k < NUM_AGENTS; ++k)
{
agentIndex = (agentIndex + step + NUM_AGENTS) % NUM_AGENTS;
if (game.squad.agents[agentIndex].present) break;
}
}
// ENTER — взять из сумки в руки. Обратно уезжает то, что было:
@ -1165,6 +1189,33 @@ 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);
// --- найм: первая строка и единственная покупка, которая приводит
// ЧЕЛОВЕКА, а не число.
{
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);
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));
}
for (int i = 0; i < int(Upgrade::COUNT); ++i)
{
const Upgrade u = Upgrade(i);
@ -1172,7 +1223,7 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const
const bool maxed = lvl >= UPGRADE_MAX_LEVEL;
const bool afford = game.profile && game.profile->CanBuy(u);
const UiRect r{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H};
const UiRect r = UpRowRect(i + 1);
const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == i);
if (hover && afford) Box(l, r, COL_HOVER);
BoxLines(l, r, afford ? COL_EDGE : COL_DEAD);
@ -1229,7 +1280,10 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const
std::snprintf(line, sizeof(line), "REACHED D%d", r.deepest);
TextCentered(l, line, 104, 12, COL_PICK);
std::snprintf(line, sizeof(line), "SURVIVORS %d / %d", r.survivors, NUM_AGENTS);
// Из скольких ушли, а не из скольких бывает: «2 / 5» у игрока с
// отрядом из двух читалось бы как потеря троих.
std::snprintf(line, sizeof(line), "SURVIVORS %d / %d", r.survivors,
std::max(r.survivors, game.squad.roster));
TextCentered(l, line, 126, 9, COL_FAINT);
// Две цифры, ради которых экран и существует: что осталось у тебя