Крафт стал выбором: закалка, отпечаток, направленные операции (M33)

Прежние пять операций были СЛЕПЫМИ: перековка бросала всё заново,
прививка ставила случайный тег, отсечение снимало последний аффикс,
возвышение поднимало случайный. Игрок не принимал ни одного решения - жал
кнопку и смотрел, что выпало. Это не крафт, это второй бросок предмета за
материалы.

Теперь половина операций направленные: игрок показывает пальцем, какой
аффикс трогать, и платит за это вдвое - вот это уже решение
("перебросить всё дёшево или снять один дорого").

Два новых глагола:
  ЗАКАЛКА двигает рулон выбранного аффикса к максимуму его тира на треть
  остатка. Дёшево, понятно, повторяемо - то самое "доработать почти
  хорошую вещь". Тир не трогает: за ступень платят возвышением.

  ОТПЕЧАТОК переносит лучший аффикс с ДРУГОЙ вещи, сжигая её. Именно он
  превращает хлам из схрона в ресурс, а не в мусор, который лежит.
  Правило "один тег один раз" им не обходится, бюджет проверяется.

В мастерской: клик по вещи кладёт её на верстак, повторный клик по той же
назначает донором (помечается кровью - он сгорит, и видеть это надо ДО
нажатия). Строки аффиксов стали кнопками: по ним выбирается цель.

Проверка меряет НАПРАВЛЕННОСТЬ, а не факт срабатывания:

  направленность: отсечение по цели, возвышение по цели,
                  закалка двигает рулон, отпечаток перенёс и сжёг донора

Отсечение пробуется на СРЕДНЕМ аффиксе из трёх: операция, которая
"работает", снимая последний, прошла бы и слепую версию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 19:37:07 +03:00
parent 3ebd1f96fc
commit 92a8864726
12 changed files with 1096 additions and 62 deletions

View File

@ -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)) | | **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)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -169,24 +169,53 @@ share(KIN) = 1 Σ конверсий, Σ конверсий ≤ 0.85
### 5.2. Операции ### 5.2. Операции
| Операция | Что делает | Цена | Риск | | Операция | Что делает | Цена | Цель |
|---|---|---|---| |---|---|---|---|
| Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | — | | Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | слепо |
| Прививка | добавляет аффикс НОВОГО тега, если есть бюджет | 2 био × тир | — | | **Закалка** | двигает рулон ВЫБРАННОГО аффикса к максимуму его тира | 6 лома × тир | по выбору |
| Отсечение | убирает последний аффикс | 1 крио × тир | — | | Прививка | добавляет аффикс нового тега, если есть бюджет | 2 био × тир | слепо |
| Возвышение | +1 тир СЛУЧАЙНОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | — | | Отсечение | снимает ВЫБРАННЫЙ аффикс | 2 крио × тир | по выбору |
| **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | **12%: предмет рассыпается** | | Возвышение | +1 тир ВЫБРАННОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | по выбору |
| **Отпечаток** | переносит лучший аффикс с ДРУГОГО предмета; донор сгорает | 4 био + 4 крио × тир, 2 осколка | по выбору вещи |
| **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | 12%: предмет рассыпается |
### Чего не хватало первой версии: ВЫБОРА
Все пять прежних операций были слепыми: перековка бросала всё заново, прививка
ставила случайный тег, отсечение снимало ПОСЛЕДНИЙ аффикс, возвышение поднимало
случайный. Игрок не принимал ни одного решения — он жал кнопку и смотрел, что
выпало. Это не крафт, это второй бросок предмета за материалы.
Теперь половина операций **направленные**: игрок показывает пальцем, какой аффикс
трогать. Плата за это — цена: направленная операция вдвое дороже слепой, и вот
это уже решение — «перебросить всё дёшево или снять один дорого».
И появились два глагола, которых не было вовсе:
- **Закалка** двигает рулон к верху его тира на треть остатка. Дёшево, понятно,
повторяемо — то самое «доработать почти хорошую вещь». Тир она не трогает: за
ступень платят возвышением;
- **Отпечаток** переносит лучший аффикс с другой вещи, сжигая её. Именно он
превращает хлам из схрона в ресурс, а не в мусор, который лежит. Правило «один
тег один раз» им не обходится, и бюджет предмета проверяется как везде.
Три уточнения, каждое против конкретного обхода: Три уточнения, каждое против конкретного обхода:
- перековка меняет **только значения**. Меняй она ещё и теги — это был бы новый - перековка меняет **только значения**. Меняй она ещё и теги — это был бы новый
бросок предмета, то есть способ обойти редкость: собери шесть слотов на чём бросок предмета, то есть способ обойти редкость;
угодно и крути, пока не выпадет нужное; - прививка не ставит тег, который уже есть;
- прививка не ставит тег, который уже есть: правило «один тег один раз» - отпечаток берёт **лучший** аффикс донора, а не выбранный: игрок и так жертвует
руками не обходится; целой вещью, и просить два решения за одну цену — перебор.
- возвышается **случайный** аффикс. Выбирай игрок сам, возвышение стало бы
«+1 тир к урону» за фиксированную цену, и все предметы в игре сошлись бы Проверяется в `--descend` направленностью, а не фактом срабатывания:
к одному.
```
направленность: отсечение по цели, возвышение по цели, закалка двигает рулон,
отпечаток перенёс и сжёг донора
```
Отсечение проверяется на СРЕДНЕМ аффиксе из трёх: операция, которая «работает»,
снимая последний, прошла бы и слепую версию.
Залом — единственная операция с риском, и он настоящий: предмет исчезает, а Залом — единственная операция с риском, и он настоящий: предмет исчезает, а
плата снимается всё равно. Замерено на тысяче попыток: **рассыпалось 11.6% при плата снимается всё равно. Замерено на тысяче попыток: **рассыпалось 11.6% при

BIN
docs/ui-workshop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

View File

@ -1770,6 +1770,67 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
++bad; ++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. Залом на тысяче попыток: доля потерь, плата за неудачу и размен. // 5. Залом на тысяче попыток: доля потерь, плата за неудачу и размен.
int destroyed = 0; int destroyed = 0;
int paidTimes = 0; int paidTimes = 0;

View File

@ -90,11 +90,26 @@ CraftCost CostOf(CraftOp op, const ItemInstance& it)
case CraftOp::REFORGE: case CraftOp::REFORGE:
c.amount[int(MaterialKind::SCRAP)] = 4 * t; c.amount[int(MaterialKind::SCRAP)] = 4 * t;
break; 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: case CraftOp::GRAFT:
c.amount[int(MaterialKind::BIO)] = 2 * t; c.amount[int(MaterialKind::BIO)] = 2 * t;
break; break;
case CraftOp::EXCISE: case CraftOp::EXCISE:
c.amount[int(MaterialKind::CRYO)] = 1 * t; // Направленная операция дороже слепой ВДВОЕ: выбор — это то, за
// что здесь платят.
c.amount[int(MaterialKind::CRYO)] = 2 * t;
break; break;
case CraftOp::EXALT: case CraftOp::EXALT:
c.amount[int(MaterialKind::BIO)] = 3 * t; c.amount[int(MaterialKind::BIO)] = 3 * t;
@ -108,10 +123,16 @@ CraftCost CostOf(CraftOp op, const ItemInstance& it)
return c; 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 (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); const CraftCost cost = CostOf(op, it);
if (!Enough(materials, cost)) return CraftResult::NO_MATERIALS; if (!Enough(materials, cost)) return CraftResult::NO_MATERIALS;
@ -176,10 +197,55 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth,
break; 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: case CraftOp::EXCISE:
{ {
if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; 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; break;
} }
@ -187,25 +253,19 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth,
{ {
if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE;
// Возвышается СЛУЧАЙНЫЙ аффикс, и это не лень: выбирай игрок сам, // Возвышается ВЫБРАННЫЙ аффикс. Раньше он выбирался случайно — из
// возвышение стало бы просто «+1 тир к урону» за фиксированную // опасения, что «+1 тир к урону за фиксированную цену» сведёт все
// цену, и все предметы в игре сошлись бы к одному. // предметы к одному. Опасение снято ценой: возвышение стоит и ядер
int candidates[MAX_AFFIXES]; // обоих видов, и осколка, и дорожает с тиром предмета. Слепое же
int n = 0; // возвышение просто не давало игроку ни одного решения.
for (int i = 0; i < it.Count(); ++i) Affix& a = it.affix[target];
{ if (a.tier >= tierCap) return CraftResult::NOT_APPLICABLE;
if (it.affix[i].tier >= tierCap) continue; if (AffixCost(a.tier + 1) - AffixCost(a.tier) > budget)
if (AffixCost(it.affix[i].tier + 1) - AffixCost(it.affix[i].tier) > budget) continue; return CraftResult::NOT_APPLICABLE;
candidates[n++] = i; const float beforeScale = std::pow(float(a.tier), 1.35f);
}
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);
++a.tier; ++a.tier;
const float after = std::pow(float(a.tier), 1.35f); const float afterScale = std::pow(float(a.tier), 1.35f);
a.value *= after / before; // рулон сохраняет своё место в диапазоне a.value *= afterScale / beforeScale; // рулон сохраняет место в диапазоне
break; break;
} }

View File

@ -117,24 +117,56 @@ MaterialDrop PropDrop(int depth);
// не за их количество. Тир берётся максимальный на предмете — по нему и видно, // не за их количество. Тир берётся максимальный на предмете — по нему и видно,
// с какой глубины вещь. // с какой глубины вещь.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// ЧЕГО НЕ ХВАТАЛО ПЕРВОЙ ВЕРСИИ: ВЫБОРА.
//
// Все пять операций были слепыми: перековка бросала всё заново, прививка ставила
// СЛУЧАЙНЫЙ тег, отсечение снимало ПОСЛЕДНИЙ аффикс, возвышение поднимало
// СЛУЧАЙНЫЙ. Игрок не принимал ни одного решения — он жал кнопку и смотрел, что
// выпало. Это не крафт, это второй бросок предмета за материалы.
//
// Теперь у половины операций есть ЦЕЛЬ: игрок показывает пальцем, какой аффикс
// трогать. Плата за это — цена: направленная операция дороже слепой, и вот
// ЭТО уже решение («перебросить всё дёшево или снять один дорого»).
//
// И появились два глагола, которых не было вовсе:
// ЗАКАЛКА — двигает РУЛОН к максимуму своего тира, не трогая сам тир. Дёшево,
// понятно, повторяемо: то самое «доработать почти хорошую вещь».
// ОТПЕЧАТОК — переносит аффикс с ДРУГОГО предмета, сжигая его. Именно он
// превращает хлам из схрона в ресурс, а не в мусор, который лежит.
// -----------------------------------------------------------------------------
enum class CraftOp : uint8_t enum class CraftOp : uint8_t
{ {
REFORGE = 0, // перебросить ВСЕ значения, теги и тиры на месте REFORGE = 0, // перебросить ВСЕ значения; слепо и дёшево
TEMPER, // подтянуть рулон ВЫБРАННОГО аффикса к максимуму его тира
GRAFT, // добавить аффикс, если позволяет бюджет GRAFT, // добавить аффикс, если позволяет бюджет
EXCISE, // убрать выбранный аффикс EXCISE, // снять ВЫБРАННЫЙ аффикс
EXALT, // +1 тир случайному аффиксу EXALT, // +1 тир ВЫБРАННОМУ аффиксу
IMPRINT, // перенести аффикс с другого предмета; донор сгорает
FRACTURE, // +2 тира всем, но на один аффикс меньше; 12% — предмет рассыпается FRACTURE, // +2 тира всем, но на один аффикс меньше; 12% — предмет рассыпается
COUNT, 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) inline const char* CraftOpName(CraftOp op)
{ {
switch (op) switch (op)
{ {
case CraftOp::REFORGE: return "REFORGE"; case CraftOp::REFORGE: return "REFORGE";
case CraftOp::TEMPER: return "TEMPER";
case CraftOp::GRAFT: return "GRAFT"; case CraftOp::GRAFT: return "GRAFT";
case CraftOp::EXCISE: return "EXCISE"; case CraftOp::EXCISE: return "EXCISE";
case CraftOp::EXALT: return "EXALT"; case CraftOp::EXALT: return "EXALT";
case CraftOp::IMPRINT: return "IMPRINT";
default: return "FRACTURE"; default: return "FRACTURE";
} }
} }
@ -143,10 +175,12 @@ inline const char* CraftOpEffect(CraftOp op)
{ {
switch (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::GRAFT: return "ADD ONE AFFIX IF BUDGET ALLOWS";
case CraftOp::EXCISE: return "REMOVE THE LAST AFFIX"; case CraftOp::EXCISE: return "REMOVE THE PICKED AFFIX";
case CraftOp::EXALT: return "RAISE ONE AFFIX BY A TIER"; 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"; default: return "+2 TIERS, ONE SLOT LESS, 12% TO DESTROY";
} }
} }
@ -177,7 +211,11 @@ enum class CraftResult : uint8_t
// Применить операцию. Материалы списываются ТОЛЬКО при OK и DESTROYED: за // Применить операцию. Материалы списываются ТОЛЬКО при OK и DESTROYED: за
// неудавшийся залом платят, за невозможную операцию — нет. // неудавшийся залом платят, за невозможную операцию — нет.
// //
// depth — глубина, по которой считается потолок тира при перековке и прививке. // depth — глубина, по которой считается потолок тира. Это рекорд игрока:
// Это рекорд игрока: мастерская на базе, и доступное в ней — то, куда он уже // мастерская на базе, и доступное в ней — то, куда он уже доходил.
// доходил. //
CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng); // target — какой аффикс трогать у операций с целью (CraftNeedsTarget).
// donor — предмет, который сгорит; нужен отпечатку. Обнуляется при успехе, и
// это не побочный эффект, а суть: отпечаток ПЕРЕПЛАВЛЯЕТ вещь.
CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng,
int target = 0, ItemInstance* donor = nullptr);

View File

@ -39,19 +39,43 @@ enum class ItemId : uint8_t
{ {
NONE = 0, NONE = 0,
// Порядок значений тут ЧИСЛОВОГО смысла не имеет: профиль и каталог хранят
// предметы токенами (`IdOfItem` / `ItemIdOfId`, sim/spawn_catalog.cpp), а не
// номерами. Поэтому вставлять новое посреди перечисления безопасно — старые
// сохранения читаются как читались.
// --- стволы --- // --- стволы ---
RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW, RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW,
NAILER, // гвоздомёт: дешёвый и частый, урона почти нет
HMG, // пулемёт: магазин на полтораста и вес, равный норме переноски
PLASMA, // резак: стреляет с той дистанции, где обычно рубят
RAIL, // рельса: дальше всех, три выстрела, пять секунд молчания
FLAMER, // огнемёт: конус жара, свои под него лезут постоянно
ACID, // распылитель: медленные кислотные капли
// --- клинки --- // --- клинки ---
SABER, KNIFE, CLEAVER, SABER, KNIFE, CLEAVER,
STILETTO, // стилет: лучшее парирование в игре, худший урон
MAUL, // молот: сильнее всех бьёт, слабее всех защищается
RIPPER, // цепной: частые слабые укусы с кислотой
CRYOEDGE, // крио-клинок: сабля, обменявшая урон на холод
// --- броня --- // --- броня ---
JACKET, // куртка: почти ничего, зато не мешает JACKET, // куртка: почти ничего, зато не мешает
VEST, // жилет: ровный размен брони на скорость VEST, // жилет: ровный размен брони на скорость
PLATE, // плита: держит удар, но боец становится обозом PLATE, // плита: держит удар, но боец становится обозом
SHROUD, // саван: брони нет, зато тише и быстрее SHROUD, // саван: брони нет, зато тише и быстрее
RECON, // разведрама: обзор вместо брони
HELM, // глухой шлем: броня вместо обзора
CARAPACE, // панцирь: запас здоровья вместо веса
WEAVE, // плетёнка: лёгкая броня ценой зрения
// --- карман --- // --- карман ---
LANTERN, // фонарь: видно дальше, и тебя тоже LANTERN, // фонарь: видно дальше, и тебя тоже
CHARM, // оберег: запас здоровья CHARM, // оберег: запас здоровья
SPURS, // шпоры: скорость SPURS, // шпоры: скорость
THERMAL, // тепловизор: видит и в темноте, но тяжелее и медленнее фонаря
PAULDRON, // наплечник: единственная броня, которая лезет в карман
CLAMP, // зажим: много здоровья за ход
ADRENAL, // впрыск: скорость за туннельное зрение
HARNESS, // разгрузка: понемногу всего и заметный вес
// --- расходники --- // --- расходники ---
BANDAGE, BANDAGE,
STIMPACK, STIMPACK,
@ -295,6 +319,40 @@ inline ItemDef ItemDefOf(ItemId id)
d.weight = 1.4f; d.weight = 1.4f;
d.r = 154; d.g = 190; d.b = 164; break; 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: case ItemId::SABER:
d.name = "SABER"; d.kind = ItemKind::BLADE; d.blade = BladeId::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.weight = 2.2f;
d.r = 196; d.g = 174; d.b = 160; break; 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: case ItemId::JACKET:
d.name = "JACKET"; d.kind = ItemKind::ARMOR; d.name = "JACKET"; d.kind = ItemKind::ARMOR;

View File

@ -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); d.resist = Resists(0.30f, -0.60f, 0.10f, 0.0f, 0.0f, 0.0f, 0.60f);
break; break;
// ---------------------------------------------------------------------
// НАСЕЛЕНИЕ ГЛУБИНЫ (акты IIVI).
//
// Шесть видов выше держат первые семь этажей и кончаются там же. Дальше
// спуск на сотню этажей упирался в одно и то же: те же твари, только их
// больше. Множителя 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 против 813 у всех остальных: он почти слеп,
// и мимо него МОЖНО пройти. Единственная тварь в игре, с которой боя
// может не быть вовсе, — и ровно поэтому она бьёт так, что бой лучше
// не начинать: круг радиусом три юнита и шестьдесят два урона снимают
// половину отряда, если тот шёл кучей.
//
// Задача игроку — маршрут, а не стрельба. Замах длинный (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), ящику нечем // UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем
@ -333,6 +539,29 @@ void UpdateTargets(std::vector<Target>& targets, float dt)
const float damp = std::exp(-g_tune.targetKnockDamp * dt); const float damp = std::exp(-g_tune.targetKnockDamp * dt);
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * 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) for (Target& t : targets)
{ {
t.hurtTimer = std::max(0.0f, t.hurtTimer - dt); t.hurtTimer = std::max(0.0f, t.hurtTimer - dt);
@ -356,7 +585,20 @@ void UpdateTargets(std::vector<Target>& targets, float dt)
// отрицательный срок, и без этой проверки он воскресал бы в тот же кадр. // отрицательный срок, и без этой проверки он воскресал бы в тот же кадр.
if (t.IsProp()) continue; 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) if (t.respawnTimer <= 0.0f)
{ {
const EnemyDef def = EnemyDefOf(t.kind); const EnemyDef def = EnemyDefOf(t.kind);

View File

@ -35,6 +35,22 @@ enum class EnemyKind : uint8_t
BRUTE, // громила: медленный, толстый, сносит одним ударом BRUTE, // громила: медленный, толстый, сносит одним ударом
LURKER, // хват: подбирается и держит бойца на месте LURKER, // хват: подбирается и держит бойца на месте
// --- население глубины (акты IIVI, docs/20-gdd.md §3) ---------------------
// Каждая ниже поставлена ради СВОЕЙ задачи игроку, а не ради строки в
// бестиарии. «Тот же бредущий, но толще» — это не новая тварь, а множитель
// от глубины, которого в этой игре нет (docs/16-descent.md): вниз выходят
// ДРУГИЕ виды, а числа у одного и того же не меняются никогда.
//
// Роль обязана читаться по профилю сопротивлений и по виду атаки — это
// единственная подсказка, которую игра даёт напрямую (docs/21-damage.md §5).
CINDER, // головня: горит сама — жаром её не взять, нужен второй ствол
STINGER, // жалун: дальнее давление, вблизи дохлый
SWARM, // рой: много слабых в одном теле, берётся площадью
HUSK, // скорлупа: стена, которую вскрывает только кислота
SLEEPER, // спящий: почти слеп, но разбуженный сносит круг
WAILER, // плакальщик: поднимает павших рядом, пока сам жив
VOIDLING, // пустотник: защищён ровно везде, брешь одна — пустота
// --- разрушаемая обстановка ----------------------------------------------- // --- разрушаемая обстановка -----------------------------------------------
// Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает. // Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает.
// Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило // Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило
@ -99,6 +115,18 @@ struct EnemyDef
float grabTime = 0.0f; // GRAB: сколько держит float grabTime = 0.0f; // GRAB: сколько держит
float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит
// --- поднятие павших (плакальщик) -----------------------------------------
// Не отдельный вид атаки: он никого не бьёт этим и ни в кого не целится.
// Это АУРА — пока такая тварь жива, лежащие рядом тела встают быстрее в
// reviveRate раз. Ноль у всех, кроме плакальщика, и ровно поэтому проверка
// стоит по данным, а не по имени вида.
//
// Задача игроку: зачистка перестаёт быть последовательной. Пока аура
// работает, отряд перебивает одних и тех же — цель приходится ВЫБИРАТЬ,
// а не просто стрелять в ближайшего.
float reviveRadius = 0.0f; // на каком расстоянии тянет мёртвых обратно
float reviveRate = 0.0f; // во сколько раз быстрее идёт их срок
// Профиль сопротивлений. Свойство ВИДА, а не глубины: глубже выходят другие // Профиль сопротивлений. Свойство ВИДА, а не глубины: глубже выходят другие
// твари, а не те же с другими числами (docs/16-descent.md). Он же — // твари, а не те же с другими числами (docs/16-descent.md). Он же —
// единственная подсказка, которую игра даёт про правильное оружие. // единственная подсказка, которую игра даёт про правильное оружие.

View File

@ -44,6 +44,12 @@ enum class FirearmId : uint8_t
SMG, // шквал: очередь дешёвыми патронами, ствол уводит адски SMG, // шквал: очередь дешёвыми патронами, ствол уводит адски
MAGNUM, // один тяжёлый выстрел: медленно, больно, ошибок не прощает MAGNUM, // один тяжёлый выстрел: медленно, больно, ошибок не прощает
BOW, // тихий составной лук: редкий тяжёлый снаряд без дульной вспышки BOW, // тихий составной лук: редкий тяжёлый снаряд без дульной вспышки
NAILER, // гвоздомёт: дешевле некуда, бьёт часто и почти никак
HMG, // пулемёт: полтораста патронов и шесть секунд беспомощности
PLASMA, // резак: два с половиной шага дальности, зато почти чистая дуга
RAIL, // рельса: три выстрела на всю комнату, между ними целая вечность
FLAMER, // огнемёт: конус жара в упор, свои под него лезут постоянно
ACID, // распылитель: медленные кислотные капли, разъедают жирное
COUNT, COUNT,
}; };
@ -52,6 +58,10 @@ enum class BladeId : uint8_t
SABER = 0, // штатный клинок: числа из tuning.h SABER = 0, // штатный клинок: числа из tuning.h
KNIFE, // нож: узкая дуга, зато почти без замаха KNIFE, // нож: узкая дуга, зато почти без замаха
CLEAVER, // тесак: широкий, тяжёлый, сносит с ног CLEAVER, // тесак: широкий, тяжёлый, сносит с ног
STILETTO, // стилет: держит чужой клинок лучше всех, а бьёт слабее всех
MAUL, // молот: самый тяжёлый удар в игре и никакой защиты
RIPPER, // цепной: мелкие частые укусы, кислота
CRYOEDGE, // крио-клинок: сабля, отдавшая часть урона за холод
COUNT, COUNT,
}; };
@ -370,6 +380,287 @@ inline FirearmDef BowDef()
return d; 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) inline FirearmDef FirearmDefOf(FirearmId id)
{ {
switch (id) switch (id)
@ -379,6 +670,12 @@ inline FirearmDef FirearmDefOf(FirearmId id)
case FirearmId::SMG: return SmgDef(); case FirearmId::SMG: return SmgDef();
case FirearmId::MAGNUM: return MagnumDef(); case FirearmId::MAGNUM: return MagnumDef();
case FirearmId::BOW: return BowDef(); 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(); default: return RifleDef();
} }
} }
@ -392,6 +689,12 @@ inline const char* FirearmName(FirearmId id)
case FirearmId::SMG: return "SMG"; case FirearmId::SMG: return "SMG";
case FirearmId::MAGNUM: return "MAGNUM"; case FirearmId::MAGNUM: return "MAGNUM";
case FirearmId::BOW: return "BOW"; 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"; default: return "RIFLE";
} }
} }
@ -455,12 +758,138 @@ inline BladeDef CleaverDef()
return d; 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) inline BladeDef BladeDefOf(BladeId id)
{ {
switch (id) switch (id)
{ {
case BladeId::KNIFE: return KnifeDef(); case BladeId::KNIFE: return KnifeDef();
case BladeId::CLEAVER: return CleaverDef(); 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(); default: return DefaultBladeDef();
} }
} }
@ -471,6 +900,10 @@ inline const char* BladeName(BladeId id)
{ {
case BladeId::KNIFE: return "KNIFE"; case BladeId::KNIFE: return "KNIFE";
case BladeId::CLEAVER: return "CLEAVER"; 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"; default: return "SABER";
} }
} }

View File

@ -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_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_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». // Выбор чекпоинта: столбец кнопок «начать с этажа N».
constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; 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)); Widget& w = out.Add(UiPart::CRAFT_ITEM, o, CraftOpRect(o));
w.text = CraftOpName(CraftOp(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; return;
} }
} }
@ -793,9 +805,22 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index)
return; return;
case UiPart::CRAFT_SLOT: case UiPart::CRAFT_SLOT:
// Выбор вещи на верстак. Ничего не тратит и ничего не ломает: // Клик по ячейке схрона: обычный — положить вещь на верстак,
// операция — это отдельный клик. // повторный по той же — назначить её ДОНОРОМ для отпечатка.
// Второй клик, а не второй список: экран и так плотный.
if (index == ui.craftIndex && ui.craftDonor != index) ui.craftDonor = index;
else
{
ui.craftIndex = index; 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; ui.craftMessage = 0;
return; 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 + uint32_t rng = uint32_t(pr.runs * 7919 + pr.StashCount() * 104729 +
int(pr.bankedXp) + ui.craftIndex * 31 + index * 17); 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)]; ItemInstance& it = pr.stash[size_t(ui.craftIndex)];
const CraftResult r = const CraftResult r = ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng,
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); 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); 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]; const Widget& c = w.item[i];
if (c.part != UiPart::CRAFT_SLOT) continue; if (c.part != UiPart::CRAFT_SLOT) continue;
DrawCell(l, *this, c, c.index == craftIndex); 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]; char af[64];
AffixLabel(bench->affix[a], af, int(sizeof(af))); 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) if (bench->Count() == 0)
Text(l, "NO AFFIXES", WS_SLOT_X, 200, 8, COL_DEAD); 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 ? COL_BLOOD
: (afford ? COL_TEXT : COL_FAINT); : (afford ? COL_TEXT : COL_FAINT);
Text(l, CraftOpName(op), r.x + 6, r.y + 3, 9, nameCol); 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}; 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", std::snprintf(line, sizeof(line), "%s: %s",
CraftOpName(CraftOp(craftOpDone)), what); 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); COL_FAINT);
break; break;
} }

View File

@ -53,6 +53,7 @@ enum class UiPart : uint8_t
SUMMARY_ITEM, // кнопка на экране итога SUMMARY_ITEM, // кнопка на экране итога
CRAFT_ITEM, // строка операции мастерской CRAFT_ITEM, // строка операции мастерской
CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем
CRAFT_AFFIX, // строка аффикса на верстаке: цель направленной операции
}; };
// Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне
@ -81,6 +82,11 @@ struct Ui
// --- мастерская ---------------------------------------------------------- // --- мастерская ----------------------------------------------------------
int craftIndex = 0; // какая вещь схрона лежит на верстаке int craftIndex = 0; // какая вещь схрона лежит на верстаке
// Какой аффикс выбран на верстаке и какая вещь назначена донором.
// Половина операций направленные (sim/craft.h), и без выбора крафт
// возвращается к «жми кнопку и смотри, что выпало».
int craftAffix = 0;
int craftDonor = -1; // -1 — донор не выбран
// Чем кончилась последняя операция. Показывается строкой под верстаком: // Чем кончилась последняя операция. Показывается строкой под верстаком:
// «не хватило материалов» и «рассыпалось» — разные события, и молча // «не хватило материалов» и «рассыпалось» — разные события, и молча
// проглотить их нельзя, особенно второе. // проглотить их нельзя, особенно второе.