squad-proto/src/sim/items.h
z.kirill 0fb6e31c2b Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.

Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».

Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.

Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.

  свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
        глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
        обзор 1.00 -> 0.35 в темноте, батарея встала сама

Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.

--accept PASS без изменений: в песочнице света бесконечно, как и патронов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:12:05 +03:00

429 lines
24 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
// -----------------------------------------------------------------------------
// Предметы и снаряжение: ЧТО боец несёт на себе.
//
// Разделение то же, что у оружия (sim/weapon_defs.h):
// * ItemDef — неизменяемое описание предмета, одна запись каталога;
// * Inventory — компонент бойца: что в руках, что надето, что в сумке.
//
// Снаряжение НЕ трогает правила боя. Оно даёт бойцу четыре числа —
// броня, скорость, запас здоровья, дальность обзора, — и всё. Никакой предмет
// не может ослабить правило «не бить своих»: у него просто нет такой ручки.
// Это не самоограничение, а следствие устройства: FireWeapons и UpdateMelee
// про Inventory не знают вообще, они читают уже готовые статы агента.
//
// Оружие живёт двумя сущностями: ItemId в инвентаре — это ЧТО взято в руки,
// а сущность-оружие ECS хранит рабочие статы. SyncLoadout переносит одно
// в другое каждый шаг, поэтому «поменять ствол» — это одна запись ItemId.
// -----------------------------------------------------------------------------
#include <cstdint>
#include "sim/weapon_defs.h"
// Сколько патронов в найденной пачке. Не «магазин»: магазины у стволов разные,
// а пачка — это просто запас, из которого магазин потом набивается.
constexpr int AMMO_BOX_ROUNDS = 40;
enum class ItemKind : uint8_t
{
NONE = 0,
FIREARM, // в руки, класс RANGED
BLADE, // в руки, класс MELEE
ARMOR, // на корпус
TRINKET, // в карман: свет, чутьё, живучесть
CONSUMABLE, // расходник
};
enum class ItemId : uint8_t
{
NONE = 0,
// --- стволы ---
RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW,
// --- клинки ---
SABER, KNIFE, CLEAVER,
// --- броня ---
JACKET, // куртка: почти ничего, зато не мешает
VEST, // жилет: ровный размен брони на скорость
PLATE, // плита: держит удар, но боец становится обозом
SHROUD, // саван: брони нет, зато тише и быстрее
// --- карман ---
LANTERN, // фонарь: видно дальше, и тебя тоже
CHARM, // оберег: запас здоровья
SPURS, // шпоры: скорость
// --- расходники ---
BANDAGE,
STIMPACK,
AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем
BATTERY, // батарея: уходит в фонарь, когда тот сел
COUNT,
};
struct ItemDef
{
const char* name = "-";
ItemKind kind = ItemKind::NONE;
// Что взять в руки, если kind — оружие.
FirearmId gun = FirearmId::RIFLE;
BladeId blade = BladeId::SABER;
// Модификаторы бойца. Складываются по всем надетым предметам.
float armor = 0.0f; // доля поглощённого урона, суммарно жёстко режется
float speedMul = 1.0f; // множитель скорости хода
float hpBonus = 0.0f;
float sightMul = 1.0f; // множитель радиуса обзора (render-only)
float heal = 0.0f; // расходник: сколько здоровья возвращает
int rounds = 0; // расходник: сколько патронов добавляет в запас
float charge = 0.0f; // расходник: сколько секунд света возвращает
// Сколько предмет весит. Единица условная, но соотношения — нет: плита
// тяжелее всего, что можно взять в руки, а карманное почти ничего не весит.
//
// Вес — это то, чем «унести всё» перестало быть бесплатным. До него решение
// на выходе сводилось к счёту свободных ячеек, а шесть бинтов и шесть плит
// стоили одинаково (docs/17-extraction.md).
//
// По умолчанию НОЛЬ, а не единица: ItemId::NONE — это пустая ячейка и
// «брони нет», и весить пустота не должна. С единицей боец без снаряжения
// таскал бы три килограмма ничего.
float weight = 0.0f;
// Цвет иконки в инвентаре. Нейтральные байты RGB — в sim/ типов рендера нет.
uint8_t r = 200, g = 200, b = 200;
};
// Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет.
constexpr float ARMOR_CAP = 0.72f;
// -----------------------------------------------------------------------------
// Переноска. Порогов ДВА, и это главное решение всей вехи.
//
// Один жёсткий предел означал бы «сумка кончилась» — то же самое, что счёт
// ячеек, только считать труднее. Настоящий выбор возникает там, где лишнее
// взять МОЖНО, но за это платят: до нормы отряд идёт как шёл, дальше — тем
// медленнее, чем больше нагрёб. А наверх идти через весь этаж, который уже
// растревожен.
//
// Верхний предел всё-таки есть: без него отряд уносил бы этаж целиком со
// скоростью ноль, и решение снова исчезло бы — только с другой стороны.
//
// Числа выставлены по каталогу лута, а не на глаз. Средняя находка первых
// тиров весит около 1.1, поздних — около 1.7, значит полная сумка из шести
// вещей тянет на 710, плюс ствол в руках.
//
// Отсюда пороги. Норма 7.5 — это ствол и три-четыре находки: отряд, который
// взял нужное и ушёл, идёт как шёл. Полная сумка (6.610 плюс ствол) неизбежно
// оказывается за нормой — так и задумано: «унести всё» обязано стоить скорости.
// Предел 14 оставляет место найденной плите (6), но с ней в сумке больше почти
// ничего не влезет — и это ровно тот выбор, ради которого вес заводился.
//
// Норму 6 пришлось поднять: прогон показал, что «карабин, сабля, фонарь и
// жилет» весят 6.9, то есть под неё не проходил даже обычный набор. Число
// противоречило собственному замыслу, и заметила это проверка, а не глаз.
//
// Сверяться с прогоном тут ОБЯЗАТЕЛЬНО, но не по столбцу «груз»: `--descend`
// кладёт в сумки бинты, чтобы проверить перенос между этажами, и по ним вес
// выглядит втрое меньше настоящего. Правило проверяется отдельно — там же,
// в прогоне, куском на выдуманном инвентаре.
// -----------------------------------------------------------------------------
constexpr float CARRY_LIMIT = 7.5f; // до этого веса скорость не страдает
constexpr float CARRY_MAX = 14.0f; // выше не поднимет: руки заняты
constexpr float CARRY_SLOW = 0.55f; // во сколько раз медленнее на пределе
// Множитель скорости от нагрузки. Между нормой и пределом падает линейно:
// нелинейная кривая тут была бы честнее физически и хуже играбельно — игрок
// должен чувствовать «каждая лишняя вещь стоит столько-то».
// bonus — прибавка к норме от улучшений профиля (sim/profile.h). Предел
// двигается вместе с нормой: улучшение обязано увеличивать И то, сколько можно
// нести свободно, И то, сколько вообще можно поднять, — иначе прокачка
// переноски упиралась бы в невидимую стену.
inline float CarryMul(float load, float bonus = 0.0f)
{
const float limit = CARRY_LIMIT + bonus;
const float top = CARRY_MAX + bonus;
if (load <= limit) return 1.0f;
if (load >= top) return CARRY_SLOW;
return 1.0f + (CARRY_SLOW - 1.0f) * (load - limit) / (top - limit);
}
// -----------------------------------------------------------------------------
// Свет. ВТОРОЙ ресурс вылазки после патронов.
//
// ГЛАВНОЕ РЕШЕНИЕ: заряд тратится за ПРОЙДЕННОЕ РАССТОЯНИЕ, а не за время.
//
// Расход по секундам был бы таймером — тем самым, которого в этой игре нет по
// прямому требованию к жанру (docs/17-extraction.md): давит исчерпание, а не
// часы. Фонарь, садящийся сам по себе, торопил бы игрока ровно как таймер,
// только без циферблата: думать стало бы дорого, а думать здесь и есть игра.
//
// За расстояние — совсем другое решение и ровно то, которое нужно: стоять и
// разглядывать этаж бесплатно, а вот ОБОЙТИ его весь ради лута стоит света.
// Тот же выбор, что и вес, только с другой стороны: «унести всё» стоит
// скорости, «обшарить всё» стоит темноты. Прямой путь к спуску дёшев, полная
// зачистка — дорога.
//
// Сел фонарь — обзор падает до DARK_SIGHT_MUL, а твари начинают замечать отряд
// дальше. Отряд не слепнет насовсем (иначе игра просто кончалась бы) — он
// меняется с тварями местами: теперь первым видит не он.
//
// Заряд считается ПО ОТРЯДУ, а не по фонарю в кармане каждого: отряд ходит
// одной кучей, и пять личных фонарей были бы пятью копиями одного числа.
// -----------------------------------------------------------------------------
// Единица заряда — тайл пути на первом этаже. Число выставлено по РАЗМЕРУ
// ЭТАЖА, а не на глаз: путь до спуска на первых этажах — шесть-восемь десятков
// тайлов, к восьмому — полторы сотни. Полного заряда должно хватать на этаж с
// осмотром и НЕ хватать на несколько этажей подряд, иначе свет ничего не
// связывает. Проверяется в `--descend`: там он сравнивается с длиной пути до
// спуска на текущей глубине.
constexpr float LIGHT_FULL = 420.0f;
constexpr float DARK_SIGHT_MUL = 0.35f; // во что превращается обзор без света
constexpr float DARK_AGGRO_MUL = 1.35f; // насколько дальше твари замечают в темноте
// Сколько заряда съедает один тайл пути. Чем глубже, тем дороже шаг: на сотом
// этаже полного заряда хватает вдвое меньше, чем на первом, — а сами этажи там
// больше. Расход растёт с глубиной по той же причине, что и всё остальное:
// иначе к лейтгейму свет перестал бы быть ресурсом.
inline float LightDrain(int depth)
{
return 1.0f + 0.012f * float(depth > 1 ? depth : 1);
}
inline ItemDef ItemDefOf(ItemId id)
{
ItemDef d;
switch (id)
{
// --- стволы ---
case ItemId::RIFLE:
d.name = "CARBINE"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::RIFLE;
d.weight = 2.2f;
d.r = 208; d.g = 196; d.b = 150; break;
case ItemId::PISTOL:
d.name = "PISTOL"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::PISTOL;
d.weight = 0.7f;
d.r = 190; d.g = 180; d.b = 168; break;
case ItemId::SHOTGUN:
d.name = "SHOTGUN"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SHOTGUN;
d.weight = 2.8f;
d.r = 214; d.g = 158; d.b = 104; break;
case ItemId::SMG:
d.name = "SMG"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SMG;
d.weight = 1.6f;
d.r = 176; d.g = 200; d.b = 220; break;
case ItemId::MAGNUM:
d.name = "MAGNUM"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::MAGNUM;
d.weight = 1.5f;
d.r = 226; d.g = 210; d.b = 176; break;
case ItemId::BOW:
d.name = "BOW"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::BOW;
d.weight = 1.4f;
d.r = 154; d.g = 190; d.b = 164; break;
// --- клинки ---
case ItemId::SABER:
d.name = "SABER"; d.kind = ItemKind::BLADE; d.blade = BladeId::SABER;
d.weight = 1.2f;
d.r = 200; d.g = 212; d.b = 228; break;
case ItemId::KNIFE:
d.name = "KNIFE"; d.kind = ItemKind::BLADE; d.blade = BladeId::KNIFE;
d.speedMul = 1.06f;
d.weight = 0.4f;
d.r = 214; d.g = 222; d.b = 232; break;
case ItemId::CLEAVER:
d.name = "CLEAVER"; d.kind = ItemKind::BLADE; d.blade = BladeId::CLEAVER;
d.speedMul = 0.94f;
d.weight = 2.2f;
d.r = 196; d.g = 174; d.b = 160; break;
// --- броня ---
case ItemId::JACKET:
d.name = "JACKET"; d.kind = ItemKind::ARMOR;
d.armor = 0.10f; d.speedMul = 1.00f;
d.weight = 1.2f;
d.r = 122; d.g = 112; d.b = 96; break;
case ItemId::VEST:
d.name = "VEST"; d.kind = ItemKind::ARMOR;
d.armor = 0.28f; d.speedMul = 0.95f; d.hpBonus = 10.0f;
d.weight = 3.0f;
d.r = 108; d.g = 118; d.b = 108; break;
case ItemId::PLATE:
d.name = "PLATE"; d.kind = ItemKind::ARMOR;
d.armor = 0.48f; d.speedMul = 0.82f; d.hpBonus = 25.0f;
d.weight = 6.0f;
d.r = 140; d.g = 146; d.b = 156; break;
case ItemId::SHROUD:
d.name = "SHROUD"; d.kind = ItemKind::ARMOR;
d.armor = 0.04f; d.speedMul = 1.12f; d.sightMul = 0.92f;
d.weight = 0.8f;
d.r = 88; d.g = 84; d.b = 100; break;
// --- карман ---
case ItemId::LANTERN:
d.name = "LANTERN"; d.kind = ItemKind::TRINKET;
d.sightMul = 1.35f;
d.weight = 0.5f;
d.r = 240; d.g = 206; d.b = 130; break;
case ItemId::CHARM:
d.name = "CHARM"; d.kind = ItemKind::TRINKET;
d.hpBonus = 30.0f;
d.weight = 0.3f;
d.r = 196; d.g = 130; d.b = 190; break;
case ItemId::SPURS:
d.name = "SPURS"; d.kind = ItemKind::TRINKET;
d.speedMul = 1.14f;
d.weight = 0.3f;
d.r = 198; d.g = 184; d.b = 120; break;
// --- расходники ---
case ItemId::BANDAGE:
d.name = "BANDAGE"; d.kind = ItemKind::CONSUMABLE; d.heal = 25.0f;
d.weight = 0.2f;
d.r = 216; d.g = 208; d.b = 196; break;
case ItemId::STIMPACK:
d.name = "STIM"; d.kind = ItemKind::CONSUMABLE; d.heal = 60.0f;
d.weight = 0.25f;
d.r = 130; d.g = 220; d.b = 170; break;
case ItemId::AMMO:
d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS;
d.weight = 1.0f;
d.r = 200; d.g = 176; d.b = 96; break;
case ItemId::BATTERY:
d.name = "BATTERY"; d.kind = ItemKind::CONSUMABLE; d.charge = LIGHT_FULL;
d.weight = 0.3f;
d.r = 150; d.g = 210; d.b = 200; break;
default: break;
}
return d;
}
inline const char* ItemName(ItemId id) { return ItemDefOf(id).name; }
inline bool IsWeaponItem(ItemId id)
{
const ItemKind k = ItemDefOf(id).kind;
return k == ItemKind::FIREARM || k == ItemKind::BLADE;
}
// --- инвентарь ----------------------------------------------------------------
// -----------------------------------------------------------------------------
// Сумка — ОДНА НА ОТРЯД, а не шесть ячеек на каждого.
//
// Раньше каждый боец таскал своё, и это порождало ровно одну проблему, которую
// нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась
// только ему и лежала мёртвым грузом до конца забега. Перекладывать между
// бойцами игра не умела, и научить её значило бы завести целый обмен.
//
// Отряд ходит одной кучей и дерётся как одно целое — пусть и несёт как одно
// целое. Личным остаётся НАДЕТОЕ: руки, броня, карман. Их не разделить, они и
// есть «кто этот боец».
// -----------------------------------------------------------------------------
constexpr int SQUAD_BAG_SLOTS = 16;
struct SquadBag
{
ItemId slots[SQUAD_BAG_SLOTS] = {};
float Load() const
{
float w = 0.0f;
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) w += ItemDefOf(slots[i]).weight;
return w;
}
int Count() const
{
int n = 0;
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] != ItemId::NONE) ++n;
return n;
}
int FreeSlot() const
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] == ItemId::NONE) return i;
return -1;
}
int Find(ItemKind kind) const
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (slots[i] != ItemId::NONE && ItemDefOf(slots[i]).kind == kind) return i;
return -1;
}
// Влезет ли ещё одна вещь. Ячейки — это «сколько разных вещей», вес —
// «сколько они тянут» на КАЖДОГО, и одно другое не заменяет.
//
// carriers — сколько живых бойцов делят ношу. Меньше живых — тяжелее всем,
// и это не побочный эффект: потерять бойца значит взвалить его долю на
// остальных, и обратно наверх они пойдут медленнее.
bool CanTake(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f) const
{
if (FreeSlot() < 0) return false;
const int n = (carriers > 0) ? carriers : 1;
return weaponLoad + (Load() + ItemDefOf(id).weight) / float(n) <= CARRY_MAX + bonus;
}
bool Take(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f)
{
if (!CanTake(id, carriers, weaponLoad, bonus)) return false;
const int slot = FreeSlot();
if (slot < 0) return false;
slots[slot] = id;
return true;
}
void Clear()
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemId::NONE;
}
};
// Компонент ECS на сущности бойца: только НАДЕТОЕ.
struct Inventory
{
ItemId weapon = ItemId::RIFLE; // в руках
ItemId armor = ItemId::NONE;
ItemId trinket = ItemId::NONE;
// Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак
// весит, нож — нет.
float Armor() const
{
const float a = ItemDefOf(armor).armor + ItemDefOf(trinket).armor;
return (a < ARMOR_CAP) ? a : ARMOR_CAP;
}
float SpeedMul() const
{
return ItemDefOf(armor).speedMul * ItemDefOf(trinket).speedMul *
ItemDefOf(weapon).speedMul;
}
float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; }
// lanternLit = у отряда ещё есть заряд. Погасший фонарь не даёт НИЧЕГО:
// без этого он продолжал бы светить на треть от своих 35% и «сел» значило
// бы «стал слабее», а не «кончился».
float SightMul(bool lanternLit = true) const
{
const float tr = (!lanternLit && trinket == ItemId::LANTERN)
? 1.0f
: ItemDefOf(trinket).sightMul;
return ItemDefOf(armor).sightMul * tr;
}
// Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи
// (SyncLoadout): инвентарь про отряд не знает.
//
// Надетое сюда НЕ входит, и это не поблажка. За броню уже заплачено
// множителем скорости (плита — 0.82), и считать её вес вторично значило бы
// наказывать за один выбор дважды. Зато плита, НАЙДЕННАЯ и лежащая в
// сумке, весит полной мерой: тащить её наверх — это и есть выбор.
float Load() const { return ItemDefOf(weapon).weight; }
};