diff --git a/docs/07-milestones.md b/docs/07-milestones.md index abd6eac..19de7d6 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -52,6 +52,8 @@ | **M32** | дерево улучшений вместо четырёх покупок: пять ветвей по восемь узлов, пять уровней у каждого, цепочка открытия, цена растёт от вложенного; старые покупки возвращают опыт в банк | `--tool economy` поймал, что первая версия дерева стоит 9% одного прохода 1..100 — то есть выбор кончался к сороковому этажу; введено общее удорожание (каждая вложенная точка поднимает цену следующих), стало 34 млн против 41 млн заложенных; проводка проверена по всем пяти ветвям: урон 12.0 → 14.4, здоровье 100 → 150, скорость 1.06 → 1.22, парирование 0.34 → 0.49, свет 420 → 620, закрытый узел не покупается; `--accept` PASS ([17-extraction.md](17-extraction.md)) | +| **M33** | крафт стал выбором: закалка (двигает рулон выбранного аффикса) и отпечаток (переносит аффикс с другой вещи, сжигая её), отсечение и возвышение работают по ВЫБРАННОМУ аффиксу, направленные операции вдвое дороже слепых | прежние пять операций были слепыми — игрок жал кнопку и смотрел, что выпало; проверка меряет направленность, а не срабатывание: отсечение пробуется на СРЕДНЕМ аффиксе из трёх, иначе слепая версия прошла бы её тоже; `--accept` PASS ([22-loot.md](22-loot.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/22-loot.md b/docs/22-loot.md index 9b97eb4..df9e6c2 100644 --- a/docs/22-loot.md +++ b/docs/22-loot.md @@ -169,24 +169,53 @@ share(KIN) = 1 − Σ конверсий, Σ конверсий ≤ 0.85 ### 5.2. Операции -| Операция | Что делает | Цена | Риск | +| Операция | Что делает | Цена | Цель | |---|---|---|---| -| Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | — | -| Прививка | добавляет аффикс НОВОГО тега, если есть бюджет | 2 био × тир | — | -| Отсечение | убирает последний аффикс | 1 крио × тир | — | -| Возвышение | +1 тир СЛУЧАЙНОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | — | -| **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | **12%: предмет рассыпается** | +| Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | слепо | +| **Закалка** | двигает рулон ВЫБРАННОГО аффикса к максимуму его тира | 6 лома × тир | по выбору | +| Прививка | добавляет аффикс нового тега, если есть бюджет | 2 био × тир | слепо | +| Отсечение | снимает ВЫБРАННЫЙ аффикс | 2 крио × тир | по выбору | +| Возвышение | +1 тир ВЫБРАННОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | по выбору | +| **Отпечаток** | переносит лучший аффикс с ДРУГОГО предмета; донор сгорает | 4 био + 4 крио × тир, 2 осколка | по выбору вещи | +| **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | 12%: предмет рассыпается | + +### Чего не хватало первой версии: ВЫБОРА + +Все пять прежних операций были слепыми: перековка бросала всё заново, прививка +ставила случайный тег, отсечение снимало ПОСЛЕДНИЙ аффикс, возвышение поднимало +случайный. Игрок не принимал ни одного решения — он жал кнопку и смотрел, что +выпало. Это не крафт, это второй бросок предмета за материалы. + +Теперь половина операций **направленные**: игрок показывает пальцем, какой аффикс +трогать. Плата за это — цена: направленная операция вдвое дороже слепой, и вот +это уже решение — «перебросить всё дёшево или снять один дорого». + +И появились два глагола, которых не было вовсе: + +- **Закалка** двигает рулон к верху его тира на треть остатка. Дёшево, понятно, + повторяемо — то самое «доработать почти хорошую вещь». Тир она не трогает: за + ступень платят возвышением; +- **Отпечаток** переносит лучший аффикс с другой вещи, сжигая её. Именно он + превращает хлам из схрона в ресурс, а не в мусор, который лежит. Правило «один + тег один раз» им не обходится, и бюджет предмета проверяется как везде. Три уточнения, каждое против конкретного обхода: - перековка меняет **только значения**. Меняй она ещё и теги — это был бы новый - бросок предмета, то есть способ обойти редкость: собери шесть слотов на чём - угодно и крути, пока не выпадет нужное; -- прививка не ставит тег, который уже есть: правило «один тег один раз» - руками не обходится; -- возвышается **случайный** аффикс. Выбирай игрок сам, возвышение стало бы - «+1 тир к урону» за фиксированную цену, и все предметы в игре сошлись бы - к одному. + бросок предмета, то есть способ обойти редкость; +- прививка не ставит тег, который уже есть; +- отпечаток берёт **лучший** аффикс донора, а не выбранный: игрок и так жертвует + целой вещью, и просить два решения за одну цену — перебор. + +Проверяется в `--descend` направленностью, а не фактом срабатывания: + +``` +направленность: отсечение по цели, возвышение по цели, закалка двигает рулон, + отпечаток перенёс и сжёг донора +``` + +Отсечение проверяется на СРЕДНЕМ аффиксе из трёх: операция, которая «работает», +снимая последний, прошла бы и слепую версию. Залом — единственная операция с риском, и он настоящий: предмет исчезает, а плата снимается всё равно. Замерено на тысяче попыток: **рассыпалось 11.6% при diff --git a/docs/ui-workshop.png b/docs/ui-workshop.png new file mode 100644 index 0000000..2a3ffc8 Binary files /dev/null and b/docs/ui-workshop.png differ diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 68e43b2..53a107e 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -1770,6 +1770,67 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t ++bad; } + // 4.5. НАПРАВЛЕННОСТЬ. Половина операций работает по ВЫБРАННОМУ аффиксу, + // и это главное, чего не было в первой версии крафта: там игрок + // жал кнопку и смотрел, что выпало. + { + ItemInstance aim(ItemId::VEST); + aim.rarity = Rarity::PROTOTYPE; + aim.affix[aim.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f}; + aim.affix[aim.count++] = Affix{AffixTag::ARMOR, 2, 0, 0.05f}; + aim.affix[aim.count++] = Affix{AffixTag::CARRY, 2, 0, 0.40f}; + + // Отсечение снимает именно средний, а не последний. + const CraftResult cut = ApplyCraft(CraftOp::EXCISE, aim, pr.material, 40, rng, 1); + const bool cutRight = cut == CraftResult::OK && aim.Count() == 2 && + aim.affix[0].tag == AffixTag::HEALTH && + aim.affix[1].tag == AffixTag::CARRY; + + // Возвышение поднимает именно указанный. + const int tierBefore = aim.affix[1].tier; + const CraftResult up = ApplyCraft(CraftOp::EXALT, aim, pr.material, 40, rng, 1); + const bool upRight = up == CraftResult::OK && aim.affix[1].tier == tierBefore + 1 && + aim.affix[0].tier == 2; + + // Закалка двигает рулон вверх, не трогая тир. + const float rollBefore = aim.affix[0].value; + const int tierKept = aim.affix[0].tier; + const CraftResult tmp = ApplyCraft(CraftOp::TEMPER, aim, pr.material, 40, rng, 0); + const bool tempRight = tmp == CraftResult::OK && aim.affix[0].value > rollBefore && + aim.affix[0].tier == tierKept; + + // Отпечаток переносит аффикс донора и сжигает его. + ItemInstance donor(ItemId::PLATE); + donor.rarity = Rarity::RELIC; + donor.affix[donor.count++] = Affix{AffixTag::SPEED, 4, 0, 0.12f}; + const int haveBefore = aim.Count(); + const CraftResult imp = + ApplyCraft(CraftOp::IMPRINT, aim, pr.material, 40, rng, 0, &donor); + const bool impRight = imp == CraftResult::OK && aim.Count() == haveBefore + 1 && + donor.Empty(); + + std::printf("направленность: отсечение %s, возвышение %s, закалка %s, отпечаток %s\n", + cutRight ? "по цели" : "МИМО", upRight ? "по цели" : "МИМО", + tempRight ? "двигает рулон" : "НЕ РАБОТАЕТ", + impRight ? "перенёс и сжёг донора" : "НЕ СРАБОТАЛ"); + + if (!cutRight || !upRight) + { + std::printf(" [FAIL] направленная операция трогает не тот аффикс\n"); + ++bad; + } + if (!tempRight) + { + std::printf(" [FAIL] закалка не двигает рулон или трогает тир\n"); + ++bad; + } + if (!impRight) + { + std::printf(" [FAIL] отпечаток не перенёс аффикс или не сжёг донора\n"); + ++bad; + } + } + // 5. Залом на тысяче попыток: доля потерь, плата за неудачу и размен. int destroyed = 0; int paidTimes = 0; diff --git a/src/sim/craft.cpp b/src/sim/craft.cpp index 2dce73b..d2b5493 100644 --- a/src/sim/craft.cpp +++ b/src/sim/craft.cpp @@ -90,11 +90,26 @@ CraftCost CostOf(CraftOp op, const ItemInstance& it) case CraftOp::REFORGE: c.amount[int(MaterialKind::SCRAP)] = 4 * t; break; + case CraftOp::TEMPER: + // Дёшево и повторяемо: закалка — это работа напильником, а не + // событие. Она и не должна ощущаться выбором вылазки. + c.amount[int(MaterialKind::SCRAP)] = 6 * t; + break; + case CraftOp::IMPRINT: + // Дороже возвышения: перенос ЛЮБОГО чужого аффикса — самая + // управляемая операция в игре, и платить за неё надо и материалом, + // и второй вещью. + c.amount[int(MaterialKind::BIO)] = 4 * t; + c.amount[int(MaterialKind::CRYO)] = 4 * t; + c.amount[int(MaterialKind::SHARD)] = 2; + break; case CraftOp::GRAFT: c.amount[int(MaterialKind::BIO)] = 2 * t; break; case CraftOp::EXCISE: - c.amount[int(MaterialKind::CRYO)] = 1 * t; + // Направленная операция дороже слепой ВДВОЕ: выбор — это то, за + // что здесь платят. + c.amount[int(MaterialKind::CRYO)] = 2 * t; break; case CraftOp::EXALT: c.amount[int(MaterialKind::BIO)] = 3 * t; @@ -108,10 +123,16 @@ CraftCost CostOf(CraftOp op, const ItemInstance& it) return c; } -CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng) +CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng, + int target, ItemInstance* donor) { if (it.Empty() || !materials) return CraftResult::NOT_APPLICABLE; + // Цель обязана существовать. Молча брать первый попавшийся аффикс нельзя: + // игрок показал пальцем, и промах здесь — это потерянные материалы. + if (CraftNeedsTarget(op) && (target < 0 || target >= it.Count())) + return CraftResult::NOT_APPLICABLE; + const CraftCost cost = CostOf(op, it); if (!Enough(materials, cost)) return CraftResult::NO_MATERIALS; @@ -176,10 +197,55 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, break; } + case CraftOp::TEMPER: + { + // Рулон подтягивается к ВЕРХУ своего тира на треть остатка. Тир не + // трогается: закалка улучшает то, что уже есть, а не поднимает + // предмет на ступень — за ступень платят возвышением. + Affix& a = it.affix[target]; + const AffixRange base = AffixBase(a.tag); + const float scale = std::pow(float(a.tier), 1.35f); + const float top = base.hi * scale; + if (a.value >= top - 0.0001f) return CraftResult::NOT_APPLICABLE; + a.value += (top - a.value) * 0.34f; + break; + } + case CraftOp::EXCISE: { if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; - --it.count; // убирается последний: выбор конкретного — дело UI + // Снимается ВЫБРАННЫЙ, а не последний: остальные сдвигаются на его + // место. Порядок аффиксов ничего не значит, кроме того, что игрок + // видит их списком. + for (int i = target; i + 1 < it.Count(); ++i) it.affix[i] = it.affix[i + 1]; + --it.count; + break; + } + + case CraftOp::IMPRINT: + { + if (!donor || donor->Empty() || donor->Count() <= 0) return CraftResult::NOT_APPLICABLE; + if (it.Count() >= MAX_AFFIXES) return CraftResult::NOT_APPLICABLE; + + // Берётся ЛУЧШИЙ аффикс донора: игрок и так жертвует целой вещью, + // и заставлять его выбирать ещё и внутри неё значит просить два + // решения за одну цену. + int bestI = 0; + for (int i = 1; i < donor->Count(); ++i) + if (donor->affix[i].tier > donor->affix[bestI].tier) bestI = i; + const Affix moved = donor->affix[bestI]; + + // Тот же тег дважды не ставится: правило «один тег один раз» + // отпечатком не обходится (sim/affix.h). + for (int i = 0; i < it.Count(); ++i) + if (it.affix[i].tag == moved.tag) return CraftResult::NOT_APPLICABLE; + + // Бюджет считается по ТЕКУЩЕЙ глубине, как и везде: перенести + // высокий тир на слабый предмет нельзя, места не хватит. + if (AffixCost(moved.tier) > budget) return CraftResult::NOT_APPLICABLE; + + it.affix[it.count++] = moved; + *donor = ItemInstance{}; // донор сгорел break; } @@ -187,25 +253,19 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, { if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; - // Возвышается СЛУЧАЙНЫЙ аффикс, и это не лень: выбирай игрок сам, - // возвышение стало бы просто «+1 тир к урону» за фиксированную - // цену, и все предметы в игре сошлись бы к одному. - int candidates[MAX_AFFIXES]; - int n = 0; - for (int i = 0; i < it.Count(); ++i) - { - if (it.affix[i].tier >= tierCap) continue; - if (AffixCost(it.affix[i].tier + 1) - AffixCost(it.affix[i].tier) > budget) continue; - candidates[n++] = i; - } - if (n == 0) return CraftResult::NOT_APPLICABLE; - - const int pick = candidates[RandRange(rng, 0, n - 1)]; - Affix& a = it.affix[pick]; - const float before = std::pow(float(a.tier), 1.35f); + // Возвышается ВЫБРАННЫЙ аффикс. Раньше он выбирался случайно — из + // опасения, что «+1 тир к урону за фиксированную цену» сведёт все + // предметы к одному. Опасение снято ценой: возвышение стоит и ядер + // обоих видов, и осколка, и дорожает с тиром предмета. Слепое же + // возвышение просто не давало игроку ни одного решения. + Affix& a = it.affix[target]; + if (a.tier >= tierCap) return CraftResult::NOT_APPLICABLE; + if (AffixCost(a.tier + 1) - AffixCost(a.tier) > budget) + return CraftResult::NOT_APPLICABLE; + const float beforeScale = std::pow(float(a.tier), 1.35f); ++a.tier; - const float after = std::pow(float(a.tier), 1.35f); - a.value *= after / before; // рулон сохраняет своё место в диапазоне + const float afterScale = std::pow(float(a.tier), 1.35f); + a.value *= afterScale / beforeScale; // рулон сохраняет место в диапазоне break; } diff --git a/src/sim/craft.h b/src/sim/craft.h index 7407bbd..6c8700a 100644 --- a/src/sim/craft.h +++ b/src/sim/craft.h @@ -117,24 +117,56 @@ MaterialDrop PropDrop(int depth); // не за их количество. Тир берётся максимальный на предмете — по нему и видно, // с какой глубины вещь. // ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// ЧЕГО НЕ ХВАТАЛО ПЕРВОЙ ВЕРСИИ: ВЫБОРА. +// +// Все пять операций были слепыми: перековка бросала всё заново, прививка ставила +// СЛУЧАЙНЫЙ тег, отсечение снимало ПОСЛЕДНИЙ аффикс, возвышение поднимало +// СЛУЧАЙНЫЙ. Игрок не принимал ни одного решения — он жал кнопку и смотрел, что +// выпало. Это не крафт, это второй бросок предмета за материалы. +// +// Теперь у половины операций есть ЦЕЛЬ: игрок показывает пальцем, какой аффикс +// трогать. Плата за это — цена: направленная операция дороже слепой, и вот +// ЭТО уже решение («перебросить всё дёшево или снять один дорого»). +// +// И появились два глагола, которых не было вовсе: +// ЗАКАЛКА — двигает РУЛОН к максимуму своего тира, не трогая сам тир. Дёшево, +// понятно, повторяемо: то самое «доработать почти хорошую вещь». +// ОТПЕЧАТОК — переносит аффикс с ДРУГОГО предмета, сжигая его. Именно он +// превращает хлам из схрона в ресурс, а не в мусор, который лежит. +// ----------------------------------------------------------------------------- enum class CraftOp : uint8_t { - REFORGE = 0, // перебросить ВСЕ значения, теги и тиры на месте + REFORGE = 0, // перебросить ВСЕ значения; слепо и дёшево + TEMPER, // подтянуть рулон ВЫБРАННОГО аффикса к максимуму его тира GRAFT, // добавить аффикс, если позволяет бюджет - EXCISE, // убрать выбранный аффикс - EXALT, // +1 тир случайному аффиксу + EXCISE, // снять ВЫБРАННЫЙ аффикс + EXALT, // +1 тир ВЫБРАННОМУ аффиксу + IMPRINT, // перенести аффикс с другого предмета; донор сгорает FRACTURE, // +2 тира всем, но на один аффикс меньше; 12% — предмет рассыпается COUNT, }; +// Нужна ли операции ЦЕЛЬ — конкретный аффикс на предмете. UI по этому флагу +// решает, показывать ли выбор, а проверка — что выбор вообще учитывается. +inline bool CraftNeedsTarget(CraftOp op) +{ + return op == CraftOp::TEMPER || op == CraftOp::EXCISE || op == CraftOp::EXALT; +} + +// Нужен ли операции ДОНОР — второй предмет, который сгорит. +inline bool CraftNeedsDonor(CraftOp op) { return op == CraftOp::IMPRINT; } + inline const char* CraftOpName(CraftOp op) { switch (op) { case CraftOp::REFORGE: return "REFORGE"; + case CraftOp::TEMPER: return "TEMPER"; case CraftOp::GRAFT: return "GRAFT"; case CraftOp::EXCISE: return "EXCISE"; case CraftOp::EXALT: return "EXALT"; + case CraftOp::IMPRINT: return "IMPRINT"; default: return "FRACTURE"; } } @@ -143,10 +175,12 @@ inline const char* CraftOpEffect(CraftOp op) { switch (op) { - case CraftOp::REFORGE: return "REROLL EVERY VALUE"; + case CraftOp::REFORGE: return "REROLL EVERY VALUE, BLIND AND CHEAP"; + case CraftOp::TEMPER: return "PUSH THE PICKED ROLL TOWARDS ITS TIER MAX"; case CraftOp::GRAFT: return "ADD ONE AFFIX IF BUDGET ALLOWS"; - case CraftOp::EXCISE: return "REMOVE THE LAST AFFIX"; - case CraftOp::EXALT: return "RAISE ONE AFFIX BY A TIER"; + case CraftOp::EXCISE: return "REMOVE THE PICKED AFFIX"; + case CraftOp::EXALT: return "RAISE THE PICKED AFFIX BY A TIER"; + case CraftOp::IMPRINT: return "COPY AN AFFIX FROM ANOTHER ITEM, BURNING IT"; default: return "+2 TIERS, ONE SLOT LESS, 12% TO DESTROY"; } } @@ -177,7 +211,11 @@ enum class CraftResult : uint8_t // Применить операцию. Материалы списываются ТОЛЬКО при OK и DESTROYED: за // неудавшийся залом платят, за невозможную операцию — нет. // -// depth — глубина, по которой считается потолок тира при перековке и прививке. -// Это рекорд игрока: мастерская на базе, и доступное в ней — то, куда он уже -// доходил. -CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng); +// depth — глубина, по которой считается потолок тира. Это рекорд игрока: +// мастерская на базе, и доступное в ней — то, куда он уже доходил. +// +// target — какой аффикс трогать у операций с целью (CraftNeedsTarget). +// donor — предмет, который сгорит; нужен отпечатку. Обнуляется при успехе, и +// это не побочный эффект, а суть: отпечаток ПЕРЕПЛАВЛЯЕТ вещь. +CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng, + int target = 0, ItemInstance* donor = nullptr); diff --git a/src/sim/items.h b/src/sim/items.h index b26817b..cd57ad0 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -39,19 +39,43 @@ enum class ItemId : uint8_t { NONE = 0, + // Порядок значений тут ЧИСЛОВОГО смысла не имеет: профиль и каталог хранят + // предметы токенами (`IdOfItem` / `ItemIdOfId`, sim/spawn_catalog.cpp), а не + // номерами. Поэтому вставлять новое посреди перечисления безопасно — старые + // сохранения читаются как читались. + // --- стволы --- RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW, + NAILER, // гвоздомёт: дешёвый и частый, урона почти нет + HMG, // пулемёт: магазин на полтораста и вес, равный норме переноски + PLASMA, // резак: стреляет с той дистанции, где обычно рубят + RAIL, // рельса: дальше всех, три выстрела, пять секунд молчания + FLAMER, // огнемёт: конус жара, свои под него лезут постоянно + ACID, // распылитель: медленные кислотные капли // --- клинки --- SABER, KNIFE, CLEAVER, + STILETTO, // стилет: лучшее парирование в игре, худший урон + MAUL, // молот: сильнее всех бьёт, слабее всех защищается + RIPPER, // цепной: частые слабые укусы с кислотой + CRYOEDGE, // крио-клинок: сабля, обменявшая урон на холод // --- броня --- JACKET, // куртка: почти ничего, зато не мешает VEST, // жилет: ровный размен брони на скорость PLATE, // плита: держит удар, но боец становится обозом SHROUD, // саван: брони нет, зато тише и быстрее + RECON, // разведрама: обзор вместо брони + HELM, // глухой шлем: броня вместо обзора + CARAPACE, // панцирь: запас здоровья вместо веса + WEAVE, // плетёнка: лёгкая броня ценой зрения // --- карман --- LANTERN, // фонарь: видно дальше, и тебя тоже CHARM, // оберег: запас здоровья SPURS, // шпоры: скорость + THERMAL, // тепловизор: видит и в темноте, но тяжелее и медленнее фонаря + PAULDRON, // наплечник: единственная броня, которая лезет в карман + CLAMP, // зажим: много здоровья за ход + ADRENAL, // впрыск: скорость за туннельное зрение + HARNESS, // разгрузка: понемногу всего и заметный вес // --- расходники --- BANDAGE, STIMPACK, @@ -295,6 +319,40 @@ inline ItemDef ItemDefOf(ItemId id) d.weight = 1.4f; d.r = 154; d.g = 190; d.b = 164; break; + // Вес стволов — единственная цена, которую платит РУКА: `Inventory::Load` + // считает только то, что в руках (надетое уже оплачено множителем + // скорости). Поэтому у тяжёлого ствола вес — не украшение: пулемёт с его + // 7.5 равен всей норме переноски отряда (CARRY_LIMIT), и любая находка + // в сумке сверх него уже замедляет. + case ItemId::NAILER: + d.name = "NAILER"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::NAILER; + d.weight = 1.3f; + d.r = 170; d.g = 160; d.b = 140; break; + case ItemId::HMG: + d.name = "HMG"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::HMG; + // Множитель скорости тут не про массу (её считает вес), а про ХВАТ: + // пулемёт несут поперёк корпуса двумя руками, и бежать с ним нельзя + // даже налегке. Единственный ствол в каталоге с такой пометкой. + d.speedMul = 0.90f; + d.weight = 7.5f; + d.r = 120; d.g = 126; d.b = 120; break; + case ItemId::PLASMA: + d.name = "PLASMA"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::PLASMA; + d.weight = 2.6f; + d.r = 130; d.g = 210; d.b = 240; break; + case ItemId::RAIL: + d.name = "RAIL"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::RAIL; + d.weight = 4.4f; + d.r = 190; d.g = 200; d.b = 225; break; + case ItemId::FLAMER: + d.name = "FLAMER"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::FLAMER; + d.weight = 4.0f; // баллон со смесью тяжелее самого оружия + d.r = 226; d.g = 120; d.b = 60; break; + case ItemId::ACID: + d.name = "ACID"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::ACID; + d.weight = 2.9f; + d.r = 150; d.g = 200; d.b = 90; break; + // --- клинки --- case ItemId::SABER: d.name = "SABER"; d.kind = ItemKind::BLADE; d.blade = BladeId::SABER; @@ -311,6 +369,30 @@ inline ItemDef ItemDefOf(ItemId id) d.weight = 2.2f; d.r = 196; d.g = 174; d.b = 160; break; + // Вес клинка и его парирование — одна и та же ось, только с разных + // сторон (sim/weapon_defs.h): 0.3 у стилета отвечает 0.52 парирования, + // 5.5 у молота — 0.02. Меняя тут вес, надо менять и там parry, иначе + // каталог начнёт врать сам себе. + case ItemId::STILETTO: + d.name = "STILETTO"; d.kind = ItemKind::BLADE; d.blade = BladeId::STILETTO; + d.speedMul = 1.08f; + d.weight = 0.3f; + d.r = 228; d.g = 232; d.b = 240; break; + case ItemId::MAUL: + d.name = "MAUL"; d.kind = ItemKind::BLADE; d.blade = BladeId::MAUL; + d.speedMul = 0.88f; // тяжелее тесака, и это видно по ходу + d.weight = 5.5f; + d.r = 150; d.g = 140; d.b = 128; break; + case ItemId::RIPPER: + d.name = "RIPPER"; d.kind = ItemKind::BLADE; d.blade = BladeId::RIPPER; + d.speedMul = 0.97f; + d.weight = 2.6f; + d.r = 168; d.g = 196; d.b = 120; break; + case ItemId::CRYOEDGE: + d.name = "CRYOEDGE"; d.kind = ItemKind::BLADE; d.blade = BladeId::CRYOEDGE; + d.weight = 1.1f; // легче сабли: отсюда и парирование выше + d.r = 150; d.g = 205; d.b = 230; break; + // --- броня --- case ItemId::JACKET: d.name = "JACKET"; d.kind = ItemKind::ARMOR; diff --git a/src/sim/target.cpp b/src/sim/target.cpp index 702e4f8..ca71cb5 100644 --- a/src/sim/target.cpp +++ b/src/sim/target.cpp @@ -194,6 +194,212 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.resist = Resists(0.30f, -0.60f, 0.10f, 0.0f, 0.0f, 0.0f, 0.60f); break; + // --------------------------------------------------------------------- + // НАСЕЛЕНИЕ ГЛУБИНЫ (акты II–VI). + // + // Шесть видов выше держат первые семь этажей и кончаются там же. Дальше + // спуск на сотню этажей упирался в одно и то же: те же твари, только их + // больше. Множителя HP от глубины в этой игре нет и не будет + // (docs/16-descent.md) — значит, глубина обязана приносить ДРУГИХ. + // + // Каждая ниже проходит один и тот же отбор: + // 1. своя ЗАДАЧА игроку, которой не решает ни одна из уже готовых; + // 2. роль читается по профилю сопротивлений и по виду атаки; + // 3. ни одного иммунитета: потолок 0.85, уязвимость не ниже −0.60 + // (sim/element.h). Тварь, которую нечем ранить, — тупик, а не + // сложность: игрок без нужной стихии просто перестаёт играть. + // + // Числа здоровья подобраны под НЫНЕШНИЙ каталог оружия (магнум 78 за + // выстрел), а не под обещанные девятьсот на сотом этаже: тварь, которую + // не берут сегодняшние стволы, ничем не отличается от иммунной. + // + // Профили взяты из спроектированной таблицы docs/21-damage.md §5. Где + // они от неё отходят — отмечено в комментарии вида и объяснено: в + // таблице расписаны семь стихий, а носят их сегодня ПЯТЬ стволов + // (кинетика, жар, дуга, кислота, пустота), и вид, у которого и + // уязвимость, и сопротивление легли мимо этих пяти, для игрока + // неотличим от вида вообще без профиля (docs/21-damage.md §6.1). + // --------------------------------------------------------------------- + + // Головня. Тварь, вокруг которой акт II учит стихиям: она ГОРИТ, и + // дробовик — лучший ствол против всего остального — против неё почти + // бесполезен (0.80 по жару против −0.55 по холоду). Задача игроку — + // не «убей», а «принеси второй ствол»: до неё огонь всегда был просто + // лучше, и это первый этаж, где такой ответ не работает. + // + // Бьёт не выпадом, а вспышкой вокруг себя: подходить к ней в упор + // нельзя даже вдвоём, зато круг маленький и уйти из него успевает + // любой. Светится сильнее всех — единственная тварь, которую видно в + // темноте раньше, чем она увидит отряд, и это честная плата за то, + // что взять её нечем, кроме кинетики. + case EnemyKind::CINDER: + d.name = "CINDER"; + d.hp = 190.0f; d.radius = 0.32f; d.speed = 1.55f; d.knockResist = 0.20f; + d.aggroRadius = 10.0f; d.attackRange = 1.00f; d.damage = 22.0f; + d.windup = 0.60f; d.interval = 2.00f; + d.attack = AttackKind::SLAM; d.slamRadius = 1.60f; + d.lightPower = 0.30f; + d.r = 178; d.g = 92; d.b = 40; + // жар в неё не идёт вовсе, холод разбирает; пуля работает как всегда + d.resist = Resists(0.10f, 0.80f, -0.55f, 0.0f, 0.0f, 0.30f, 0.10f); + break; + + // Жалун. Плевок стреляет редко и больно, этот — часто и почти не + // больно, зато с девяти юнитов и не переставая. Задача другая: он не + // убивает, он не даёт СТОЯТЬ. Решатель огневых позиций держит отряд на + // месте, пока цель под огнём, — жалун обнуляет это, и до него + // приходится идти, ломая строй. + // + // Живучести у него нет намеренно: дошёл — снял одним магазином. Цена + // не в его здоровье, а в том, куда отряд ради него ушёл. + case EnemyKind::STINGER: + d.name = "STINGER"; + d.hp = 85.0f; d.radius = 0.26f; d.speed = 1.90f; d.knockResist = 0.0f; + d.aggroRadius = 12.5f; d.attackRange = 7.00f; d.damage = 6.0f; + d.windup = 0.30f; d.interval = 0.55f; + d.ranged = true; d.projectileSpeed = 11.0f; + d.attack = AttackKind::SPIT; d.spitRange = 9.00f; + d.lightPower = 0.08f; + d.r = 104; d.g = 120; d.b = 132; + // хитин держит удар и кислоту, но трескается от жара и стыни + d.resist = Resists(0.35f, -0.35f, -0.25f, 0.50f, 0.15f, 0.0f, 0.20f); + break; + + // Рой. Одно тело, внутри которого много: отсюда и широчайший сектор + // удара (обойти его вбок нельзя, из него можно только ВЫЙТИ), и частые + // слабые укусы вместо одного удара. Задача игроку — площадь: жар и дуга + // снимают его мгновенно, а прицельная стрельба по одному телу работает + // ровно так же плохо, как стрельба по стае. + // + // Дёшев и быстр: рой не «сильнее бредущего», он проверяет, взяли ли + // вниз хоть что-то, кроме кинетики. + // + // Отход от таблицы: кислота там нулевая, здесь 0.55. Иначе у роя не + // остаётся НИ ОДНОГО сопротивления среди стихий, которые носят стволы + // (жар, дуга и радиация у него уже расписаны, а радиацию не носит + // никто), и разброс схлопывается в 1.3 — то есть «состав удара по нему + // не значит ничего». Смысл тот же: кислота травит одно тело, а их тут + // много. + case EnemyKind::SWARM: + d.name = "SWARM"; + d.hp = 40.0f; d.radius = 0.30f; d.speed = 2.20f; d.knockResist = 0.0f; + d.aggroRadius = 13.0f; d.attackRange = 0.65f; d.damage = 5.0f; + d.windup = 0.22f; d.interval = 0.75f; + d.attack = AttackKind::SWIPE; d.swipeArcDeg = 160.0f; + d.lightPower = 0.04f; + d.r = 138; d.g = 132; d.b = 56; + // горит и проводит дугу как солома; кислоте достаётся одно тело из многих + d.resist = Resists(-0.20f, -0.60f, 0.0f, 0.55f, -0.55f, 0.40f, 0.30f); + break; + + // Скорлупа. Стена: медленная, тупая, с узким сектором удара — уйти от + // неё вбок проще, чем от бредущего. Опасна не ударом, а тем, что стоит + // и не умирает: шестьсот с лишним здоровья под сопротивлением 0.70 по + // кинетике — это семьдесят секунд работы карабином, то есть весь + // боезапас отряда за одну тварь. + // + // Ответ ровно один и написан в профиле: −0.50 по кислоте. Лук снимает + // её за пятнадцать секунд. Задача игроку — не «убей», а «пойми, что + // прежний ствол больше не ствол», и это первая тварь, ради которой + // сборку меняют целиком. + // + // Отброса нет вообще (0.90): клинком её не сдвинуть, коридор она + // перекрывает намертво. + case EnemyKind::HUSK: + d.name = "HUSK"; + d.hp = 620.0f; d.radius = 0.44f; d.speed = 0.80f; d.knockResist = 0.90f; + d.aggroRadius = 8.00f; d.attackRange = 1.00f; d.damage = 26.0f; + d.windup = 0.70f; d.interval = 1.80f; + d.attack = AttackKind::SWIPE; d.swipeArcDeg = 55.0f; + d.lightPower = 0.06f; + d.r = 128; d.g = 122; d.b = 104; + // панцирь держит всё, кроме кислоты: она его и растворяет + d.resist = Resists(0.70f, 0.30f, 0.40f, -0.50f, 0.30f, 0.20f, 0.15f); + break; + + // Спящий. Радиус агро 3.4 против 8–13 у всех остальных: он почти слеп, + // и мимо него МОЖНО пройти. Единственная тварь в игре, с которой боя + // может не быть вовсе, — и ровно поэтому она бьёт так, что бой лучше + // не начинать: круг радиусом три юнита и шестьдесят два урона снимают + // половину отряда, если тот шёл кучей. + // + // Задача игроку — маршрут, а не стрельба. Замах длинный (1.15 с), + // потому что уйти из такого круга обязано быть возможно; глаза почти + // не горят (0.02) — именно поэтому её и принимают за камень. + // + // Профиль в таблице §5 её не описывает: вид заведён здесь. Холод + // (−0.50) — потому что разбудить её должно быть дешевле, чем ломать + // панцирь; пустота (−0.30) — потому что спит она не совсем в этом мире. + case EnemyKind::SLEEPER: + d.name = "SLEEPER"; + d.hp = 780.0f; d.radius = 0.50f; d.speed = 0.85f; d.knockResist = 0.95f; + d.aggroRadius = 3.40f; d.attackRange = 1.40f; d.damage = 62.0f; + d.windup = 1.15f; d.interval = 3.00f; + d.attack = AttackKind::SLAM; d.slamRadius = 3.00f; + d.lightPower = 0.02f; + d.r = 96; d.g = 88; d.b = 74; + // камень: пуля вязнет, жар не берёт, а стынь и пустота будят и рвут + d.resist = Resists(0.55f, 0.30f, -0.50f, 0.10f, 0.10f, 0.20f, -0.30f); + break; + + // Плакальщик. Единственная тварь, которая меняет не бой, а ПОРЯДОК + // целей: пока он жив, лежащие рядом встают в шесть раз быстрее, и + // зачистка комнаты не заканчивается никогда. Сам он почти безобиден — + // одиннадцать урона плевком раз в две секунды. + // + // Задача игроку — увидеть, кто здесь главный, и добраться до него + // первым. Он держится позади (плюётся с пяти юнитов), светится ярче + // соседей (0.14) и ходит медленно: игра показывает его честно, а + // ошибается тот, кто стреляет в ближайшего. + // + // По таблице §5 ответ на него — радиация, и она здесь на −0.60. Но + // радиацию сегодня не носит ни один ствол, поэтому у него есть и + // вторая брешь — пустота (−0.35): она обрывает связь, которой он тянет + // мёртвых, и её носит магнум. Кислота (0.55) — сопротивление, без + // которого разброс стихий по нему схлопывается ниже коридора. + case EnemyKind::WAILER: + d.name = "WAILER"; + d.hp = 240.0f; d.radius = 0.33f; d.speed = 1.00f; d.knockResist = 0.20f; + d.aggroRadius = 12.0f; d.attackRange = 5.00f; d.damage = 11.0f; + d.windup = 0.70f; d.interval = 2.20f; + d.ranged = true; d.projectileSpeed = 7.0f; + d.attack = AttackKind::SPIT; d.spitRange = 7.00f; + // Аура, а не атака: он никого ею не бьёт и не целится. Радиус + // меньше дальности его же плевка — тела, которые он поднимает, + // лежат вокруг НЕГО, и подойти к ним отряду придётся. + d.reviveRadius = 4.20f; d.reviveRate = 6.00f; + d.lightPower = 0.14f; + d.r = 158; d.g = 150; d.b = 176; + // радиация выжигает то, чем он тянет мёртвых; пустота рвёт саму связь + d.resist = Resists(0.20f, 0.0f, 0.0f, 0.55f, 0.25f, -0.60f, -0.35f); + break; + + // Пустотник. Ровные 0.45 по шести стихиям: против него нет верного + // ствола, потому что все стволы одинаково плохи. Это последний урок + // акта VI и проверка на то, что игрок понял систему целиком: брешь + // одна — пустота (−0.60), и берётся он не подбором стихии, а СРЕЗАНИЕМ + // сопротивлений (магнум, схлопывание, сверхпроводник). + // + // Ходит быстро и рвёт на шесть с половиной юнитов — вдвое дальше + // бегуна: дистанции для него нет, и держать его на расстоянии + // бессмысленно. Задача игроку — сборка, а не позиция. + // + // Таблица §5 даёт ему ровные 0.30. Здесь 0.45: при 0.30 разброс стихий + // по нему выходит 1.48 — то есть «пустота почти ничего не меняет», а + // весь смысл вида в обратном. Иммунитета всё равно нет нигде: даже + // карабин его убивает, просто вчетверо дольше магнума. + case EnemyKind::VOIDLING: + d.name = "VOIDLING"; + d.hp = 300.0f; d.radius = 0.32f; d.speed = 2.00f; d.knockResist = 0.45f; + d.aggroRadius = 14.0f; d.attackRange = 0.85f; d.damage = 24.0f; + d.windup = 0.40f; d.interval = 1.50f; + d.attack = AttackKind::LUNGE; d.lungeDist = 6.40f; d.lungeTime = 0.26f; + d.lightPower = 0.16f; + d.r = 52; d.g = 48; d.b = 86; + // защищён ровно всюду и нигде не непробиваем; пустота — единственная брешь + d.resist = Resists(0.45f, 0.45f, 0.45f, 0.45f, 0.45f, 0.45f, -0.60f); + break; + // --- разрушаемая обстановка ------------------------------------------- // Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы // UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем @@ -333,6 +539,29 @@ void UpdateTargets(std::vector& targets, float dt) const float damp = std::exp(-g_tune.targetKnockDamp * dt); const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt); + // --- кто тянет мёртвых обратно (плакальщик) ------------------------------ + // Живёт ЗДЕСЬ, а не в sim/enemy.cpp, потому что срок покойника считает эта + // функция и только она: поднятие павшего — это ускоренный тот же таймер, а + // не второй механизм воскрешения рядом с первым. + // + // Список собирается один раз за шаг. EnemyDefOf — это switch, и спрашивать + // его на каждую пару «труп × тварь» значило бы четыре тысячи вызовов в кадр + // на полном этаже. Почти всегда список пуст, и тогда весь механизм не стоит + // ничего: в песочнице приёмки он пуст ВСЕГДА (там одни мишени), поэтому + // --accept меряет ровно то же, что мерял. + struct Reviver { Vec2 pos; float radius2; float rate; }; + Reviver revivers[MAX_TARGETS]; + int reviverCount = 0; + for (const Target& t : targets) + { + if (!t.alive || t.IsDummy() || t.IsProp()) continue; + if (reviverCount >= MAX_TARGETS) break; + const EnemyDef def = EnemyDefOf(t.kind); + if (def.reviveRate <= 1.0f || def.reviveRadius <= 0.0f) continue; + revivers[reviverCount++] = + Reviver{t.pos, def.reviveRadius * def.reviveRadius, def.reviveRate}; + } + for (Target& t : targets) { t.hurtTimer = std::max(0.0f, t.hurtTimer - dt); @@ -356,7 +585,20 @@ void UpdateTargets(std::vector& targets, float dt) // отрицательный срок, и без этой проверки он воскресал бы в тот же кадр. if (t.IsProp()) continue; - t.respawnTimer -= dt; + // Плакальщик рядом — срок идёт быстрее. Берётся НАИБОЛЬШИЙ множитель, а + // не сумма: двое плакальщиков не поднимают вдвое быстрее, иначе комната + // с тремя такими не зачищается в принципе, сколько по ней ни стреляй. + // Мишень приёмки под ауру не попадает: её срок — ручка tuning, на + // которой меряют, и трогать его нельзя ничем. + float reviveRate = 1.0f; + if (!t.IsDummy()) + { + for (int i = 0; i < reviverCount; ++i) + if (DistanceSq(revivers[i].pos, t.pos) <= revivers[i].radius2) + reviveRate = std::max(reviveRate, revivers[i].rate); + } + + t.respawnTimer -= dt * reviveRate; if (t.respawnTimer <= 0.0f) { const EnemyDef def = EnemyDefOf(t.kind); diff --git a/src/sim/target.h b/src/sim/target.h index ca5628d..b271980 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -35,6 +35,22 @@ enum class EnemyKind : uint8_t BRUTE, // громила: медленный, толстый, сносит одним ударом LURKER, // хват: подбирается и держит бойца на месте + // --- население глубины (акты II–VI, docs/20-gdd.md §3) --------------------- + // Каждая ниже поставлена ради СВОЕЙ задачи игроку, а не ради строки в + // бестиарии. «Тот же бредущий, но толще» — это не новая тварь, а множитель + // от глубины, которого в этой игре нет (docs/16-descent.md): вниз выходят + // ДРУГИЕ виды, а числа у одного и того же не меняются никогда. + // + // Роль обязана читаться по профилю сопротивлений и по виду атаки — это + // единственная подсказка, которую игра даёт напрямую (docs/21-damage.md §5). + CINDER, // головня: горит сама — жаром её не взять, нужен второй ствол + STINGER, // жалун: дальнее давление, вблизи дохлый + SWARM, // рой: много слабых в одном теле, берётся площадью + HUSK, // скорлупа: стена, которую вскрывает только кислота + SLEEPER, // спящий: почти слеп, но разбуженный сносит круг + WAILER, // плакальщик: поднимает павших рядом, пока сам жив + VOIDLING, // пустотник: защищён ровно везде, брешь одна — пустота + // --- разрушаемая обстановка ----------------------------------------------- // Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает. // Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило @@ -99,6 +115,18 @@ struct EnemyDef float grabTime = 0.0f; // GRAB: сколько держит float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит + // --- поднятие павших (плакальщик) ----------------------------------------- + // Не отдельный вид атаки: он никого не бьёт этим и ни в кого не целится. + // Это АУРА — пока такая тварь жива, лежащие рядом тела встают быстрее в + // reviveRate раз. Ноль у всех, кроме плакальщика, и ровно поэтому проверка + // стоит по данным, а не по имени вида. + // + // Задача игроку: зачистка перестаёт быть последовательной. Пока аура + // работает, отряд перебивает одних и тех же — цель приходится ВЫБИРАТЬ, + // а не просто стрелять в ближайшего. + float reviveRadius = 0.0f; // на каком расстоянии тянет мёртвых обратно + float reviveRate = 0.0f; // во сколько раз быстрее идёт их срок + // Профиль сопротивлений. Свойство ВИДА, а не глубины: глубже выходят другие // твари, а не те же с другими числами (docs/16-descent.md). Он же — // единственная подсказка, которую игра даёт про правильное оружие. diff --git a/src/sim/weapon_defs.h b/src/sim/weapon_defs.h index 5ab3006..d7d3346 100644 --- a/src/sim/weapon_defs.h +++ b/src/sim/weapon_defs.h @@ -44,6 +44,12 @@ enum class FirearmId : uint8_t SMG, // шквал: очередь дешёвыми патронами, ствол уводит адски MAGNUM, // один тяжёлый выстрел: медленно, больно, ошибок не прощает BOW, // тихий составной лук: редкий тяжёлый снаряд без дульной вспышки + NAILER, // гвоздомёт: дешевле некуда, бьёт часто и почти никак + HMG, // пулемёт: полтораста патронов и шесть секунд беспомощности + PLASMA, // резак: два с половиной шага дальности, зато почти чистая дуга + RAIL, // рельса: три выстрела на всю комнату, между ними целая вечность + FLAMER, // огнемёт: конус жара в упор, свои под него лезут постоянно + ACID, // распылитель: медленные кислотные капли, разъедают жирное COUNT, }; @@ -52,6 +58,10 @@ enum class BladeId : uint8_t SABER = 0, // штатный клинок: числа из tuning.h KNIFE, // нож: узкая дуга, зато почти без замаха CLEAVER, // тесак: широкий, тяжёлый, сносит с ног + STILETTO, // стилет: держит чужой клинок лучше всех, а бьёт слабее всех + MAUL, // молот: самый тяжёлый удар в игре и никакой защиты + RIPPER, // цепной: мелкие частые укусы, кислота + CRYOEDGE, // крио-клинок: сабля, отдавшая часть урона за холод COUNT, }; @@ -370,6 +380,287 @@ inline FirearmDef BowDef() return d; } +// ----------------------------------------------------------------------------- +// Вторая шестёрка стволов. Правило то же, что и у первой: НИ ОДИН не имеет права +// быть «карабином, но лучше». У каждого своя роль, и у каждой роли — своя цена, +// записанная числами, а не обещанная в комментарии. +// +// Чем платят, по каналам: +// * дальностью (`range`) — резак и огнемёт стоят там же, где клинок; +// * простоем (`reloadTime`) — пулемёт и рельса молчат секундами; +// * разбросом на ходу (`spreadMoveDeg`) — тяжёлое с рук на бегу не работает; +// * весом (`ItemDef::weight`, sim/items.h) — пулемёт съедает норму переноски +// отряда целиком, и это его главный минус, а не масса ради массы; +// * составом удара — стихия сильнее у того, у кого хуже баллистика. +// +// ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Раскрутки (ствол разгоняется и начинает бить точнее) +// и сквозного пробития (пуля идёт дальше сквозь тело) в правилах боя нет: +// у выстрела есть откат, конус и снаряд, и всё. Заводить поля, которых никто не +// читает, значило бы положить в каталог две мёртвые ветки, поэтому обе роли +// отыграны тем, что работает: у пулемёта — магазином и простоем, у рельсы — +// пустотой (VOID и означает «защита не работает») и скоростью снаряда. +// ----------------------------------------------------------------------------- + +// Гвоздомёт: строительный инструмент, которым стреляют от бедности. Самый +// дешёвый выстрел в игре — и самый бесполезный по отдельности: пять единиц +// урона броня съедает почти целиком. Берут его не за урон, а за то, что он +// перезаряжается за секунду с четвертью и не уводит ствол, то есть работает +// там, где отряд отступает и стрелять надо ВСЁ ВРЕМЯ. +inline FirearmDef NailerDef() +{ + FirearmDef d; + d.name = "nailer"; + d.range = 4.0f; // короче пистолета: гвоздь и не должен лететь + d.fireInterval = 0.12f; + d.damage = 4.5f; + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.24f; + d.bulletSpeed = 13.0f; // медленный снаряд: по бегуну мимо + d.bulletRadius = 0.035f; + d.knockback = 0.10f; // тварь даже не качнётся + + d.spreadStandDeg = 2.6f; // гвоздь летит криво сам по себе + d.spreadMoveDeg = 6.0f; + d.spreadRecoilDeg = 0.25f; + d.spreadRecoilMax = 2.0f; + d.recoilDecay = 6.0f; // увод оседает быстрее, чем копится + + d.magazine = 60; + d.reloadTime = 1.25f; // быстрее всех: обойма гвоздей ставится одним движением + + d.tracerLength = 0.45f; + d.muzzleRadiusPx = 2.5f; + d.muzzleTime = 0.04f; + d.muzzleFlamePx = 0.10f; + d.flashPower = 0.22f; + d.flashRadius = 2.0f; + d.shakePerShot = 0.12f; + d.tracerR = 198; d.tracerG = 192; d.tracerB = 176; + d.muzzleR = 210; d.muzzleG = 198; d.muzzleB = 168; + + // Ржавчина на гвозде — доля кислоты крошечная: это не стихийное оружие, а + // кинетика, которой немного повезло с материалом. + d.elem = ElementMix::Of(Element::TOX, 0.20f); + return d; +} + +// Тяжёлый пулемёт: полтораста патронов подряд — и шесть секунд, за которые +// отряд успевает потерять бойца. Второй ствол в игре, у которого простой опаснее +// самого боя (первый — рельса). +// +// Раскрутки в правилах боя нет (см. шапку блока), и подделывать её нечем: увод +// ствола умеет только РАСТИ. Поэтому цена входа отыграна тем, что есть, — +// магазином, простоем и весом 7.5 в sim/items.h, ровно равным норме переноски +// отряда: пулемётчик один занимает всю сумку, и любая находка уже замедляет. +inline FirearmDef HmgDef() +{ + FirearmDef d; + d.name = "hmg"; + d.range = 6.4f; // КОРОЧЕ карабина: длинный ствол не даёт дальности сам по себе + d.fireInterval = 0.10f; + d.damage = 9.0f; + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.34f; + d.bulletSpeed = 24.0f; + d.bulletRadius = 0.045f; + d.knockback = 0.30f; + + d.spreadStandDeg = 1.4f; + d.spreadMoveDeg = 15.0f; // на ходу это стрельба в белый свет + d.spreadRecoilDeg = 0.35f; + d.spreadRecoilMax = 5.5f; + d.recoilDecay = 2.4f; // увод оседает медленнее, чем у любого другого ствола + + d.magazine = 150; + d.reloadTime = 6.2f; // самое дорогое молчание в каталоге + + d.tracerLength = 1.45f; + d.muzzleRadiusPx = 6.0f; + d.muzzleTime = 0.06f; + d.muzzleFlamePx = 0.40f; + d.flashPower = 0.62f; + d.flashRadius = 4.4f; + d.shakePerShot = 0.30f; + d.tracerR = 255; d.tracerG = 226; d.tracerB = 176; + d.muzzleR = 255; d.muzzleG = 214; d.muzzleB = 140; + + // Обеднённый сердечник: доля радиации небольшая, но очередь кладёт её + // стаками — единственный ствол, который набивает статус САМ, без сборки. + d.elem = ElementMix::Of(Element::RAD, 0.30f); + return d; +} + +// Плазменный резак: горная техника, приспособленная под бой. Дальность 2.6 — +// меньше, чем у дробовика, то есть стрелок с резаком стоит ровно там же, где +// боец с клинком, и получает всё, что прилетает в первую линию. Взамен почти +// чистая дуга: против того, кто держит кинетику, это единственный ответ +// с ЭТОГО расстояния. +inline FirearmDef PlasmaDef() +{ + FirearmDef d; + d.name = "plasma"; + d.range = 2.6f; // короче всех в игре + d.fireInterval = 0.34f; + d.damage = 30.0f; + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.28f; + d.bulletSpeed = 26.0f; + d.bulletRadius = 0.055f; + d.knockback = 0.15f; // струя не толкает: тварь дойдёт вплотную и останется + + d.spreadStandDeg = 0.5f; // луч точный — толку от этого на 2.6 немного + d.spreadMoveDeg = 5.0f; + d.spreadRecoilDeg = 0.0f; // отдачи нет вовсе + d.spreadRecoilMax = 0.0f; + d.recoilDecay = 8.0f; + + d.magazine = 14; + d.reloadTime = 2.9f; + + d.tracerLength = 0.60f; + d.muzzleRadiusPx = 5.5f; + d.muzzleTime = 0.08f; + d.muzzleFlamePx = 0.30f; + d.flashPower = 0.90f; // светит сильнее, чем стреляет: резак виден издалека + d.flashRadius = 5.0f; + d.shakePerShot = 0.25f; + d.tracerR = 150; d.tracerG = 230; d.tracerB = 255; + d.muzzleR = 190; d.muzzleG = 240; d.muzzleB = 255; + + d.elem = ElementMix::Of(Element::ARC, 0.90f); + return d; +} + +// Рельса: три выстрела и почти пять секунд на перезарядку. Бьёт дальше всего в +// игре (11.5 против 8.6 у магнума), и это её единственное безусловное +// достоинство — всё остальное отнято. +// +// «Пробивает» здесь означает НЕ сквозное попадание (такого в правилах боя нет), +// а пустоту: VOID срезает сопротивления, то есть защита цели перестаёт работать. +// Заводить сквозной снаряд ради одного ствола значило бы менять правило 6.3 — +// пуля, летящая ДАЛЬШЕ цели, перестаёт быть проверенной на своих. +inline FirearmDef RailDef() +{ + FirearmDef d; + d.name = "rail"; + d.range = 11.5f; + d.fireInterval = 1.70f; // между выстрелами тварь проходит три шага + d.damage = 170.0f; + d.laneHalfWidth = 0.08f; + d.muzzleOffset = 0.36f; + d.bulletSpeed = 48.0f; // быстрее всех: на такой дальности иначе не попасть + d.bulletRadius = 0.045f; + d.knockback = 2.20f; + + d.spreadStandDeg = 0.20f; // со станка — точнее некуда + d.spreadMoveDeg = 14.0f; // с рук на ходу — выброшенный выстрел из трёх + d.spreadRecoilDeg = 3.00f; + d.spreadRecoilMax = 7.00f; + d.recoilDecay = 2.00f; // второй выстрел подряд уже не точный + + d.magazine = 3; + d.reloadTime = 4.60f; + + d.tracerLength = 3.20f; // след через полкомнаты + d.muzzleRadiusPx = 5.0f; + d.muzzleTime = 0.11f; + d.muzzleFlamePx = 0.20f; + d.flashPower = 0.95f; + d.flashRadius = 6.0f; + d.shakePerShot = 2.20f; // трясёт сильнее магнума + d.tracerR = 214; d.tracerG = 208; d.tracerB = 255; + d.muzzleR = 200; d.muzzleG = 196; d.muzzleB = 255; + + d.elem = ElementMix::Of(Element::VOID, 0.70f); + return d; +} + +// Огнемёт: конус в упор. Конус — это `pellets` в широком разбросе, то есть тот +// же механизм, что у картечи, и та же цена, только больше: пятнадцать градусов +// проверяются правилом 6.3 на своих КАЖДЫЙ спуск, и в свалке огнемётчик молчит +// чаще, чем стреляет. Это не недоработка, а весь смысл вещи: жар требует места. +// +// Урон за язык мизерный, ставка целиком на горение, а горение считается от +// МАКСИМАЛЬНОГО здоровья цели (docs/21-damage.md) — значит по мелочи огнемёт +// перерасход, а по громиле единственный смысл. +inline FirearmDef FlamerDef() +{ + FirearmDef d; + d.name = "flamer"; + d.range = 2.4f; + d.fireInterval = 0.16f; + d.damage = 7.0f; // на язык пламени + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.30f; + d.bulletSpeed = 9.0f; // языки ползут: по бегуну на отходе мимо + d.bulletRadius = 0.07f; + d.knockback = 0.05f; + + d.spreadStandDeg = 15.0f; // это и есть конус + d.spreadMoveDeg = 19.0f; + d.spreadRecoilDeg = 0.0f; + d.spreadRecoilMax = 0.0f; + d.recoilDecay = 8.0f; + + d.magazine = 100; + d.reloadTime = 3.6f; + d.pellets = 3; + + d.tracerLength = 0.30f; + d.muzzleRadiusPx = 8.0f; + d.muzzleTime = 0.09f; + d.muzzleFlamePx = 0.75f; // самый длинный язык в игре + d.flashPower = 0.75f; + d.flashRadius = 4.8f; + d.shakePerShot = 0.10f; + d.tracerR = 255; d.tracerG = 168; d.tracerB = 70; + d.muzzleR = 255; d.muzzleG = 150; d.muzzleB = 60; + + d.elem = ElementMix::Of(Element::INC, 0.95f); + return d; +} + +// Кислотный распылитель: две медленные капли за спуск. Урон за каплю смешной, +// зато почти всё в нём — кислота, а кислота работает по тому, по чему не +// работает ничего: по жирному и бронированному. По бегуну и по всему, что +// движется поперёк, капли просто не попадают — скорость снаряда 11. +inline FirearmDef AcidDef() +{ + FirearmDef d; + d.name = "acid"; + d.range = 5.0f; + d.fireInterval = 0.40f; + d.damage = 16.0f; // на каплю + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.28f; + d.bulletSpeed = 11.0f; + d.bulletRadius = 0.06f; + d.knockback = 0.10f; + + d.spreadStandDeg = 6.0f; + d.spreadMoveDeg = 11.0f; + d.spreadRecoilDeg = 0.30f; + d.spreadRecoilMax = 3.5f; + d.recoilDecay = 4.0f; + + d.magazine = 18; + d.reloadTime = 2.9f; + d.pellets = 2; + + d.tracerLength = 0.35f; + d.muzzleRadiusPx = 4.0f; + d.muzzleTime = 0.05f; + d.muzzleFlamePx = 0.0f; // не порох: вспышки нет + d.flashPower = 0.20f; + d.flashRadius = 2.4f; + d.shakePerShot = 0.14f; + d.tracerR = 176; d.tracerG = 226; d.tracerB = 110; + d.muzzleR = 150; d.muzzleG = 210; d.muzzleB = 96; + + d.elem = ElementMix::Of(Element::TOX, 0.85f); + return d; +} + inline FirearmDef FirearmDefOf(FirearmId id) { switch (id) @@ -379,6 +670,12 @@ inline FirearmDef FirearmDefOf(FirearmId id) case FirearmId::SMG: return SmgDef(); case FirearmId::MAGNUM: return MagnumDef(); case FirearmId::BOW: return BowDef(); + case FirearmId::NAILER: return NailerDef(); + case FirearmId::HMG: return HmgDef(); + case FirearmId::PLASMA: return PlasmaDef(); + case FirearmId::RAIL: return RailDef(); + case FirearmId::FLAMER: return FlamerDef(); + case FirearmId::ACID: return AcidDef(); default: return RifleDef(); } } @@ -392,6 +689,12 @@ inline const char* FirearmName(FirearmId id) case FirearmId::SMG: return "SMG"; case FirearmId::MAGNUM: return "MAGNUM"; case FirearmId::BOW: return "BOW"; + case FirearmId::NAILER: return "NAILER"; + case FirearmId::HMG: return "HMG"; + case FirearmId::PLASMA: return "PLASMA"; + case FirearmId::RAIL: return "RAIL"; + case FirearmId::FLAMER: return "FLAMER"; + case FirearmId::ACID: return "ACID"; default: return "RIFLE"; } } @@ -455,13 +758,139 @@ inline BladeDef CleaverDef() return d; } +// ----------------------------------------------------------------------------- +// Вторая четвёрка клинков. Ось, вдоль которой они разложены, одна и та же: +// ВЕС ПРОТИВ ПАРИРОВАНИЯ (docs/11-melee.md). Стилет 0.3 кг держит чужой удар в +// половине случаев, молот 5.5 кг не держит его никогда — и это ровно тот размен, +// из-за которого «взять потяжелее» перестаёт быть очевидным решением. +// +// Соотношение с уже имеющимися: нож 0.4 -> 0.34, сабля 1.2 -> 0.20, +// тесак 2.2 -> 0.08. Новые садятся между ними, а не поверх. +// +// Второй общий размен — УРОН ПРОТИВ СТИХИИ: у крио-клинка и цепного урон +// заметно ниже сабельного, и разницу они отдали за состав удара. Клинок с +// полным уроном И полной стихией был бы просто лучшей саблей. +// ----------------------------------------------------------------------------- + +// Стилет: фехтовальная игла с гардой. Парирование 0.52 — потолок каталога, и +// это единственное, ради чего его берут. +// +// Цена честная и вся сразу: самый слабый удар в игре (44 против 62 у ножа), +// досягаемость 0.85 — короче не бывает, значит бить приходится ВНУТРИ круга +// твари, — и отброс почти нулевой, то есть отбитая тварь остаётся стоять +// вплотную и бьёт снова. Стилетом выживают, а не убивают. +inline BladeDef StilettoDef() +{ + BladeDef d; + d.name = "stiletto"; + d.reach = 0.85f; + d.arcDeg = 42.0f; // уже ножа: в тесноте сектор почти всегда чист + d.windup = 0.06f; + d.strike = 0.07f; + d.recover = 0.11f; + d.interval = 0.28f; + d.damage = 44.0f; + d.parry = 0.52f; + d.knockback = 0.35f; + d.lungeSpeed = 4.8f; + + // Игла ищет щель в защите — доля пустоты маленькая, но именно она делает + // стилет пригодным против того, кто равномерно защищён. + d.elem = ElementMix::Of(Element::VOID, 0.30f); + return d; +} + +// Молот: 430 за удар — больше, чем у чего угодно в игре, и отброс 7.5, который +// выносит тварь из свалки целиком. +// +// Платит всем остальным. Замах 0.46 — вдвое длиннее тесачного, и на нём боец +// вкопан; восстановление 0.62 — почти две трети секунды открытым. Парирование +// 0.02 означает, что размен «удар на удар» молот проигрывает всегда, поэтому +// бить он обязан ПЕРВЫМ. И удар у него ЧИСТО КИНЕТИЧЕСКИЙ: там, где цель режет +// кинетику бронёй или сопротивлением, четыреста тридцать превращаются в сотню, +// а стихии, чтобы обойти это, у молота нет. +inline BladeDef MaulDef() +{ + BladeDef d; + d.name = "maul"; + d.reach = 1.55f; + d.arcDeg = 105.0f; + d.windup = 0.46f; + d.strike = 0.20f; + d.recover = 0.62f; + d.interval = 1.45f; + d.damage = 430.0f; + d.parry = 0.02f; + d.knockback = 7.50f; + d.lungeSpeed = 1.90f; // рывка почти нет: с молотом не бегают + return d; +} + +// Цепной меч: зубья вместо лезвия, укус вместо удара. Бьёт чаще всех (0.24 с +// между началами замахов) и слабее почти всех — 42 за укус. +// +// Из этого следует его минус, и он не в цифрах, а в арифметике урона: сорок два +// умножаются на сопротивление ДО того, как что-нибудь наберётся, поэтому против +// защищённой цели цепь стачивается в ноль. Плюс парирование 0.10: цепь идёт +// сама по себе и подставить её под чужой клинок нельзя — она уводит удар в +// сторону вместе с рукой. +inline BladeDef RipperDef() +{ + BladeDef d; + d.name = "ripper"; + d.reach = 1.25f; + d.arcDeg = 70.0f; + d.windup = 0.09f; + d.strike = 0.10f; + d.recover = 0.15f; + d.interval = 0.24f; + d.damage = 42.0f; + d.parry = 0.10f; + d.knockback = 0.50f; + d.lungeSpeed = 3.40f; + + // Смазка на зубьях: частый удар набивает стаки кислоты быстрее всего в игре, + // и в этом весь смысл вещи — не в уроне, а в скорости накопления статуса. + d.elem = ElementMix::Of(Element::TOX, 0.70f); + return d; +} + +// Крио-клинок: та же сабля, отдавшая четверть урона за холод. 104 против 130 — +// разница и есть цена стихии, и по цели, которая холод держит, это просто +// плохая сабля, без единого преимущества. +// +// Тонкий сплав легче стали (1.1 против 1.2), поэтому парирование чуть выше +// сабельного — правило «легче значит лучше держит» одно на весь каталог. +inline BladeDef CryoedgeDef() +{ + BladeDef d; + d.name = "cryoedge"; + d.reach = 1.45f; + d.arcDeg = 92.0f; + d.windup = 0.19f; + d.strike = 0.13f; + d.recover = 0.26f; + d.interval = 0.66f; // медленнее сабли: сплав тяжело разгонять + d.damage = 104.0f; + d.parry = 0.24f; + d.knockback = 1.80f; + d.lungeSpeed = 3.20f; + + d.elem = ElementMix::Of(Element::CRY, 0.85f); + return d; +} + inline BladeDef BladeDefOf(BladeId id) { switch (id) { - case BladeId::KNIFE: return KnifeDef(); - case BladeId::CLEAVER: return CleaverDef(); - default: return DefaultBladeDef(); + case BladeId::KNIFE: return KnifeDef(); + case BladeId::CLEAVER: return CleaverDef(); + case BladeId::STILETTO: return StilettoDef(); + case BladeId::MAUL: return MaulDef(); + case BladeId::RIPPER: return RipperDef(); + case BladeId::CRYOEDGE: return CryoedgeDef(); + default: return DefaultBladeDef(); } } @@ -469,8 +898,12 @@ inline const char* BladeName(BladeId id) { switch (id) { - case BladeId::KNIFE: return "KNIFE"; - case BladeId::CLEAVER: return "CLEAVER"; - default: return "SABER"; + case BladeId::KNIFE: return "KNIFE"; + case BladeId::CLEAVER: return "CLEAVER"; + case BladeId::STILETTO: return "STILETTO"; + case BladeId::MAUL: return "MAUL"; + case BladeId::RIPPER: return "RIPPER"; + case BladeId::CRYOEDGE: return "CRYOEDGE"; + default: return "SABER"; } } diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 9b0bfe5..5a51792 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -161,7 +161,9 @@ constexpr int WS_SLOT_X = 16, WS_SLOT_Y = 74, WS_SLOT_W = 52, WS_SLOT_H = 18; constexpr int WS_SLOT_GX = 4, WS_SLOT_GY = 3, WS_SLOT_COLS = 4, WS_SLOT_ROWS = 6; constexpr int WS_SLOT_CELLS = WS_SLOT_COLS * WS_SLOT_ROWS; -constexpr int WS_OP_X = 252, WS_OP_Y = 96, WS_OP_W = 212, WS_OP_H = 22, WS_OP_STEP = 25; +// Семь операций: строки ужаты, чтобы последняя не налезала на подсказку +// внизу экрана. Кадр 480x270, и место здесь считается по-настоящему. +constexpr int WS_OP_X = 252, WS_OP_Y = 84, WS_OP_W = 212, WS_OP_H = 20, WS_OP_STEP = 22; // Выбор чекпоинта: столбец кнопок «начать с этажа N». constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; @@ -561,6 +563,16 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) Widget& w = out.Add(UiPart::CRAFT_ITEM, o, CraftOpRect(o)); w.text = CraftOpName(CraftOp(o)); } + + // Строки аффиксов на верстаке — тоже кнопки: по ним выбирают ЦЕЛЬ + // направленной операции (sim/craft.h). + if (ui.craftIndex >= 0 && ui.craftIndex < game.profile->StashCount()) + { + const ItemInstance& bench = game.profile->stash[size_t(ui.craftIndex)]; + for (int a = 0; a < bench.Count(); ++a) + out.Add(UiPart::CRAFT_AFFIX, a, + UiRect{WS_SLOT_X, 198 + a * 9, 210, 9}); + } return; } } @@ -793,9 +805,22 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) return; case UiPart::CRAFT_SLOT: - // Выбор вещи на верстак. Ничего не тратит и ничего не ломает: - // операция — это отдельный клик. - ui.craftIndex = index; + // Клик по ячейке схрона: обычный — положить вещь на верстак, + // повторный по той же — назначить её ДОНОРОМ для отпечатка. + // Второй клик, а не второй список: экран и так плотный. + if (index == ui.craftIndex && ui.craftDonor != index) ui.craftDonor = index; + else + { + ui.craftIndex = index; + ui.craftAffix = 0; + if (ui.craftDonor == index) ui.craftDonor = -1; + } + ui.craftMessage = 0; + return; + + case UiPart::CRAFT_AFFIX: + // Выбор цели направленной операции. Ничего не тратит. + ui.craftAffix = index; ui.craftMessage = 0; return; @@ -811,9 +836,25 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) uint32_t rng = uint32_t(pr.runs * 7919 + pr.StashCount() * 104729 + int(pr.bankedXp) + ui.craftIndex * 31 + index * 17); + // Донор обязан быть ДРУГОЙ вещью: переплавить предмет в самого + // себя нельзя, и молча брать «первый попавшийся» тоже. + ItemInstance* donor = nullptr; + if (CraftNeedsDonor(CraftOp(index)) && ui.craftDonor >= 0 && + ui.craftDonor < pr.StashCount() && ui.craftDonor != ui.craftIndex) + donor = &pr.stash[size_t(ui.craftDonor)]; + ItemInstance& it = pr.stash[size_t(ui.craftIndex)]; - const CraftResult r = - ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng); + const CraftResult r = ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng, + ui.craftAffix, donor); + + // Сгоревший донор убирается из схрона: пустая ячейка посреди + // списка читалась бы как ошибка, а не как цена операции. + if (r == CraftResult::OK && donor && donor->Empty()) + { + pr.StashRemoveAt(ui.craftDonor); + if (ui.craftIndex > ui.craftDonor) --ui.craftIndex; + ui.craftDonor = -1; + } // Рассыпавшаяся вещь убирается из схрона: пустая ячейка посреди // списка выглядела бы как ошибка, а не как последствие риска. @@ -1442,7 +1483,7 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const TextCentered(l, line, 40, 8, COL_FAINT); } - Text(l, "STASH", WS_SLOT_X, 60, 8, COL_FAINT); + Text(l, "STASH (CLICK TWICE = DONOR)", WS_SLOT_X, 60, 8, COL_FAINT); Text(l, "BENCH", WS_OP_X, 60, 8, COL_FAINT); // --- ячейки схрона --- @@ -1451,6 +1492,10 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const const Widget& c = w.item[i]; if (c.part != UiPart::CRAFT_SLOT) continue; DrawCell(l, *this, c, c.index == craftIndex); + // Донор помечается кровью: он сгорит, и увидеть это надо ДО + // нажатия, а не после. + if (c.index == craftDonor) + Box(l, UiRect{c.rect.x + c.rect.w - 5, c.rect.y + 2, 3, 3}, COL_BLOOD); } // --- что лежит на верстаке --- @@ -1475,7 +1520,15 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const { char af[64]; AffixLabel(bench->affix[a], af, int(sizeof(af))); - Text(l, af, WS_SLOT_X, 200 + a * 9, 8, COL_FAINT); + + // Выбранная строка подсвечена: направленные операции + // работают именно по ней, и промах тут стоит материалов. + const bool picked = (a == craftAffix); + const bool hot = (hoverPart == UiPart::CRAFT_AFFIX && hoverIndex == a); + if (picked) Box(l, UiRect{WS_SLOT_X - 2, 198 + a * 9, 212, 9}, COL_HOVER); + Text(l, picked ? ">" : " ", WS_SLOT_X - 6, 200 + a * 9, 8, COL_PICK); + Text(l, af, WS_SLOT_X, 200 + a * 9, 8, + (picked || hot) ? COL_TEXT : COL_FAINT); } if (bench->Count() == 0) Text(l, "NO AFFIXES", WS_SLOT_X, 200, 8, COL_DEAD); @@ -1507,7 +1560,7 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const ? COL_BLOOD : (afford ? COL_TEXT : COL_FAINT); Text(l, CraftOpName(op), r.x + 6, r.y + 3, 9, nameCol); - Text(l, CraftOpEffect(op), r.x + 6, r.y + 13, 7, COL_FAINT); + Text(l, CraftOpEffect(op), r.x + 6, r.y + 12, 7, COL_FAINT); // Цена справа: только те материалы, которые нужны. char price[64] = {0}; @@ -1556,10 +1609,10 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const } std::snprintf(line, sizeof(line), "%s: %s", CraftOpName(CraftOp(craftOpDone)), what); - TextCentered(l, line, 244, 9, col); + TextCentered(l, line, 254, 8, col); } - TextCentered(l, "CLICK ITEM THEN OPERATION Q/E SCROLL ESC BACK", 258, 7, + TextCentered(l, "CLICK ITEM THEN OPERATION Q/E SCROLL ESC BACK", 264, 7, COL_FAINT); break; } diff --git a/src/ui/menu.h b/src/ui/menu.h index c8945f9..f2fc7f6 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -53,6 +53,7 @@ enum class UiPart : uint8_t SUMMARY_ITEM, // кнопка на экране итога CRAFT_ITEM, // строка операции мастерской CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем + CRAFT_AFFIX, // строка аффикса на верстаке: цель направленной операции }; // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне @@ -81,6 +82,11 @@ struct Ui // --- мастерская ---------------------------------------------------------- int craftIndex = 0; // какая вещь схрона лежит на верстаке + // Какой аффикс выбран на верстаке и какая вещь назначена донором. + // Половина операций направленные (sim/craft.h), и без выбора крафт + // возвращается к «жми кнопку и смотри, что выпало». + int craftAffix = 0; + int craftDonor = -1; // -1 — донор не выбран // Чем кончилась последняя операция. Показывается строкой под верстаком: // «не хватило материалов» и «рассыпалось» — разные события, и молча // проглотить их нельзя, особенно второе.