diff --git a/CLAUDE.md b/CLAUDE.md index 5a8a18b..4f9523b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,10 @@ `squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля). - **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё, достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)). +- **Таймера в игре нет и не заводить.** Фонарь тратит заряд за ПРОЙДЕННЫЙ ПУТЬ, + а не за время: стоять и думать бесплатно, дорого — обшарить этаж целиком. + Расход со `dt` вместо пути — это таймер, и `--descend` его ловит + ([17-extraction.md](docs/17-extraction.md)). - **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей). Числа не должны спасать — иначе хоррор кончается на сороковом этаже diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index bd9b427..76d802b 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -130,7 +130,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--view ` | перебить вид отображения из манифеста проекта (отладочное) | | `--seed N` | сид сборки уровня (по умолчанию 1) | | `--depth N` | начать не с первого этажа (для проверок) | -| `--descend N` | прогон забега на N этажей без окна | +| `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль | | `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--no-profile` | не читать и не писать профиль | diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 48e8485..164057f 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -34,6 +34,8 @@ | **M22** | статусы стихий и четыре реакции (SHATTER, IGNITE, SUPERCOND, DECAY) с силой от ГЛУБИНЫ, а не от урона оружия; три ограничителя — потолок стаков, откат на цели, запрет рекурсии | `--tool reactions`: все четыре срабатывают, площадные задевают соседа вполовину, стаки гасятся, вторая пара внутри отката даёт 0.0, урон от реакции не кладёт статусов; шаг симуляции 2.9 мкс из 16666; `--accept` PASS ([21-damage.md](21-damage.md)) | +| **M23** | свет как расходник: заряд фонаря общий на отряд и тратится за ПРОЙДЕННЫЙ ПУТЬ, а не за время; погас — обзор 35% и твари замечают в 1.35 раза дальше; батарея встаёт сама, как бинт | проверка в `--descend`: стоянка стоит 0.2 из 420 (то есть таймера нет), ход 1.10 за тайл при 1.10 в каталоге, заряда на 4.0 прямых пути до спуска, обзор 1.00 → 0.35, батарея сработала; первая версия расхода была ПОСЕКУНДНОЙ — то есть таймером, которого в игре нет по требованию к жанру, и переписана на путь; измеритель тоже был неправ (делил на смещение, а не на путь) и показывал 1.55 против 1.10; `--accept` PASS без изменений — в песочнице свет бесконечен ([17-extraction.md](17-extraction.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/13-combat.md b/docs/13-combat.md index f0a7f13..7f9c121 100644 --- a/docs/13-combat.md +++ b/docs/13-combat.md @@ -182,7 +182,11 @@ | SHROUD | 4% | 112% | обзор 92% | В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%). -Расходники: BANDAGE, STIM. +Расходники: BANDAGE, STIM, AMMO, BATTERY. + +Фонарь — единственная вещь с ЗАРЯДОМ: он садится за пройденный путь, и погасший +не даёт ничего. Заряд общий на отряд, батарея встаёт сама +([17-extraction.md](17-extraction.md)). Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`): остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав @@ -217,6 +221,11 @@ HP, броню, скорость, обзор. `ENTER` меняет местам - уголок над карточкой — боец при цели; - карточка перечёркнута — боец упал. +Исключение одно и на весь HUD — строка забега сверху: этаж, опыт, добыча и +заряд фонаря. Номер этажа формой не покажешь. Погасший фонарь там показан +СЛОВОМ `DARK`, а не полосой нулевой длины: темнота меняет правила, а +«полоски не видно» читалось бы как «полоски тут и не было». + ## Смерть бойца Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по diff --git a/docs/16-descent.md b/docs/16-descent.md index a5d87ec..369a806 100644 --- a/docs/16-descent.md +++ b/docs/16-descent.md @@ -161,6 +161,11 @@ entity plate class=loot tier=3 depth=9 radius=0.30 color=140,1 запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с которой потом работает и первый этаж, и песочница приёмки. +Глубина жмёт на свет ВТОРОЙ раз, уже через ресурс: `LightDrain(глубина)` делает +каждый шаг дороже по заряду фонаря, а этажи внизу длиннее. Один множитель — +сколько видно, второй — сколько ещё будет видно +([17-extraction.md](17-extraction.md)). + ## Цифры на экране HUD прототипа был принципиально бессловесным (`render/hud.h`). Для спуска этого diff --git a/docs/17-extraction.md b/docs/17-extraction.md index 4e73092..5b8bb1d 100644 --- a/docs/17-extraction.md +++ b/docs/17-extraction.md @@ -159,6 +159,42 @@ stash saber plate vest smg bow charm lantern spurs его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы найденную пачку до конца забега. Это нашла проверка, а не глаз. +### Свет: второй ресурс, и тратится он за ПУТЬ + +Фонарь садится. Полного заряда (`LIGHT_FULL`, 420) хватает примерно на +**четыре прямых пути до спуска** — то есть на этаж с осмотром, но не на два +этажа подряд. Кончился — обзор падает до `DARK_SIGHT_MUL` (35%), а твари +начинают замечать отряд в `DARK_AGGRO_MUL` раз дальше. Отряд не слепнет: он +меняется с тварями местами, и первым теперь видит не он. + +**Главное решение — заряд тратится за пройденное расстояние, а не за время.** + +Расход по секундам был бы таймером, только без циферблата: садящийся сам собой +фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого. +Здесь думать бесплатно. Стоять и разглядывать — бесплатно. Платить приходится +за то, что **обошёл этаж целиком**, а не за то, что задержался. + +Получается тот же размен, что и с весом, только с другой стороны: + +| ресурс | что он делает дорогим | +|---|---| +| вес | унести всё | +| свет | обшарить всё | + +Прямая дорога к спуску дёшева обоими. Полная зачистка — дорога обоими сразу. + +Чем глубже, тем дороже шаг (`LightDrain`): на сотом этаже заряда хватает вдвое +меньше, чем на первом, а сами этажи там длиннее. Заряд едет вниз вместе с +отрядом и **не обновляется на новом этаже** — иначе он был бы ресурсом этажа, а +не вылазки, и никуда бы не гнал. + +Батарея (0.3 веса) — расходник по тем же правилам, что бинт и пачка: встаёт в +фонарь сама, в тот же шаг, когда свет погас. Держать её в сумке при погасшем +фонаре отряду незачем, а темнота ради темноты — не решение игрока. + +Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом. +Иначе «сел» означало бы «стал слабее», а не «кончился». + ## Во что вкладывается опыт ![экран улучшений](ui-upgrades.png) @@ -234,6 +270,9 @@ squad_proto.exe --descend 7 --no-profile ``` расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе перезарядка: запас 40 -> 16 (магазин 24) +свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге (прошли 12.7, продвинулись 1.6) + глубина 8: заряда на 383 тайлов, это 4.0 прямых пути до спуска (средний 95, здешний 155) + обзор 1.00 -> 0.35 в темноте, батарея встала сама переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1 ``` @@ -241,6 +280,19 @@ squad_proto.exe --descend 7 --no-profile «патронов» в таблице стоит на месте — и без прямой проверки бесконечный боезапас выглядел бы ровно так же. +У света первая строка проверки — **не про баланс, а про жанр**: заряд на +стоянке обязан стоять. Написать таймер случайно очень легко (одна строка со +`dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково. + +Расход сравнивается с числом из каталога, и первая версия проверки показала +1.55 за тайл при 1.10 в правилах. Расходился не свет, а измеритель: он делил +потраченное на **смещение** отряда, а отряд шёл не по прямой. Считать надо путь +пошагово — тогда сходится до сотой. + +Хватает ли заряда на этаж — считается в **этажах**, а не в тайлах, и по средней +длине этажей забега: путь до спуска скачет вдвое от сида, и порог, выставленный +по одному этажу, ловил бы удачу, а не баланс. + ``` 7 RUST 13 19 3 149 живых 5, в сумках 30 эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6 diff --git a/docs/21-damage.md b/docs/21-damage.md index 8108b9d..d08e1ec 100644 --- a/docs/21-damage.md +++ b/docs/21-damage.md @@ -295,16 +295,34 @@ DECAY 6.2 3.1 0 ## 7. Свет как ресурс -Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не -садится. Должен садиться. +Сделано в M23, но **не по формуле, которая стояла здесь раньше**. В ней расход +считался от времени: ``` -заряд(t) = заряд₀ − drain·t, drain = 1/сек -радиус = base · (0.35 + 0.65 · заряд/заряд₀) +заряд(t) = заряд₀ − drain·t, drain = 1/сек <- ТАК НЕ СДЕЛАНО ``` -Батареи — расходник (вес 0.3), заменяются автоматически при разряде, как бинты. -Кончились — отряд идёт в темноте: обзор 35%, твари замечают первыми. +Это таймер. Игра заявлена без таймера намеренно +([17-extraction.md](17-extraction.md)): давить обязано исчерпание, а не часы. +Фонарь, садящийся сам по себе, торопил бы игрока ровно как секундомер, только +без циферблата, — и думать над этажом стало бы дорого. + +Как сделано: + +``` +заряд ← заряд − путь · drain(глубина), drain = 1 + 0.012·глубина +``` + +Путь — смещение центра отряда за шаг. Стоять бесплатно, идти дорого. Полного +заряда (420) хватает примерно на четыре прямых пути до спуска, то есть на этаж +с осмотром и не больше. + +Радиус не тает плавно вместе с зарядом, как предполагала старая формула: +фонарь либо светит полностью, либо не светит вовсе (обзор 35%, твари замечают в +1.35 раза дальше). Плавное угасание — это «стало чуть хуже» пять минут подряд, +и заметить момент, когда пора наверх, по нему нельзя. Обрыв заметен сразу. + +Батареи — расходник (вес 0.3), встают в фонарь сами при разряде, как бинты. Это второй после патронов ресурс, который гонит наверх, и он же — причина, по которой ветвь Разведки в дереве узлов не «приятная», а обязательная. diff --git a/projects/descent/catalog/entities.txt b/projects/descent/catalog/entities.txt index 2dab303..c394083 100644 --- a/projects/descent/catalog/entities.txt +++ b/projects/descent/catalog/entities.txt @@ -44,6 +44,9 @@ entity locker class=prop depth=2 radius=0.36 color=78,88,96 spri # не исчезают: бинт нужен всегда, он просто перестаёт быть удачей. entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE +# Батарея — второй расходник первой необходимости после бинта: свет садится сам +# и на всех, поэтому нижним тиром и с первого этажа. Иконки пока нет. +entity battery class=loot tier=0 depth=1 radius=0.22 color=150,210,200 sprite=- name=BATTERY entity pistol class=loot tier=0 depth=1 radius=0.24 color=190,180,168 sprite=item_icon_pistol name=PISTOL entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE entity jacket class=loot tier=0 depth=1 radius=0.26 color=122,112,96 sprite=item_icon_jacket name=JACKET diff --git a/projects/hollow/catalog/entities.txt b/projects/hollow/catalog/entities.txt index 394c2b7..324b792 100644 --- a/projects/hollow/catalog/entities.txt +++ b/projects/hollow/catalog/entities.txt @@ -22,6 +22,7 @@ entity barrel class=prop depth=1 radius=0.33 color=92,84,58 spri # двенадцать. Спуск здесь не успевает одеть отряд, и это замысел. entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE +entity battery class=loot tier=0 depth=1 radius=0.22 color=150,210,200 sprite=- name=BATTERY entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE entity rifle class=loot tier=1 depth=2 radius=0.26 color=208,196,150 sprite=item_icon_rifle name=CARBINE diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index bf3e44f..4225680 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -633,9 +633,16 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t "EXIT", "SQUAD"); int bad = 0; + // Средний путь до спуска по всем этажам забега. Нужен свету: сравнивать + // заряд с длиной ОДНОГО этажа — значит мерить не баланс, а везение сида, + // а этажи одной глубины различаются вдвое. + int exitSum = 0; + int exitCount = 0; + for (int i = 0; i < floors; ++i) { const GeneratedLevel& l = game.level; + if (l.exitDistance > 0) { exitSum += l.exitDistance; ++exitCount; } if (!game.levelGenerated) { @@ -664,10 +671,17 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t ammoLeft += std::max(0, game.squad.agents[a].ammoReserve); std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f" - " (перегруз %d), патронов %d\n", + " (перегруз %d), патронов %d, света на %.0f тайлов\n", l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced, double(l.dangerSpent), int(l.loot.size()), l.exitDistance, - game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft); + game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft, + double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth)))); + + // Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд, + // обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение + // на этаж. Прогон спуска не шагает симуляцией, поэтому сам заряд тут не + // тратится: срезаем его руками на первом этаже и смотрим внизу. + if (i == 0) game.squad.lightCharge -= 60.0f; if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; } if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0) @@ -791,6 +805,168 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t } } + // ------------------------------------------------------------------------- + // Свет. Проверяется ПРАВИЛО целиком: стоять бесплатно -> идти дорого -> + // погас, и обзор упал -> батарея из сумки встала сама. + // + // Первая проверка тут главная, и она не про баланс, а про жанр: заряд, + // тающий на стоянке, — это таймер, которого в игре нет. Написать такое + // случайно очень легко (одна строка в Step с dt вместо пути), а заметить + // на глаз невозможно: цифра в HUD тает одинаково. + // + // Столбец в таблице выше для этого не годится вовсе: прогон спуска не + // шагает симуляцией, и заряд в нём не тратится совсем. + // + // Мир возвращается как был: дальше проверяется эвакуация, и она обязана + // считать сумку отряда, а не сумку измеряющего. + { + Agent& a = game.squad.agents[0]; + const SquadBag savedBag = game.squad.bag; + const float savedLight = game.squad.lightCharge; + const int exitTiles = game.level.exitDistance; + + // 0. Доехал ли заряд, срезанный на первом этаже, до низа. + if (game.squad.lightCharge >= LIGHT_FULL) + { + std::printf("\n [FAIL] заряд обновился при спуске — свет не ресурс вылазки\n"); + ++bad; + } + + // 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не + // должно стоить ничего заметного. + game.in = InputState{}; + const float standBefore = game.squad.lightCharge; + for (int k = 0; k < 120; ++k) game.Step(FIXED_DT); + const float standCost = standBefore - game.squad.lightCharge; + + // 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону + // и делим потраченное на пройденное. + // + // Путь копится ПОШАГОВО, а не берётся как расстояние между началом и + // концом. Первая версия делила на смещение и показала 1.55 за тайл + // при 1.10 в каталоге: отряд шёл не по прямой (стена, строй), и + // инструмент мерил цену продвижения, а не цену шага. Число выглядело + // как расхождение с правилом, хотя расходился только измеритель. + // Гоняем по четырём сторонам, а не в одну: отряд стоит там, где его + // высадил спуск, и в любую отдельную сторону может упереться в стену + // через полтора тайла. Тогда вся выборка — это топтание в строю. + const Vec2 from = game.squad.Centroid(); + const float walkBefore = game.squad.lightCharge; + + float path = 0.0f; + Vec2 prev = from; + const Vec2 dirs[4] = {{1.0f, 0.0f}, {0.0f, 1.0f}, {-1.0f, 0.0f}, {0.0f, -1.0f}}; + for (const Vec2& d : dirs) + { + game.in.moveScreen = d; + for (int k = 0; k < 150; ++k) + { + game.Step(FIXED_DT); + const Vec2 now = game.squad.Centroid(); + path += Distance(now, prev); + prev = now; + } + } + const float walkCost = walkBefore - game.squad.lightCharge; + const float perTile = (path > 0.5f) ? walkCost / path : 0.0f; + const float progress = Distance(game.squad.Centroid(), from); + game.in = InputState{}; + + // 3. Погас — обзор падает. Меряется на живом бойце, а не по константе: + // константу можно завести и не подключить. + game.squad.bag.Clear(); + game.squad.lightCharge = 0.0f; + game.Step(FIXED_DT); + const float sightDark = a.sightMul; + + game.squad.lightCharge = LIGHT_FULL; + game.Step(FIXED_DT); + const float sightLit = a.sightMul; + + // 4. Батарея из сумки встаёт сама, как бинт. + game.squad.lightCharge = 0.0f; + game.squad.bag.slots[0] = ItemId::BATTERY; + for (int k = 0; k < 8; ++k) game.Step(FIXED_DT); + const bool relit = game.squad.lightCharge > 0.0f; + const bool spentBattery = game.squad.bag.slots[0] == ItemId::NONE; + + // Хватает ли заряда на этаж — та самая калибровка, без которой свет + // либо украшение, либо игра в темноте с первого этажа. + // + // Считается в ЭТАЖАХ, а не в тайлах: «заряда на 383 тайла» ни о чём не + // говорит, пока рядом нет длины этажа. И берётся СРЕДНЯЯ длина по + // забегу — на одном этаже путь до спуска скачет вдвое от сида, и + // порог, выставленный по одному, ловил бы удачу, а не баланс. + const float reach = LIGHT_FULL / std::max(0.01f, LightDrain(game.depth)); + const float avgExit = (exitCount > 0) ? float(exitSum) / float(exitCount) : 0.0f; + const float floorsPerCharge = (avgExit > 1.0f) ? reach / avgExit : 0.0f; + + std::printf("\nсвет: стоянка %.1f, ход %.2f за тайл при %.2f в каталоге (прошли %.1f," + " продвинулись %.1f)\n" + " глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска" + " (средний %.0f, здешний %d)\n" + " обзор %.2f -> %.2f в темноте, батарея %s\n", + double(standCost), double(perTile), double(LightDrain(game.depth)), + double(path), double(progress), + game.depth, double(reach), double(floorsPerCharge), double(avgExit), + exitTiles, + double(sightLit), double(sightDark), + relit ? (spentBattery ? "встала сама" : "встала, но осталась в сумке") + : "НЕ встала"); + + // Расход обязан совпасть с числом из каталога. Разойдись он — значит, + // свет тратит что-то ещё, кроме пути, и таймер вернулся боком. + const float expected = LightDrain(game.depth); + if (perTile > 0.0f && std::fabs(perTile - expected) > expected * 0.05f) + { + std::printf(" [FAIL] расход %.2f за тайл против %.2f в каталоге — свет тратит" + " не только путь\n", double(perTile), double(expected)); + ++bad; + } + if (standCost > 1.0f) + { + std::printf(" [FAIL] заряд тает на стоянке (%.1f за две секунды) — это таймер," + " а таймера в игре нет\n", double(standCost)); + ++bad; + } + if (perTile <= 0.0f) + { + std::printf(" [FAIL] ход не тратит света — фонарь декоративен\n"); + ++bad; + } + // Нижняя граница — по ЗДЕШНЕМУ этажу: на нём отряд стоит сейчас, и не + // дойти до спуска в свету он не должен ни на одном сиде. + if (reach < float(exitTiles)) + { + std::printf(" [FAIL] заряда (%.0f тайлов) не хватает даже на дорогу до спуска" + " (%d) — этаж непроходим в свету\n", double(reach), exitTiles); + ++bad; + } + // Верхняя — по среднему. Полная зачистка этажа — это два-три прямых + // пути, поэтому шесть прямых путей на заряд это уже «полтора этажа с + // осмотром», и дальше свет перестаёт быть решением. + if (floorsPerCharge > 6.0f) + { + std::printf(" [FAIL] заряда хватает на %.1f пути до спуска — свет ничего" + " не связывает\n", double(floorsPerCharge)); + ++bad; + } + if (sightDark >= sightLit * 0.6f) + { + std::printf(" [FAIL] темнота не режет обзор: %.2f против %.2f\n", + double(sightDark), double(sightLit)); + ++bad; + } + if (!relit || !spentBattery) + { + std::printf(" [FAIL] батарея не сработала сама — свет чинить нечем\n"); + ++bad; + } + + game.squad.bag = savedBag; + game.squad.lightCharge = savedLight; + } + // ------------------------------------------------------------------------- // Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз» // выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами, @@ -963,14 +1139,15 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t const int floorsDone = game.floorsCleared + 1; const float xpEarned = game.xp; + // ------------------------------------------------------------------------- // Опыт тратится. Проверяется цепочка целиком: купили — подействовало на + // отряд — пережило запись профиля. Число в профиле, которое ни на что не + // влияет, выглядит точно так же, как работающее улучшение. // // Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает // глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в // одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра // сломана, а потому что её сломал измеряющий. - // отряд — пережило запись профиля. Число в профиле, которое ни на что не - // влияет, выглядит точно так же, как работающее улучшение. // ------------------------------------------------------------------------- { Profile& pr = *game.profile; diff --git a/src/game.cpp b/src/game.cpp index 8bda22a..afd2055 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -323,6 +323,12 @@ void Game::BuildFloor(const SquadCarry* carry) // правила стрельбы, а не то, на сколько минут хватило патронов. for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1; + + // Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает + // вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь + // смысл — на четвёртом этаже светить уже почти нечем. + // В песочнице приёмки света бесконечно (-1), как и патронов. + squad.lightCharge = combatMode ? LIGHT_FULL : -1.0f; } lanes.Clear(); @@ -417,6 +423,8 @@ SolverContext Game::MakeContext() const // Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон, // который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно: // боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой. +// Батарея ставится ровно в тот шаг, когда фонарь сел: держать её в сумке при +// погасшем свете отряду незачем, а темнота ради темноты — не решение игрока. // ----------------------------------------------------------------------------- void Game::UseConsumables(float dt) { @@ -441,6 +449,11 @@ void Game::UseConsumables(float dt) const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac; const bool safe = nearest > g_tune.healSafeDist; + // Свет — ресурс ОТРЯДА, поэтому условие пересчитывается на каждом бойце: + // поставил батарею первый — второй уже видит заряженный фонарь и свою + // не тратит. + const bool dark = squad.LightOut(); + // Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто // держит его прямо сейчас. Боец с клинком в руках и запасным карабином // в сумке иначе таскал бы найденную пачку до конца забега мёртвым @@ -458,10 +471,12 @@ void Game::UseConsumables(float dt) const bool healsNow = (d.heal > 0.0f) && hurt && safe; const bool loadsNow = (d.rounds > 0) && dry; - if (!healsNow && !loadsNow) continue; + const bool lightsNow = (d.charge > 0.0f) && dark; + if (!healsNow && !loadsNow && !lightsNow) continue; if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal); if (loadsNow) a.ammoReserve += d.rounds; + if (lightsNow) squad.lightCharge = d.charge; squad.bag.slots[s] = ItemId::NONE; events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{}); @@ -483,10 +498,12 @@ void Game::Step(float dt) // его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл // сюда, и это не побочный эффект, а то, ради чего сумка общая. const float bagShare = squad.bag.Load() / float(squad.Carriers()); + SquadBuffs buffs; buffs.sight = squad.sightBonus; buffs.hp = squad.hpBonus; buffs.carry = squad.carryBonus; + buffs.lightOut = squad.LightOut(); for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs); for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt); @@ -500,7 +517,8 @@ void Game::Step(float dt) UpdateTargets(targets, dt); // Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует // на то, что видит сейчас, а не на прошлый кадр. - UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events); + UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events, + buffs.lightOut ? DARK_AGGRO_MUL : 1.0f); // --- 1. якорь и состояние отряда ----------------------------------------- // Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде. @@ -508,8 +526,23 @@ void Game::Step(float dt) squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt); // --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) ----- + const Vec2 lightFrom = squad.Centroid(); squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt); + // --- 2.5 свет ------------------------------------------------------------- + // Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и меряется путь по центру отряда, а не + // по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в + // стену, и светил бы фонарь на месте. + // + // Топтание в строю сюда попадает тоже, но оно на порядок меньше похода + // через этаж и на решение не влияет. Считать «только осмысленное движение» + // значило бы завести второе понятие пути ради полутора процентов. + if (squad.lightCharge > 0.0f) + { + const float walked = Distance(squad.Centroid(), lightFrom); + squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth)); + } + // --- 3. ручное прицеливание (6.8) ---------------------------------------- UpdateAim(dt); diff --git a/src/render/hud.cpp b/src/render/hud.cpp index 91ce060..54ba4e8 100644 --- a/src/render/hud.cpp +++ b/src/render/hud.cpp @@ -129,6 +129,25 @@ void DrawRunBar(Framebuffer& fb, const Game& game) std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried); MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW); + // Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как + // патроны в карточке бойца. Разница одна: кончившийся заряд показывается + // СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и + // то, кто кого замечает первым, — а «полоски не видно» читалось бы как + // «полоски тут и не было». + if (game.squad.lightCharge >= 0.0f) + { + const int ly = 5 + (MICRO_H + 3) * 2; + if (game.squad.LightOut()) + MicroTextShadow(fb, 5, ly, "DARK", RGBA(225, 95, 70), COL_SHADOW); + else + { + const float k = Clampf(game.squad.lightCharge / LIGHT_FULL, 0.0f, 1.0f); + fb.FillRect(5, ly + 1, 46, 3, RGBA(44, 48, 56)); + fb.FillRect(5, ly + 1, std::max(1, int(46.0f * k)), 3, + (k < 0.25f) ? RGBA(220, 150, 60) : RGBA(232, 214, 150)); + } + } + // Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка // «нажми F» висела бы на экране весь забег и перестала бы читаться. // diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 0415e7b..89c0e5a 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -218,7 +218,11 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& // пришлось бы везде, где скорость читается, а смысла в этом ноль. a.load = inv->Load() + bagShare; a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry); - a.sightMul = inv->SightMul() + buffs.sight; + // Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и + // купленное. Улучшение «обзор» без света не спасает: сел фонарь — + // светить нечем, сколько бы отряд ни вложил в глаза. + a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight) * + (buffs.lightOut ? DARK_SIGHT_MUL : 1.0f); a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp; a.held = inv->weapon; if (a.hp > a.maxHp) a.hp = a.maxHp; @@ -227,6 +231,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& { a.maxHp = g_tune.agentHP + buffs.hp; a.load = bagShare; + a.sightMul = buffs.lightOut ? DARK_SIGHT_MUL : 1.0f; } const WeaponItem* item = world.Get(a.weaponEntity); diff --git a/src/sim/agent.h b/src/sim/agent.h index 511e6bd..c3c9b44 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -164,6 +164,10 @@ struct SquadBuffs float sight = 0.0f; // прибавка к множителю обзора float hp = 0.0f; // прибавка к запасу здоровья float carry = 0.0f; // прибавка к норме переноски + // Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане + // перестаёт что-либо давать. По умолчанию false — песочница приёмки светит + // всегда (sim/squad.h). + bool lightOut = false; }; void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f, diff --git a/src/sim/enemy.cpp b/src/sim/enemy.cpp index 097520e..e745473 100644 --- a/src/sim/enemy.cpp +++ b/src/sim/enemy.cpp @@ -64,7 +64,8 @@ void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even } // namespace void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, - std::vector& targets, float dt, CombatEventLog* events) + std::vector& targets, float dt, CombatEventLog* events, + float aggroMul) { // Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение // бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили @@ -151,7 +152,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age // --- кого преследуем -------------------------------------------------- float dist = 0.0f; - const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius, &dist); + const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius * aggroMul, &dist); if (victim >= 0) t.aggro = true; // заметил — уже не забудет t.victim = victim; diff --git a/src/sim/enemy.h b/src/sim/enemy.h index f89a046..f243368 100644 --- a/src/sim/enemy.h +++ b/src/sim/enemy.h @@ -32,8 +32,13 @@ // Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще: // --accept обязан считать ровно то же, что считал. +// aggroMul — во сколько раз дальше твари замечают отряд прямо сейчас. Больше +// единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h). +// Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они +// так же и ходят так же, просто находят раньше. void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, - std::vector& targets, float dt, CombatEventLog* events); + std::vector& targets, float dt, CombatEventLog* events, + float aggroMul = 1.0f); // Сколько тварей сейчас идёт к отряду. Для напряжения и HUD. int CountAggroEnemies(const std::vector& targets); diff --git a/src/sim/items.h b/src/sim/items.h index 2a01b54..21a2382 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -55,6 +55,7 @@ enum class ItemId : uint8_t BANDAGE, STIMPACK, AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем + BATTERY, // батарея: уходит в фонарь, когда тот сел COUNT, }; @@ -76,6 +77,7 @@ struct ItemDef float heal = 0.0f; // расходник: сколько здоровья возвращает int rounds = 0; // расходник: сколько патронов добавляет в запас + float charge = 0.0f; // расходник: сколько секунд света возвращает // Сколько предмет весит. Единица условная, но соотношения — нет: плита // тяжелее всего, что можно взять в руки, а карманное почти ничего не весит. @@ -147,6 +149,48 @@ inline float CarryMul(float load, float bonus = 0.0f) 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; @@ -246,6 +290,10 @@ inline ItemDef ItemDefOf(ItemId id) 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; } @@ -357,7 +405,17 @@ struct Inventory ItemDefOf(weapon).speedMul; } float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; } - float SightMul() const { return ItemDefOf(armor).sightMul * ItemDefOf(trinket).sightMul; } + + // 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): инвентарь про отряд не знает. diff --git a/src/sim/spawn_catalog.cpp b/src/sim/spawn_catalog.cpp index 67383cd..a109a7b 100644 --- a/src/sim/spawn_catalog.cpp +++ b/src/sim/spawn_catalog.cpp @@ -46,7 +46,7 @@ const LootRow LOOT_ROWS[] = { {"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD}, {"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM}, {"spurs", ItemId::SPURS}, - {"ammo", ItemId::AMMO}, + {"ammo", ItemId::AMMO}, {"battery", ItemId::BATTERY}, {"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK}, }; diff --git a/src/sim/squad.h b/src/sim/squad.h index 8b51387..7fb31fc 100644 --- a/src/sim/squad.h +++ b/src/sim/squad.h @@ -53,6 +53,17 @@ struct Squad // как одно целое (sim/items.h). Личным остаётся только надетое. SquadBag bag; + // Сколько заряда фонаря осталось. Ресурс отряда, а не бойца, и тратится он + // за ПРОЙДЕННЫЙ ПУТЬ, а не за время: таймера в игре нет (sim/items.h). + // + // -1 означает «не садится» и стоит в ПЕСОЧНИЦЕ приёмки — по той же причине, + // по которой там бесконечны патроны: критерии меряют огневую работу, а не + // то, на сколько минут хватило фонаря. + float lightCharge = -1.0f; + + // Фонарь сел. Отрицательный заряд — это песочница, там темноты нет. + bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; } + // Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего. int Carriers() const { diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index cdf3992..256aa9a 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -273,6 +273,7 @@ Tip ItemTip(ItemId id) if (d.sightMul != 1.0f) t.Add("SIGHT %+d%%", int((d.sightMul - 1.0f) * 100.0f)); if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal)); if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds); + if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge)); return t; }