284 lines
12 KiB
C++
284 lines
12 KiB
C++
|
|
#include "sim/affix.h"
|
|||
|
|
|
|||
|
|
#include <cmath>
|
|||
|
|
#include <cstdio>
|
|||
|
|
|
|||
|
|
namespace
|
|||
|
|
{
|
|||
|
|
|
|||
|
|
// Свой генератор, а не <random>: броски обязаны быть одинаковыми на всех
|
|||
|
|
// платформах и сборках. `std::uniform_real_distribution` этого не обещает, и
|
|||
|
|
// «тот же сид — тот же уровень» на нём разъезжается между компиляторами.
|
|||
|
|
uint32_t NextRand(uint32_t& s)
|
|||
|
|
{
|
|||
|
|
s = s * 1664525u + 1013904223u;
|
|||
|
|
return s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
float Frand(uint32_t& s) { return float(NextRand(s) & 0xFFFFFF) / float(0x1000000); }
|
|||
|
|
|
|||
|
|
int RandRange(uint32_t& s, int lo, int hi) // [lo, hi]
|
|||
|
|
{
|
|||
|
|
if (hi <= lo) return lo;
|
|||
|
|
return lo + int(NextRand(s) % uint32_t(hi - lo + 1));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Теги, которые ложатся на оружие, броню и карман. Списком, потому что это
|
|||
|
|
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
|
|||
|
|
// таблицу, а не выводя из вида предмета.
|
|||
|
|
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
|
|||
|
|
AffixTag::AMMO, AffixTag::SPEED};
|
|||
|
|
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
|
|||
|
|
AffixTag::CARRY, AffixTag::AMMO};
|
|||
|
|
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
|
|||
|
|
AffixTag::SPEED, AffixTag::CARRY};
|
|||
|
|
|
|||
|
|
} // namespace
|
|||
|
|
|
|||
|
|
const char* AffixName(AffixTag t)
|
|||
|
|
{
|
|||
|
|
switch (t)
|
|||
|
|
{
|
|||
|
|
case AffixTag::DAMAGE: return "DAMAGE";
|
|||
|
|
case AffixTag::PEN: return "PIERCE";
|
|||
|
|
case AffixTag::CONVERT: return "CONVERT";
|
|||
|
|
case AffixTag::ARMOR: return "ARMOR";
|
|||
|
|
case AffixTag::HEALTH: return "HEALTH";
|
|||
|
|
case AffixTag::SPEED: return "SPEED";
|
|||
|
|
case AffixTag::SIGHT: return "SIGHT";
|
|||
|
|
case AffixTag::AMMO: return "ROUNDS";
|
|||
|
|
case AffixTag::CARRY: return "CARRY";
|
|||
|
|
case AffixTag::LIGHT: return "LIGHT";
|
|||
|
|
default: return "-";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void AffixLabel(const Affix& a, char* out, int cap)
|
|||
|
|
{
|
|||
|
|
if (!out || cap <= 0) return;
|
|||
|
|
out[0] = '\0';
|
|||
|
|
|
|||
|
|
switch (a.tag)
|
|||
|
|
{
|
|||
|
|
// Проценты — там, где число и есть доля. Смешивать «+14%» и «+14» в
|
|||
|
|
// одном списке нельзя: игрок читает подсказку целиком и сравнивает
|
|||
|
|
// строки между собой.
|
|||
|
|
case AffixTag::DAMAGE:
|
|||
|
|
case AffixTag::PEN:
|
|||
|
|
case AffixTag::ARMOR:
|
|||
|
|
case AffixTag::SPEED:
|
|||
|
|
case AffixTag::SIGHT:
|
|||
|
|
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
|
|||
|
|
int(a.value * 100.0f + 0.5f), int(a.tier));
|
|||
|
|
break;
|
|||
|
|
case AffixTag::CONVERT:
|
|||
|
|
std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d",
|
|||
|
|
int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)),
|
|||
|
|
int(a.tier));
|
|||
|
|
break;
|
|||
|
|
case AffixTag::CARRY:
|
|||
|
|
std::snprintf(out, size_t(cap), "CARRY +%.1f T%d", double(a.value), int(a.tier));
|
|||
|
|
break;
|
|||
|
|
default:
|
|||
|
|
std::snprintf(out, size_t(cap), "%s +%d T%d", AffixName(a.tag), int(a.value + 0.5f),
|
|||
|
|
int(a.tier));
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool AffixFitsKind(AffixTag t, ItemKind k)
|
|||
|
|
{
|
|||
|
|
auto has = [&](const AffixTag* list, int n) {
|
|||
|
|
for (int i = 0; i < n; ++i)
|
|||
|
|
if (list[i] == t) return true;
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
switch (k)
|
|||
|
|
{
|
|||
|
|
case ItemKind::FIREARM:
|
|||
|
|
case ItemKind::BLADE: return has(WEAPON_TAGS, int(sizeof(WEAPON_TAGS) / sizeof(AffixTag)));
|
|||
|
|
case ItemKind::ARMOR: return has(ARMOR_TAGS, int(sizeof(ARMOR_TAGS) / sizeof(AffixTag)));
|
|||
|
|
case ItemKind::TRINKET: return has(TRINKET_TAGS, int(sizeof(TRINKET_TAGS) / sizeof(AffixTag)));
|
|||
|
|
// Расходники не катаются. Бинт с аффиксами — это не решение, а шум:
|
|||
|
|
// его применяют автоматически и выбрасывают.
|
|||
|
|
default: return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void AffixMods::Add(const Affix& a)
|
|||
|
|
{
|
|||
|
|
switch (a.tag)
|
|||
|
|
{
|
|||
|
|
case AffixTag::DAMAGE: damageMul += a.value; break;
|
|||
|
|
case AffixTag::PEN: pen += a.value; break;
|
|||
|
|
case AffixTag::ARMOR: armor += a.value; break;
|
|||
|
|
case AffixTag::HEALTH: hp += a.value; break;
|
|||
|
|
case AffixTag::SPEED: speedMul += a.value; break;
|
|||
|
|
case AffixTag::SIGHT: sightMul += a.value; break;
|
|||
|
|
case AffixTag::AMMO: ammo += a.value; break;
|
|||
|
|
case AffixTag::CARRY: carry += a.value; break;
|
|||
|
|
case AffixTag::LIGHT: light += a.value; break;
|
|||
|
|
case AffixTag::CONVERT:
|
|||
|
|
{
|
|||
|
|
// Конверсии складываются между собой, а кинетика — это остаток.
|
|||
|
|
// Складывать смеси иначе (перемножением долей) значило бы, что два
|
|||
|
|
// аффикса по 20% дают 36%, и игрок никогда не сойдётся с подсказкой.
|
|||
|
|
const int e = int(a.param);
|
|||
|
|
if (e <= 0 || e >= ELEMENT_COUNT) break;
|
|||
|
|
convert.share[e] += a.value;
|
|||
|
|
|
|||
|
|
float sum = 0.0f;
|
|||
|
|
for (int i = 1; i < ELEMENT_COUNT; ++i) sum += convert.share[i];
|
|||
|
|
if (sum > 0.85f) // кинетика не исчезает никогда (sim/element.h)
|
|||
|
|
{
|
|||
|
|
const float k = 0.85f / sum;
|
|||
|
|
for (int i = 1; i < ELEMENT_COUNT; ++i) convert.share[i] *= k;
|
|||
|
|
sum = 0.85f;
|
|||
|
|
}
|
|||
|
|
convert.share[int(Element::KIN)] = 1.0f - sum;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
default: break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void AffixMods::Add(const ItemInstance& it)
|
|||
|
|
{
|
|||
|
|
for (int i = 0; i < it.Count(); ++i) Add(it.affix[i]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert)
|
|||
|
|
{
|
|||
|
|
// Доля, которую забрала конверсия, вычитается из КИНЕТИКИ оружия, а не из
|
|||
|
|
// его собственной стихии: дробовик с жаром, на который выкатили холод,
|
|||
|
|
// остаётся зажигательным — просто теперь ещё и холодным.
|
|||
|
|
float taken = 0.0f;
|
|||
|
|
for (int e = 1; e < ELEMENT_COUNT; ++e) taken += convert.share[e];
|
|||
|
|
if (taken <= 0.0f) return base;
|
|||
|
|
|
|||
|
|
ElementMix out = base;
|
|||
|
|
const float kin = base.share[int(Element::KIN)];
|
|||
|
|
const float use = (taken < kin) ? taken : kin; // кинетики меньше, чем забирают
|
|||
|
|
|
|||
|
|
const float k = (taken > 0.0f) ? use / taken : 0.0f;
|
|||
|
|
for (int e = 1; e < ELEMENT_COUNT; ++e) out.share[e] += convert.share[e] * k;
|
|||
|
|
out.share[int(Element::KIN)] = kin - use;
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Rarity RollRarity(int depth, uint32_t& rng)
|
|||
|
|
{
|
|||
|
|
// Шанс «не обычного» растёт с глубиной и упирается в 0.88. Потолок нужен:
|
|||
|
|
// без него на дне обычных вещей не остаётся вовсе, и редкость перестаёт
|
|||
|
|
// что-либо значить — сравнивать станет не с чем.
|
|||
|
|
//
|
|||
|
|
// Наклон 0.011 подобран замером (`--tool drops`), а не взят из документа:
|
|||
|
|
// в docs/22-loot.md §1 формула и таблица противоречили друг другу. Формула
|
|||
|
|
// (0.02 + 0.0075·d) давала на первом этаже 2.7% небанальных находок, а
|
|||
|
|
// таблица рядом — 22%. Выбрана формула, потому что первый этаж обязан быть
|
|||
|
|
// скучным по замыслу игры, а наклон поднят так, чтобы сойтись с таблицей на
|
|||
|
|
// дне: 12% обычных на сотом этаже.
|
|||
|
|
const float d = float(depth > 0 ? depth : 1);
|
|||
|
|
const float pRare = std::min(0.88f, 0.02f + 0.011f * d);
|
|||
|
|
|
|||
|
|
if (Frand(rng) >= pRare) return Rarity::COMMON;
|
|||
|
|
|
|||
|
|
// Внутри «не обычного» — своя лестница, тоже по глубине: реликты внизу
|
|||
|
|
// становятся обычным делом, наверху их нет вовсе.
|
|||
|
|
const float r = Frand(rng);
|
|||
|
|
const float pRelic = std::min(0.30f, 0.004f * d);
|
|||
|
|
const float pProto = std::min(0.45f, 0.06f + 0.006f * d);
|
|||
|
|
|
|||
|
|
if (r < pRelic) return Rarity::RELIC;
|
|||
|
|
if (r < pRelic + pProto) return Rarity::PROTOTYPE;
|
|||
|
|
return Rarity::MARKED;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int AffixCountFor(Rarity r, uint32_t& rng)
|
|||
|
|
{
|
|||
|
|
switch (r)
|
|||
|
|
{
|
|||
|
|
case Rarity::MARKED: return RandRange(rng, 1, 2);
|
|||
|
|
case Rarity::PROTOTYPE: return RandRange(rng, 3, 4);
|
|||
|
|
case Rarity::RELIC: return RandRange(rng, 5, 6);
|
|||
|
|
default: return 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ItemInstance RollItem(ItemId base, int depth, uint32_t& rng)
|
|||
|
|
{
|
|||
|
|
ItemInstance it(base);
|
|||
|
|
if (base == ItemId::NONE) return it;
|
|||
|
|
|
|||
|
|
const ItemDef def = ItemDefOf(base);
|
|||
|
|
it.rarity = AffixFitsKind(AffixTag::DAMAGE, def.kind) ||
|
|||
|
|
AffixFitsKind(AffixTag::ARMOR, def.kind) ||
|
|||
|
|
AffixFitsKind(AffixTag::SIGHT, def.kind)
|
|||
|
|
? RollRarity(depth, rng)
|
|||
|
|
: Rarity::COMMON;
|
|||
|
|
|
|||
|
|
int want = AffixCountFor(it.rarity, rng);
|
|||
|
|
if (want <= 0) return it;
|
|||
|
|
|
|||
|
|
// Уровень предмета — это глубина, на которой он найден. Отсюда и бюджет:
|
|||
|
|
// одна и та же куртка внизу держит больше аффиксов, чем наверху.
|
|||
|
|
float budget = ItemBudget(depth);
|
|||
|
|
const int tierCap = MaxAffixTier(depth);
|
|||
|
|
|
|||
|
|
// Список тегов, доступных этому виду. Порядок перемешивается броском, а не
|
|||
|
|
// берётся как есть: иначе первый тег в таблице оказывался бы на каждом
|
|||
|
|
// предмете в игре.
|
|||
|
|
AffixTag pool[int(AffixTag::COUNT)];
|
|||
|
|
int poolCount = 0;
|
|||
|
|
for (int t = 1; t < int(AffixTag::COUNT); ++t)
|
|||
|
|
if (AffixFitsKind(AffixTag(t), def.kind)) pool[poolCount++] = AffixTag(t);
|
|||
|
|
if (poolCount == 0) return it;
|
|||
|
|
|
|||
|
|
for (int i = poolCount - 1; i > 0; --i)
|
|||
|
|
{
|
|||
|
|
const int j = RandRange(rng, 0, i);
|
|||
|
|
const AffixTag tmp = pool[i];
|
|||
|
|
pool[i] = pool[j];
|
|||
|
|
pool[j] = tmp;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (want > poolCount) want = poolCount; // один тег один раз (правило 2)
|
|||
|
|
if (want > MAX_AFFIXES) want = MAX_AFFIXES;
|
|||
|
|
|
|||
|
|
for (int i = 0; i < want; ++i)
|
|||
|
|
{
|
|||
|
|
// Тир — случайный до потолка глубины, но не дороже остатка бюджета.
|
|||
|
|
// Именно так бюджет и ограничивает: не «нельзя шесть высших», а
|
|||
|
|
// «на шестой уже не хватило».
|
|||
|
|
int tier = RandRange(rng, 1, tierCap);
|
|||
|
|
while (tier > 1 && AffixCost(tier) > budget) --tier;
|
|||
|
|
if (AffixCost(tier) > budget) break;
|
|||
|
|
budget -= AffixCost(tier);
|
|||
|
|
|
|||
|
|
const AffixRange base_ = AffixBase(pool[i]);
|
|||
|
|
const float scale = std::pow(float(tier), 1.35f);
|
|||
|
|
|
|||
|
|
Affix a;
|
|||
|
|
a.tag = pool[i];
|
|||
|
|
a.tier = uint8_t(tier);
|
|||
|
|
a.value = (base_.lo + (base_.hi - base_.lo) * Frand(rng)) * scale;
|
|||
|
|
|
|||
|
|
// Стихия конверсии — любая, кроме кинетики: «перевести урон в
|
|||
|
|
// кинетику» не значит ничего.
|
|||
|
|
if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1));
|
|||
|
|
|
|||
|
|
it.affix[it.count++] = a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Аффиксов могло влезть меньше, чем обещала редкость. Понижаем её до
|
|||
|
|
// фактического содержимого: «реликт» с двумя аффиксами — это обман,
|
|||
|
|
// причём тот самый, который игрок замечает первым.
|
|||
|
|
if (it.count <= 0) it.rarity = Rarity::COMMON;
|
|||
|
|
else if (it.count <= 2) it.rarity = Rarity::MARKED;
|
|||
|
|
else if (it.count <= 4) it.rarity = Rarity::PROTOTYPE;
|
|||
|
|
else it.rarity = Rarity::RELIC;
|
|||
|
|
|
|||
|
|
return it;
|
|||
|
|
}
|