#pragma once // ----------------------------------------------------------------------------- // Профиль: всё, что переживает забег. // // Игра стала экстракшен-шутером, и это меняет смысл спуска целиком. Раньше // глубина была прогрессом: чем дальше, тем лучше. Теперь она СТАВКА — унесённое // остаётся, потерянное не возвращается, и каждый этаж это заново заданный // вопрос «ещё ниже или назад». // // Отсюда всё устройство файла: // * снаряжение бойцов лежит ЗДЕСЬ, а не собирается заново при старте. То, в // чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки; // * схрон — общий запас сверх надетого. Он и есть накопленный результат; // * максимальная глубина открывает чекпоинты. Без них десятитысячный этаж // недостижим не по сложности, а по времени. // // Формат текстовый и построчный по той же причине, что у комнат: профиль лежит // рядом с игрой, его правят руками при отладке, и он обязан читаться глазами. // // Профиль — состояние ИГРОКА, а не мира, поэтому он не в ecs::World и не // сбрасывается ни Reset, ни Descend. // ----------------------------------------------------------------------------- #include #include #include "sim/craft.h" #include "sim/merc.h" #include "sim/tree.h" #include #include "sim/items.h" #include "tuning.h" // Через сколько этажей ставится чекпоинт. Пятьдесят: забег — это ДОЛГИЙ спуск, // а не пятиэтажная петля. Чекпоинт через каждые пять превращал вылазку в // перебежку между сохранениями, и весь риск умещался в пять этажей. constexpr int CHECKPOINT_STEP = 50; // Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без // потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает. constexpr int STASH_CAP = 48; // Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже // которого забег не проваливается: без него полный вайп означал бы отряд с // голыми руками и конец игры вместо нового захода. struct StarterKit { ItemId weapon = ItemId::RIFLE; ItemId armor = ItemId::JACKET; ItemId trinket = ItemId::NONE; }; // ----------------------------------------------------------------------------- // Наёмники. НАХОДЯТСЯ, а не покупаются: капсула на этаже — это место, до // которого надо дойти, и риск, который надо пережить вместе с ним. // // Встречается редко: примерно раз в сто этажей и не чаще, чем раз в двадцать. // Пятый оперативник обязан быть событием забега, а не строкой в списке покупок. // ----------------------------------------------------------------------------- constexpr int RESCUE_EVERY = 100; // средняя редкость встречи, в этажах constexpr int RESCUE_COOLDOWN = 20; // ближе этого две капсулы не встречаются // Бросок наёмника. Сумма прибавок примерно одинакова, а распределение — нет: // один крепкий, другой быстрый, третий глазастый. Так двое найденных не // оказываются одним и тем же человеком с другим именем. MercStats RollMerc(uint32_t& rng); // Прежние четыре строки покупок (Upgrade::AMMO/CARRY/SIGHT/VIGOR) удалены // целиком: их заменило дерево (sim/tree.h). Держать обе системы значило бы // платить дважды за одно и то же и объяснять игроку, чем строка «+20 патронов» // отличается от узла «DEEP POCKETS». // ----------------------------------------------------------------------------- // Версия формата профиля. // // Заведена не «на будущее»: формат уже ломался дважды за три вехи — когда сумка // стала общей и когда появились улучшения. Оба раза старый файл читался молча и // частично, а игрок видел это как «схрон куда-то делся». // // 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent // 2 — общая сумка, улучшения отряда // 3 — размер отряда (найм): раньше бойцов всегда было пять // 4 — экземпляры предметов: редкость и аффиксы в токене после '@' // 5 — материалы: строка mat // 6 — наёмники: строка merc и глубина последней встречи // 7 — дерево улучшений вместо четырёх строк покупок // // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // читается вовсе. Второе важнее первого: молча обнулить профиль, который // записала более новая сборка, — это стереть десятки часов, а отказ читать // всего лишь просит не запускать старую сборку. // ----------------------------------------------------------------------------- constexpr int PROFILE_VERSION = 7; struct Profile { // Какой версией записан прочитанный файл. Новый профиль сразу текущей. int version = PROFILE_VERSION; // Уровни узлов дерева. Живут в профиле, а не в игре: это то, что // переживает вылазку (sim/tree.h). int node[TREE_NODES] = {}; // Сколько всего точек вложено в дерево. По ней считается общее удорожание // (sim/tree.h): каждая взятая точка поднимает цену следующих. int TreeRanks() const { int n = 0; for (int i = 0; i < TREE_NODES; ++i) n += node[i]; return n; } int NodeRank(int index) const { return (index >= 0 && index < TREE_NODES) ? node[index] : 0; } // Узел открыт, если предыдущий в его ветви взят хотя бы на уровень. Цепочка, // а не свободная сетка: ветвь обязана читаться как путь. bool NodeUnlocked(int index) const { const int req = TreeNodeRequires(index); return req < 0 || NodeRank(req) > 0; } bool CanBuyNode(int index) const { if (index < 0 || index >= TREE_NODES) return false; if (!NodeUnlocked(index)) return false; const int rank = NodeRank(index); if (rank >= TreeNodeAt(index).maxRank) return false; return bankedXp >= TreeNodeCost(index, rank, TreeRanks()); } bool BuyNode(int index) { if (!CanBuyNode(index)) return false; bankedXp -= TreeNodeCost(index, NodeRank(index), TreeRanks()); ++node[index]; return true; } // Сумма всего взятого. Считается при сборке этажа, дальше читаются готовые // числа: правила боя про дерево не знают. TreeBonus Tree() const { TreeBonus b; for (int i = 0; i < TREE_NODES; ++i) if (node[i] > 0) b.Add(TreeNodeAt(i).effect, TreeNodeAt(i).perRank * float(node[i])); return b; } // Сколько оперативников в отряде. Игра начинается ОДНИМ (docs/20-gdd.md), // потолок — NUM_AGENTS. Живёт в профиле, потому что это накопленный // результат, а не состояние вылазки: нанятый не теряется при вайпе, он // теряет только снаряжение, как и все. int squadSize = 1; // Числа найденных оперативников по слотам. У первого их нет: он и есть // тот, с кого игра начинается. MercStats merc[NUM_AGENTS]; // На какой глубине последний раз встретилась капсула. Нужна редкости: // «не чаще раза в двадцать этажей» считается отсюда. int lastRescueDepth = 0; // Место в отряде ещё есть? bool CanRescue() const { return squadSize < NUM_AGENTS; } // Снаряжение бойцов ровно в том виде, в каком они вернулись. Массив всегда // на все слоты: незанятые просто пустые. Inventory loadout[NUM_AGENTS]; // Общий запас сверх надетого. Экземпляры, а не виды: схрон — это то, из // чего собирают следующую вылазку, и вещь без аффиксов в нём была бы уже // другой вещью (sim/affix.h). std::vector stash; // Материалы. ВАЛЮТА: не занимают ячеек, не весят, в схроне не лежат // (sim/craft.h). Зачисляются только при эвакуации — ставка одна на всё. int material[MATERIAL_COUNT] = {}; // --- прогресс ------------------------------------------------------------- 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(const ItemInstance& 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();