squad-proto/src/sim/items.h

429 lines
24 KiB
C
Raw Normal View History

Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
#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, // пачка патронов: уходит в подсумок, когда стрелять нечем
Свет как расходник: заряд тратится за путь, а не за время (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-16 02:12:05 +07:00
BATTERY, // батарея: уходит в фонарь, когда тот сел
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
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; // расходник: сколько патронов добавляет в запас
Свет как расходник: заряд тратится за путь, а не за время (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-16 02:12:05 +07:00
float charge = 0.0f; // расходник: сколько секунд света возвращает
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Сколько предмет весит. Единица условная, но соотношения — нет: плита
// тяжелее всего, что можно взять в руки, а карманное почти ничего не весит.
//
// Вес — это то, чем «унести всё» перестало быть бесплатным. До него решение
// на выходе сводилось к счёту свободных ячеек, а шесть бинтов и шесть плит
// стоили одинаково (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);
}
Свет как расходник: заряд тратится за путь, а не за время (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-16 02:12:05 +07:00
// -----------------------------------------------------------------------------
// Свет. ВТОРОЙ ресурс вылазки после патронов.
//
// ГЛАВНОЕ РЕШЕНИЕ: заряд тратится за ПРОЙДЕННОЕ РАССТОЯНИЕ, а не за время.
//
// Расход по секундам был бы таймером — тем самым, которого в этой игре нет по
// прямому требованию к жанру (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);
}
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
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;
Свет как расходник: заряд тратится за путь, а не за время (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-16 02:12:05 +07:00
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;
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
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; }
Свет как расходник: заряд тратится за путь, а не за время (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-16 02:12:05 +07:00
// 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;
}
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи
// (SyncLoadout): инвентарь про отряд не знает.
//
// Надетое сюда НЕ входит, и это не поблажка. За броню уже заплачено
// множителем скорости (плита — 0.82), и считать её вес вторично значило бы
// наказывать за один выбор дважды. Зато плита, НАЙДЕННАЯ и лежащая в
// сумке, весит полной мерой: тащить её наверх — это и есть выбор.
float Load() const { return ItemDefOf(weapon).weight; }
};