Compare commits
2 Commits
3ebd1f96fc
...
f5240111d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5240111d5 | ||
|
|
92a8864726 |
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -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% при
|
||||
|
||||
BIN
docs/ui-workshop.png
Normal file
BIN
docs/ui-workshop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
@ -30,6 +30,17 @@ entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,1
|
||||
entity lurker class=monster depth=5 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
|
||||
entity brute class=monster depth=7 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE
|
||||
|
||||
# Акты II-VI. До этого каталог кончался громилой на седьмом этаже, и глубже
|
||||
# бюджет опасности выбирался теми же пятью видами — то есть спуск переставал
|
||||
# показывать новое ровно там, где обязан был начать.
|
||||
entity cinder class=monster depth=9 danger=2.8 radius=0.32 color=178,92,40 sprite=shambler_walk name=CINDER
|
||||
entity stinger class=monster depth=21 danger=2.2 radius=0.26 color=104,120,132 sprite=spitter_walk name=STINGER
|
||||
entity swarm class=monster depth=36 danger=1.6 radius=0.30 color=138,132,56 sprite=rusher_walk name=SWARM
|
||||
entity husk class=monster depth=36 danger=7.0 radius=0.44 color=128,122,104 sprite=brute_walk name=HUSK
|
||||
entity sleeper class=monster depth=44 danger=6.0 radius=0.50 color=96,88,74 sprite=brute_walk name=SLEEPER
|
||||
entity wailer class=monster depth=53 danger=8.0 radius=0.33 color=158,150,176 sprite=spitter_walk name=WAILER
|
||||
entity voidling class=monster depth=73 danger=11.0 radius=0.32 color=52,48,86 sprite=lurker_walk name=VOIDLING
|
||||
|
||||
# Мишень приёмки: не ходит и не бьёт. Опасности не несёт, поэтому и бюджета
|
||||
# не тратит — ею ставят неподвижную цель, когда нужна именно цель.
|
||||
entity dummy class=monster depth=1 danger=0.0 radius=0.34 color=120,44,44 sprite=- name=TARGET
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -41,8 +41,13 @@
|
||||
namespace
|
||||
{
|
||||
|
||||
const EnemyKind KINDS[] = {EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SPITTER,
|
||||
EnemyKind::LURKER, EnemyKind::BRUTE};
|
||||
// ВЕСЬ бестиарий, а не пятеро. Список был захардкожен пятью видами и не знал
|
||||
// даже личинки: инструменты показывали половину игры и молчали про остальное.
|
||||
const EnemyKind KINDS[] = {EnemyKind::LARVA, EnemyKind::SHAMBLER, EnemyKind::RUSHER,
|
||||
EnemyKind::SPITTER, EnemyKind::LURKER, EnemyKind::BRUTE,
|
||||
EnemyKind::CINDER, EnemyKind::STINGER, EnemyKind::SWARM,
|
||||
EnemyKind::HUSK, EnemyKind::SLEEPER, EnemyKind::WAILER,
|
||||
EnemyKind::VOIDLING};
|
||||
constexpr int KIND_COUNT = int(sizeof(KINDS) / sizeof(KINDS[0]));
|
||||
|
||||
const FirearmId GUNS[] = {FirearmId::RIFLE, FirearmId::PISTOL, FirearmId::SHOTGUN,
|
||||
@ -171,10 +176,16 @@ int CmdTtk()
|
||||
worst = std::max(worst, mul);
|
||||
}
|
||||
|
||||
// Личинка из коридора исключена НАМЕРЕННО. Она населяет первые этажи и
|
||||
// умирает от чего угодно — в этом её работа: показать бой до того, как
|
||||
// игрок узнает про стихии. Требовать от неё разброса значило бы
|
||||
// требовать знания стихий на первом этаже.
|
||||
const bool tutorial = (k == EnemyKind::LARVA);
|
||||
const float spread = worst / std::max(0.01f, best);
|
||||
const bool off = !tutorial && (spread < SPREAD_MIN || spread > SPREAD_MAX);
|
||||
std::printf(" %6.2fx%s\n", double(spread),
|
||||
(spread < SPREAD_MIN || spread > SPREAD_MAX) ? " <-- вне коридора" : "");
|
||||
if (spread < SPREAD_MIN || spread > SPREAD_MAX) ++bad;
|
||||
off ? " <-- вне коридора" : (tutorial ? " (учебная)" : ""));
|
||||
if (off) ++bad;
|
||||
}
|
||||
|
||||
std::printf("\nсекунды на убийство одним стрелком, без промахов и движения.\n"
|
||||
|
||||
@ -568,6 +568,14 @@ Sprite MakeLocker(uint32_t body)
|
||||
//
|
||||
// 14x14, по одной форме на ВИД предмета. Читать нужно вид, а не модель: имя
|
||||
// пишется рядом текстом, и дублировать его пиктограммой незачем.
|
||||
//
|
||||
// Внутри вида форма чуть гуляет по ВЕСУ, и только по нему. Причина простая:
|
||||
// стволов в каталоге стало двенадцать, и полка из двенадцати одинаковых
|
||||
// пиктограмм, отличающихся лишь оттенком, читается как одна вещь в двенадцати
|
||||
// цветах. Вес — единственное, что видно на 14 пикселях и что при этом значит в
|
||||
// игре хоть что-то: гвоздомёт короткий и тонкий, пулемёт длинный и толстый.
|
||||
// Второй характеристики сюда класть нельзя — начнётся рисование, а рисуют в
|
||||
// этом проекте не здесь (docs/12-assets.md).
|
||||
Sprite MakeIcon(ItemId id)
|
||||
{
|
||||
const ItemDef def = ItemDefOf(id);
|
||||
@ -582,44 +590,65 @@ Sprite MakeIcon(ItemId id)
|
||||
const uint32_t dark = ShadeColor(c, 0.45f);
|
||||
const uint32_t hot = ShadeColor(c, 1.35f);
|
||||
|
||||
// «Насколько тяжёлое» в долях: 0 — карманная мелочь, 1 — пулемёт и панцирь.
|
||||
const float heft = Clampf(def.weight / 7.5f, 0.0f, 1.0f);
|
||||
|
||||
switch (def.kind)
|
||||
{
|
||||
case ItemKind::FIREARM:
|
||||
for (int x = 1; x < 12; ++x) s.Set(x, 6, c);
|
||||
for (int x = 1; x < 12; ++x) s.Set(x, 7, dark);
|
||||
{
|
||||
// Длина ствола и вторая, тёмная линия под ним — у лёгкого её нет.
|
||||
const int len = 8 + int(heft * 4.0f + 0.5f);
|
||||
for (int x = 1; x < 1 + len; ++x) s.Set(x, 6, c);
|
||||
if (def.weight >= 1.5f)
|
||||
for (int x = 1; x < 1 + len; ++x) s.Set(x, 7, dark);
|
||||
for (int y = 7; y < 11; ++y) s.Set(3, y, dark);
|
||||
s.Set(11, 5, hot);
|
||||
s.Set(12, 6, hot);
|
||||
s.Set(len, 5, hot);
|
||||
s.Set(1 + len, 6, hot);
|
||||
break;
|
||||
}
|
||||
|
||||
case ItemKind::BLADE:
|
||||
for (int i = 0; i < 10; ++i) s.Set(2 + i, 11 - i, (i > 4) ? hot : c);
|
||||
{
|
||||
// Тот же диагональный клинок, только длиннее у тяжёлого.
|
||||
const int len = 7 + int(heft * 5.0f + 0.5f);
|
||||
for (int i = 0; i < len; ++i) s.Set(2 + i, 11 - i, (i > len / 2) ? hot : c);
|
||||
s.Set(3, 10, dark);
|
||||
s.Set(4, 11, dark);
|
||||
s.Set(2, 9, dark);
|
||||
break;
|
||||
}
|
||||
|
||||
case ItemKind::ARMOR:
|
||||
{
|
||||
// Плечи: чем тяжелее броня, тем шире силуэт.
|
||||
const int pad = (def.weight >= 3.0f) ? 1 : ((def.weight <= 1.5f) ? 3 : 2);
|
||||
for (int y = 2; y < 12; ++y)
|
||||
{
|
||||
const int inset = (y < 9) ? 2 : (2 + (y - 8));
|
||||
const int inset = (y < 9) ? pad : (pad + (y - 8));
|
||||
for (int x = inset; x < 14 - inset; ++x)
|
||||
s.Set(x, y, (y == 2 || x == inset || x == 13 - inset) ? dark : c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ItemKind::TRINKET:
|
||||
{
|
||||
// Карманное весит доли килограмма, поэтому шкала тут своя: делить
|
||||
// на вес пулемёта значило бы, что все обереги одного размера.
|
||||
const float r = 4.0f + Clampf(def.weight / 2.4f, 0.0f, 1.0f) * 1.5f;
|
||||
for (int y = 0; y < 14; ++y)
|
||||
for (int x = 0; x < 14; ++x)
|
||||
{
|
||||
const float dx = float(x) - 6.5f, dy = float(y) - 6.5f;
|
||||
const float d = std::sqrt(dx * dx + dy * dy);
|
||||
if (d > 5.5f) continue;
|
||||
s.Set(x, y, (d > 4.5f) ? dark : c);
|
||||
if (d > r) continue;
|
||||
s.Set(x, y, (d > r - 1.0f) ? dark : c);
|
||||
}
|
||||
s.Set(5, 4, hot);
|
||||
s.Set(4, 5, hot);
|
||||
break;
|
||||
}
|
||||
|
||||
case ItemKind::CONSUMABLE:
|
||||
for (int i = 3; i < 11; ++i)
|
||||
@ -656,15 +685,37 @@ uint32_t SlotColor(int slot)
|
||||
|
||||
BodyLook LookOf(ItemId weapon)
|
||||
{
|
||||
// Силуэтов пять на весь каталог, и больше не нужно: на 14 пикселях
|
||||
// различимы только «короткое», «длинное», «тяжёлое» и то же самое для
|
||||
// клинка. Новое оружие раскладывается по ним, а не заводит шестую форму, —
|
||||
// иначе пришлось бы рисовать, а рисуют здесь не так (docs/12-assets.md).
|
||||
//
|
||||
// Молчаливый default сюда класть НЕЛЬЗЯ: он даёт GUN_LONG, и клинок в руках
|
||||
// выглядел бы стволом. Поэтому каждый клинок перечислен явно.
|
||||
switch (weapon)
|
||||
{
|
||||
case ItemId::PISTOL: return BodyLook::GUN_SHORT;
|
||||
case ItemId::PISTOL:
|
||||
case ItemId::NAILER:
|
||||
case ItemId::PLASMA: return BodyLook::GUN_SHORT;
|
||||
|
||||
case ItemId::SHOTGUN:
|
||||
case ItemId::MAGNUM: return BodyLook::GUN_HEAVY;
|
||||
case ItemId::BOW: return BodyLook::GUN_LONG;
|
||||
case ItemId::MAGNUM:
|
||||
case ItemId::HMG:
|
||||
case ItemId::FLAMER:
|
||||
case ItemId::RAIL: return BodyLook::GUN_HEAVY;
|
||||
|
||||
case ItemId::BOW:
|
||||
case ItemId::ACID: return BodyLook::GUN_LONG;
|
||||
|
||||
case ItemId::SABER:
|
||||
case ItemId::CLEAVER: return BodyLook::BLADE_LONG;
|
||||
case ItemId::KNIFE: return BodyLook::BLADE_SHORT;
|
||||
case ItemId::CLEAVER:
|
||||
case ItemId::MAUL:
|
||||
case ItemId::RIPPER:
|
||||
case ItemId::CRYOEDGE: return BodyLook::BLADE_LONG;
|
||||
|
||||
case ItemId::KNIFE:
|
||||
case ItemId::STILETTO: return BodyLook::BLADE_SHORT;
|
||||
|
||||
default: return BodyLook::GUN_LONG;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
194
src/sim/items.h
194
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;
|
||||
@ -333,6 +415,50 @@ inline ItemDef ItemDefOf(ItemId id)
|
||||
d.weight = 0.8f;
|
||||
d.r = 88; d.g = 84; d.b = 100; break;
|
||||
|
||||
// --- броня: три размена, по одному на вещь ---------------------------
|
||||
//
|
||||
// Вес НАДЕТОЙ брони в `Inventory::Load` не входит (за неё уже заплачено
|
||||
// множителем скорости), но входит, пока она лежит в сумке. Поэтому
|
||||
// тяжёлая броня — это не «медленно», а «либо надел, либо не унёс».
|
||||
|
||||
// Разведрама: обзор в обмен на защиту. Брони меньше, чем у куртки,
|
||||
// то есть это САМАЯ слабая броня в каталоге, и берут её не за неё.
|
||||
// Минус жёсткий: в темноте (сел фонарь) обзор режется вместе со всем
|
||||
// остальным, и рама превращается в полтора килограмма ничего.
|
||||
case ItemId::RECON:
|
||||
d.name = "RECON"; d.kind = ItemKind::ARMOR;
|
||||
d.armor = 0.08f; d.speedMul = 1.06f; d.sightMul = 1.22f;
|
||||
d.weight = 1.8f;
|
||||
d.r = 130; d.g = 140; d.b = 120; break;
|
||||
|
||||
// Глухой шлем: защита в обмен на обзор. Брони больше, чем у жилета, а
|
||||
// видит боец на пятую часть хуже — и это не косметика: обзор решает,
|
||||
// кого отряд заметит первым (docs/17-extraction.md).
|
||||
case ItemId::HELM:
|
||||
d.name = "HELM"; d.kind = ItemKind::ARMOR;
|
||||
d.armor = 0.36f; d.speedMul = 0.96f; d.hpBonus = 8.0f; d.sightMul = 0.78f;
|
||||
d.weight = 3.6f;
|
||||
d.r = 150; d.g = 150; d.b = 158; break;
|
||||
|
||||
// Панцирь из чужого хитина: здоровье в обмен на вес. Брони МЕНЬШЕ, чем у
|
||||
// плиты, зато запас втрое больше — то есть он не «терпит удар», а даёт
|
||||
// пережить лишний. Тяжелее всего, что вообще есть в игре: найденный
|
||||
// панцирь в сумке — это половина нормы переноски отряда.
|
||||
case ItemId::CARAPACE:
|
||||
d.name = "CARAPACE"; d.kind = ItemKind::ARMOR;
|
||||
d.armor = 0.34f; d.speedMul = 0.94f; d.hpBonus = 60.0f;
|
||||
d.weight = 8.4f;
|
||||
d.r = 120; d.g = 104; d.b = 86; break;
|
||||
|
||||
// Плетёнка: лёгкая броня ценой зрения. Между курткой и жилетом по
|
||||
// защите, быстрее обеих — но лицо закрыто, и видит боец хуже всех,
|
||||
// кроме шлема.
|
||||
case ItemId::WEAVE:
|
||||
d.name = "WEAVE"; d.kind = ItemKind::ARMOR;
|
||||
d.armor = 0.18f; d.speedMul = 1.08f; d.sightMul = 0.88f;
|
||||
d.weight = 1.4f;
|
||||
d.r = 96; d.g = 100; d.b = 112; break;
|
||||
|
||||
// --- карман ---
|
||||
case ItemId::LANTERN:
|
||||
d.name = "LANTERN"; d.kind = ItemKind::TRINKET;
|
||||
@ -350,6 +476,74 @@ inline ItemDef ItemDefOf(ItemId id)
|
||||
d.weight = 0.3f;
|
||||
d.r = 198; d.g = 184; d.b = 120; break;
|
||||
|
||||
// --- карман: чем он может быть и чем не может ------------------------
|
||||
//
|
||||
// У предмета РОВНО ЧЕТЫРЕ канала влияния — броня, скорость, здоровье,
|
||||
// обзор (шапка файла), и это не недосмотр, а то, из-за чего снаряжение
|
||||
// не может ослабить правило «не бить своих». Всё, что кладётся в карман,
|
||||
// обязано укладываться в них.
|
||||
//
|
||||
// Отсюда — чего в кармане НЕТ и почему (тот же разбор, что у триггеров
|
||||
// в docs/21-damage.md §4):
|
||||
// * усилителя реакций — сила реакции считается от ГЛУБИНЫ, у неё
|
||||
// вообще нет ручки на предмете, и заводить её ради одной вещи
|
||||
// значило бы завести второй канал урона;
|
||||
// * стабилизатора разброса — конус живёт в `FirearmDef`, а `Inventory`
|
||||
// до оружия не дотягивается: снаряжение правил стрельбы не трогает;
|
||||
// * подсумка «+патроны» — этот канал уже ЕСТЬ, но не у предмета, а у
|
||||
// рулона: `AffixTag::AMMO` (sim/affix.h). Второй способ добавлять
|
||||
// патроны был бы вторым именем того же самого;
|
||||
// * маскировки — агро тварей читает свет отряда (`DARK_AGGRO_MUL`), а
|
||||
// не карман бойца.
|
||||
// Каждая из этих вещей, положенная сюда «как есть», лежала бы в сумке,
|
||||
// показывалась в подсказке и не делала НИЧЕГО.
|
||||
|
||||
// Тепловизор: обзора меньше, чем у фонаря, и тяжелее его вдвое. Берут
|
||||
// ради единственного свойства, которого у фонаря нет: тепло видно и БЕЗ
|
||||
// СВЕТА. Погасший фонарь не даёт ничего (`Inventory::SightMul`), а
|
||||
// тепловизор продолжает работать — пусть и по тёмным правилам.
|
||||
case ItemId::THERMAL:
|
||||
d.name = "THERMAL"; d.kind = ItemKind::TRINKET;
|
||||
d.sightMul = 1.30f; d.speedMul = 0.94f;
|
||||
d.weight = 1.0f;
|
||||
d.r = 236; d.g = 140; d.b = 90; break;
|
||||
|
||||
// Наплечник: единственная броня, которая лезет в карман. Мало, и уходит
|
||||
// в тот же потолок ARMOR_CAP — то есть на бойце в плите он не даёт
|
||||
// почти ничего, и это ровно тот выбор, ради которого он и заведён:
|
||||
// наплечник для лёгких, а не «плюс к тяжёлым».
|
||||
case ItemId::PAULDRON:
|
||||
d.name = "PAULDRON"; d.kind = ItemKind::TRINKET;
|
||||
d.armor = 0.12f; d.speedMul = 0.97f;
|
||||
d.weight = 1.1f;
|
||||
d.r = 132; d.g = 138; d.b = 146; break;
|
||||
|
||||
// Зажим: здоровья почти вдвое больше оберега, и за это боец ходит
|
||||
// медленнее всех в кармане. Живучесть, купленная скоростью, — самый
|
||||
// опасный размен в игре с экстракшеном: наверх ещё идти.
|
||||
case ItemId::CLAMP:
|
||||
d.name = "CLAMP"; d.kind = ItemKind::TRINKET;
|
||||
d.hpBonus = 55.0f; d.speedMul = 0.92f;
|
||||
d.weight = 0.6f;
|
||||
d.r = 200; d.g = 90; d.b = 90; break;
|
||||
|
||||
// Впрыск: быстрее шпор, но зрение сужается — туннельное. Отряд с
|
||||
// впрыском доезжает куда угодно первым и замечает всё последним.
|
||||
case ItemId::ADRENAL:
|
||||
d.name = "ADRENAL"; d.kind = ItemKind::TRINKET;
|
||||
d.speedMul = 1.22f; d.sightMul = 0.86f;
|
||||
d.weight = 0.4f;
|
||||
d.r = 240; d.g = 230; d.b = 120; break;
|
||||
|
||||
// Разгрузка: понемногу хода и обзора, ни в чём не лучшая. Цена —
|
||||
// вес 2.4, в разы больше любой другой карманной вещи, то есть она
|
||||
// отъедает норму переноски у ТОГО, ЧТО ОТРЯД УНЕСЁТ НАВЕРХ.
|
||||
case ItemId::HARNESS:
|
||||
d.name = "HARNESS"; d.kind = ItemKind::TRINKET;
|
||||
d.sightMul = 1.18f; d.speedMul = 1.08f;
|
||||
d.weight = 2.4f;
|
||||
d.r = 176; d.g = 150; d.b = 110; break;
|
||||
|
||||
// --- расходники ---
|
||||
case ItemId::BANDAGE:
|
||||
d.name = "BANDAGE"; d.kind = ItemKind::CONSUMABLE; d.heal = 25.0f;
|
||||
|
||||
@ -24,6 +24,13 @@ const Row ROWS[] = {
|
||||
{"spitter", EnemyKind::SPITTER},
|
||||
{"brute", EnemyKind::BRUTE},
|
||||
{"lurker", EnemyKind::LURKER},
|
||||
{"cinder", EnemyKind::CINDER},
|
||||
{"stinger", EnemyKind::STINGER},
|
||||
{"swarm", EnemyKind::SWARM},
|
||||
{"husk", EnemyKind::HUSK},
|
||||
{"sleeper", EnemyKind::SLEEPER},
|
||||
{"wailer", EnemyKind::WAILER},
|
||||
{"voidling", EnemyKind::VOIDLING},
|
||||
{"crate", EnemyKind::CRATE},
|
||||
{"barrel", EnemyKind::BARREL},
|
||||
{"locker", EnemyKind::LOCKER},
|
||||
|
||||
@ -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<Target>& 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<Target>& 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);
|
||||
|
||||
@ -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). Он же —
|
||||
// единственная подсказка, которую игра даёт про правильное оружие.
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 — донор не выбран
|
||||
// Чем кончилась последняя операция. Показывается строкой под верстаком:
|
||||
// «не хватило материалов» и «рассыпалось» — разные события, и молча
|
||||
// проглотить их нельзя, особенно второе.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user