diff --git a/CLAUDE.md b/CLAUDE.md index 4f9523b..98bb1e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` его ловит diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 164057f..84240d9 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -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)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/13-combat.md b/docs/13-combat.md index 7f9c121..bf8370a 100644 --- a/docs/13-combat.md +++ b/docs/13-combat.md @@ -188,6 +188,11 @@ не даёт ничего. Заряд общий на отряд, батарея встаёт сама ([17-extraction.md](17-extraction.md)). +В забеге с профилем всё иначе: отряд выходит в том, в чём вернулся, и начинается +он **одним оперативником** с ножом, фонарём и курткой — остальные четверо +нанимаются за опыт ([17-extraction.md](17-extraction.md)). Комплекты ниже — это +dev-режим без профиля и песочница приёмки. + Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`): остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав отряда читается прямо по строю. diff --git a/docs/17-extraction.md b/docs/17-extraction.md index 5b8bb1d..31039ef 100644 --- a/docs/17-extraction.md +++ b/docs/17-extraction.md @@ -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» подтверждало работу того, чего в файле не было. + ## Чего ещё нет - **Схватки за добычу.** В настоящем экстракшене выход можно не отдать — diff --git a/docs/20-gdd.md b/docs/20-gdd.md index 02e0904..50cb435 100644 --- a/docs/20-gdd.md +++ b/docs/20-gdd.md @@ -16,6 +16,11 @@ работает. Не страшно — и это НАМЕРЕННО: игроку показывают дно шкалы, чтобы через сто этажей он мог оглянуться. +> Сделано в M24. С поправкой на то, что есть: костюма радиозащиты в игре нет — +> сопротивления пока только у тварей, поэтому одиночка выходит в куртке. +> Замерено: он переживает первую схватку и убивает двух тварей, оставаясь с 41 +> здоровьем из 100 ([17-extraction.md](17-extraction.md)). + Последний экран, которого нет: пятеро в силовой броне идут сквозь Бездну, поджигают, замораживают и расщепляют всё в радиусе, и всё равно однажды не возвращаются. diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 4225680..d0116cc 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -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; + } // ------------------------------------------------------------------------- // Опыт тратится. Проверяется цепочка целиком: купили — подействовало на diff --git a/src/game.cpp b/src/game.cpp index afd2055..045ac80 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -119,6 +119,8 @@ void Game::HarvestToProfile(bool onlySurvivors) const Agent& a = squad.agents[i]; const Inventory* inv = world.Get(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 живёт дольше уровня), и в этом весь diff --git a/src/render/hud.cpp b/src/render/hud.cpp index 54ba4e8..e9a5dfc 100644 --- a/src/render/hud.cpp +++ b/src/render/hud.cpp @@ -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); // Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда, diff --git a/src/render/view.cpp b/src/render/view.cpp index 251f0da..961a05f 100644 --- a/src/render/view.cpp +++ b/src/render/view.cpp @@ -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) diff --git a/src/sim/agent.h b/src/sim/agent.h index c3c9b44..edb5095 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -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; diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index 8885616..55887b2 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -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]; diff --git a/src/sim/profile.h b/src/sim/profile.h index 86f78cf..a9db3e5 100644 --- a/src/sim/profile.h +++ b/src/sim/profile.h @@ -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]; // Общий запас сверх надетого. diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp index 78a7e55..c6c2136 100644 --- a/src/sim/squad.cpp +++ b/src/sim/squad.cpp @@ -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; } } diff --git a/src/sim/squad.h b/src/sim/squad.h index 7fb31fc..e79757a 100644 --- a/src/sim/squad.h +++ b/src/sim/squad.h @@ -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. diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 256aa9a..0a1c661 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -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); // Две цифры, ради которых экран и существует: что осталось у тебя