#pragma once // ----------------------------------------------------------------------------- // Профиль: всё, что переживает забег. // // Игра стала экстракшен-шутером, и это меняет смысл спуска целиком. Раньше // глубина была прогрессом: чем дальше, тем лучше. Теперь она СТАВКА — унесённое // остаётся, потерянное не возвращается, и каждый этаж это заново заданный // вопрос «ещё ниже или назад». // // Отсюда всё устройство файла: // * снаряжение бойцов лежит ЗДЕСЬ, а не собирается заново при старте. То, в // чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки; // * схрон — общий запас сверх надетого. Он и есть накопленный результат; // * максимальная глубина открывает чекпоинты. Без них десятитысячный этаж // недостижим не по сложности, а по времени. // // Формат текстовый и построчный по той же причине, что у комнат: профиль лежит // рядом с игрой, его правят руками при отладке, и он обязан читаться глазами. // // Профиль — состояние ИГРОКА, а не мира, поэтому он не в ecs::World и не // сбрасывается ни Reset, ни Descend. // ----------------------------------------------------------------------------- #include #include #include "sim/items.h" #include "tuning.h" // Через сколько этажей ставится чекпоинт. Пять — это «достаточно редко, чтобы // дойти до следующего было событием, и достаточно часто, чтобы потеря забега // не откатывала в самое начало». constexpr int CHECKPOINT_STEP = 5; // Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без // потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает. constexpr int STASH_CAP = 48; // Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже // которого забег не проваливается: без него полный вайп означал бы отряд с // голыми руками и конец игры вместо нового захода. struct StarterKit { ItemId weapon = ItemId::RIFLE; ItemId armor = ItemId::JACKET; ItemId trinket = ItemId::NONE; }; // ----------------------------------------------------------------------------- // Во что вкладывается вынесенный опыт. // // Улучшения РЕСУРСНЫЕ, а не силовые: больше патронов, больше переноски, дальше // видно, больше запаса здоровья. Игра про исчерпание, и вкладываться в неё надо // в то, что кончается, — иначе прокачка съедает сам жанр: отряд, который бьёт // вдвое сильнее, перестаёт бояться, а страх тут единственная механика, которую // нельзя восстановить балансом. // // Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре // остаётся снаряжение, и второе личное измерение превратило бы сборы перед // вылазкой в бухгалтерию на пятерых. // ----------------------------------------------------------------------------- enum class Upgrade : uint8_t { AMMO = 0, // запас патронов на вылазку CARRY, // норма переноски до перегруза SIGHT, // радиус обзора VIGOR, // запас здоровья COUNT, }; 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; constexpr float UPGRADE_SIGHT_STEP = 0.06f; constexpr float UPGRADE_VIGOR_STEP = 12.0f; // Цена следующего уровня. Растёт линейно: первая покупка обязана случиться на // второй-третьей вылазке, иначе опыт снова «просто число», а десятая — стоить // заметно, иначе кривая кончается. inline float UpgradeCost(int currentLevel) { return 150.0f * float(currentLevel + 1); } const char* UpgradeName(Upgrade u); const char* UpgradeEffect(Upgrade u); // ----------------------------------------------------------------------------- // Версия формата профиля. // // Заведена не «на будущее»: формат уже ломался дважды за три вехи — когда сумка // стала общей и когда появились улучшения. Оба раза старый файл читался молча и // частично, а игрок видел это как «схрон куда-то делся». // // 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent // 2 — общая сумка, улучшения отряда // 3 — размер отряда (найм): раньше бойцов всегда было пять // // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // читается вовсе. Второе важнее первого: молча обнулить профиль, который // записала более новая сборка, — это стереть десятки часов, а отказ читать // всего лишь просит не запускать старую сборку. // ----------------------------------------------------------------------------- constexpr int PROFILE_VERSION = 3; struct Profile { // Какой версией записан прочитанный файл. Новый профиль сразу текущей. int version = PROFILE_VERSION; // Уровни улучшений, по одному на каждое. Живут в профиле, а не в игре: // это то, что переживает вылазку. int upgrade[int(Upgrade::COUNT)] = {}; int UpgradeLevel(Upgrade u) const { return upgrade[int(u)]; } bool CanBuy(Upgrade u) const { return upgrade[int(u)] < UPGRADE_MAX_LEVEL && bankedXp >= UpgradeCost(upgrade[int(u)]); } bool Buy(Upgrade u) { if (!CanBuy(u)) return false; bankedXp -= UpgradeCost(upgrade[int(u)]); ++upgrade[int(u)]; 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]; // Общий запас сверх надетого. std::vector stash; // --- прогресс ------------------------------------------------------------- int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж int runs = 0; // сколько вылазок начато int extractions = 0; // сколько закончилось эвакуацией int wipes = 0; float bankedXp = 0.0f; // опыт, вынесенный из успешных вылазок // --- чекпоинты ------------------------------------------------------------ // Самый глубокий чекпоинт, с которого можно начать. Глубина 1 доступна // всегда: начать сверху — законный выбор, а не наказание. int DeepestCheckpoint() const; // Чекпоинт номер i (0 — первый этаж). Возвращает глубину. static int CheckpointDepth(int index) { return 1 + index * CHECKPOINT_STEP; } int CheckpointCount() const; // --- схрон ---------------------------------------------------------------- bool StashPush(ItemId id); // false — схрон полон bool StashRemoveAt(int index); int StashCount() const { return int(stash.size()); } // Сбросить снаряжение к стартовому комплекту. Схрон и прогресс не трогает: // потерять вылазку — не то же самое, что потерять всё нажитое. void ResetLoadouts(); bool Load(const std::string& path, std::string* err = nullptr); bool Save(const std::string& path, std::string* err = nullptr) const; }; // Где лежит профиль: рядом с бинарником или в корне репозитория. Возвращает // путь, даже если файла ещё нет, — по нему и будет первая запись. std::string DefaultProfilePath();