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