From b29358ad12deaa3e22c6f7a36da66035bda715cd Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Tue, 18 Aug 2026 18:42:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D0=B2=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8C:=20SHIFT+=D0=BA=D0=BB=D0=B8=D0=BA,=20=D1=81=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B0,=20=D1=81=D1=80=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=20=D0=BD=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=D1=8B=D0=BC=20(M38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перетаскивание мышью в кадре 480x270 — это попадание в ячейку 74x18 с зажатой кнопкой, и делать так тридцать раз после каждой вылазки нельзя. * SHIFT + клик отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО: ствол в руки, плита в броню, брелок в карман, расходник в сумку; из слота — обратно в схрон. Обмен идёт через тот же ApplyDrop, что и перетаскивание: своя копия правил означала бы ещё одно место, где вещь может потеряться. * Кнопка SORT раскладывает схрон: класс -> редкость -> аффиксы -> имя. * Подсказка вещи из схрона дописывает блок NOW: что стоит в этом слоте сейчас и чем новое отличается. --ui-check меряет сохранность, а не факт вызова: вещей было 5 — стало 5, ствол в руках, прежний в схроне, профиль о переодевании знает. Проверка сперва воспроизведена на сломанной сборке: без SaveLoadout печатает «профиль НЕ знает». Замеры: --ui-check поломок 0, --accept PASS, --descend 8 ошибок 0. Co-Authored-By: Claude Opus 5 --- docs/07-milestones.md | 2 + docs/13-combat.md | 29 +++++++ src/debug/harness.cpp | 61 ++++++++++++++ src/ui/menu.cpp | 191 +++++++++++++++++++++++++++++++++++++++++- src/ui/menu.h | 5 ++ 5 files changed, 286 insertions(+), 2 deletions(-) diff --git a/docs/07-milestones.md b/docs/07-milestones.md index f2736f5..6422634 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -62,6 +62,8 @@ | **M37** | **поле путей**: отставший боец возвращается в строй по геометрии уровня, а не по прямой; обход включается только тому, кто упёрся и потерял строй | это был тот самый «отряд не проходит там, где одиночка проходит»: `--play 6 5` вставал на первом этаже на всех восьми сидах, стало 47 из 48 (единственная потеря — вайп в бою); по дороге вскрылись ДВЕ неверные мерки: критерий 11.6 награждал брошенных за стеной бойцов (растянутый отряд = «колонна»), а `--wall` читал «стены не нашлось» как «стена на нулевом этаже» и объявлял лучший результат провалом; обе переписаны и проверены на сломанной сборке; шаг симуляции 6.1 мкс; `--accept` PASS ([05-squad-ai.md](05-squad-ai.md), [08-decisions.md](08-decisions.md)) | +| **M38** | инвентарь перестал быть перетаскиванием: SHIFT+клик отправляет вещь туда, где ей место, кнопка SORT раскладывает схрон, подсказка показывает, что заменится | `--ui-check` меряет не «функция вызвалась», а СОХРАННОСТЬ: вещей было 5 — стало 5, ствол в руках, прежний в схроне, профиль знает; проверка воспроизведена на сломанной сборке (без записи в профиль печатает «профиль НЕ знает») ([13-combat.md](13-combat.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/13-combat.md b/docs/13-combat.md index bf8370a..16042dd 100644 --- a/docs/13-combat.md +++ b/docs/13-combat.md @@ -213,6 +213,35 @@ dev-режим без профиля и песочница приёмки. HP, броню, скорость, обзор. `ENTER` меняет местами оружие в руках и выбранную ячейку сумки — отряд не носит воздух, у него всегда что-то в руках. +### Как перекладываются вещи + +Три способа, и все три ведут в один и тот же обмен (`ApplyDrop`): своя копия +правил в каждом означала бы три места, где вещь может потеряться. + +| Способ | Что делает | +|---|---| +| **SHIFT + клик** | отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО: ствол в руки, плита в броню, брелок в карман, расходник в сумку; из слота — обратно в схрон | +| перетаскивание | точный перенос в конкретную ячейку (порядок схрона — единственное, чем игрок им управляет) | +| ПКМ по вещи | выбросить под ноги; из рук нельзя — безоружный боец не боец | + +SHIFT + клик появился потому, что кадр 480x270: ячейка схрона 74x18 пикселей, и +попадать в неё зажатой кнопкой тридцать раз после каждой вылазки невозможно. + +Кнопка **SORT** раскладывает схрон: класс → редкость → число аффиксов → имя. +Именно в таком порядке, а не по имени: игрок ищет «во что переодеться», то есть +сначала вид вещи, а внутри вида — лучшее. + +Подсказка вещи из схрона или сумки дописывает блок **NOW**: что стоит в том же +слоте сейчас и чем новое от него отличается. Без него выбор снаряжения — это +запоминание чисел: навёл на плиту, увидел «ARMOR +18%», ушёл смотреть на бойца +и уже не помнишь первое. + +Всё это проверяется кодом возврата, а не глазом: `--ui-check` меряет +СОХРАННОСТЬ (сколько вещей было в схроне и слотах, столько и осталось), доехало +ли переодевание до профиля и не теряет ли вещи сортировка. Проверка сперва +воспроизведена на сломанной сборке — без записи в профиль она печатает +«профиль НЕ знает» и падает. + ## HUD [src/render/hud.cpp](../src/render/hud.cpp) рисуется в тот же софтверный diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index c4d0fbb..e8a4cee 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -2583,6 +2583,67 @@ int RunUiCheck() } } + // --- быстрый перенос и сортировка схрона --------------------------------- + // + // Перекладывание вещей — самое дорогое место инвентаря: здесь предмет может + // ПРОПАСТЬ, и до этой проверки он ровно так и пропадал (M36, пистолет). + // Меряем не «функция вызвалась», а сохранность: сколько вещей было в сумме + // по схрону и слотам, столько и осталось. + { + Ui ui; + ui.agentIndex = 0; + ui.started = false; // сборы НА БАЗЕ: сумки нет, схрон есть + + profile.stash.clear(); + profile.StashPush(ItemInstance{ItemId::PISTOL}); + profile.StashPush(ItemInstance{ItemId::PLATE}); + profile.StashPush(ItemInstance{ItemId::SABER}); + + Inventory* inv = game.world.Get(game.squad.agents[0].self); + const ItemId handsBefore = inv ? ItemId(inv->weapon) : ItemId::NONE; + const int totalBefore = profile.StashCount() + (inv && !inv->weapon.Empty() ? 1 : 0) + + (inv && !inv->armor.Empty() ? 1 : 0); + + // Пистолет из схрона обязан оказаться В РУКАХ, а прежнее оружие — в + // схроне. Именно этот обмен игрок делает после каждой вылазки. + UiQuickMoveForTest(ui, game, UiPart::STASH, 0); + const bool inHands = inv && ItemId(inv->weapon) == ItemId::PISTOL; + const bool oldBack = [&]() { + for (int k = 0; k < profile.StashCount(); ++k) + if (ItemId(profile.stash[size_t(k)]) == handsBefore) return true; + return false; + }(); + + const int totalAfter = profile.StashCount() + (inv && !inv->weapon.Empty() ? 1 : 0) + + (inv && !inv->armor.Empty() ? 1 : 0); + + // И профиль обязан узнать о переодевании: без этого вылазка соберёт + // бойца заново из профиля и вернёт ему старый ствол (M36). + const bool saved = ItemId(profile.loadout[0].weapon) == ItemId::PISTOL; + + std::printf("\nбыстрый перенос: в руках %s, прежнее в схроне %s, профиль %s, " + "вещей было %d стало %d\n", + inHands ? "да" : "НЕТ", oldBack ? "да" : "НЕТ", saved ? "знает" : "НЕ знает", + totalBefore, totalAfter); + if (!inHands || !oldBack || !saved || totalAfter != totalBefore) + { + std::printf(" [FAIL] SHIFT+клик теряет вещь или не доводит её до профиля\n"); + ++bad; + } + + // Сортировка не имеет права ничего потерять: это перестановка, а не + // операция над вещами. + const int sortBefore = profile.StashCount(); + UiActivate(ui, game, UiPart::STASH_SORT, 0); + const int sortAfter = profile.StashCount(); + std::printf("сортировка схрона: было %d, стало %d\n", sortBefore, sortAfter); + if (sortBefore != sortAfter) + { + std::printf(" [FAIL] сортировка схрона теряет вещи\n"); + ++bad; + } + } + std::printf("\nитог: пунктов %d, поломок %d\n", count, bad); return bad == 0 ? 0 : 1; } diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 41107ab..1878b17 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -156,6 +156,9 @@ constexpr int SQ_HINT_Y = 250, SQ_HINT_STEP = 10; // две ячейки в ряду: прокрутка есть, а угадывать вещь по обрубку — нет. constexpr int SQ_STASH_X = 16, SQ_STASH_Y = 206, SQ_STASH_W = 74, SQ_STASH_H = 18; constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 6, SQ_STASH_ROWS = 2; +// Кнопка «разложить»: справа от заголовка схрона, там же, где подсказка о +// прокрутке. Схрон на 48 ячеек, вещи валятся в порядке подбора. +constexpr int SQ_SORT_X = 168, SQ_SORT_W = 34, SQ_SORT_H = 10; constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS; // Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка @@ -348,6 +351,42 @@ Tip ItemTip(const ItemInstance& it) return t; } +// Сравнение с надетым. Без него выбор снаряжения — это запоминание чисел: +// игрок наводит на найденную плиту, видит «ARMOR +18%», уходит смотреть, что +// на бойце, возвращается и уже не помнит первое. Дописываем прямо в ту же +// подсказку, отдельным блоком после аффиксов. +// +// Сравнивается ТО, ЧТО ЗАМЕНИТСЯ: класс вещи выбирает слот, слот даёт текущую +// вещь. Если слот пуст или это та же самая вещь, блока нет вовсе. +void AddCompare(Tip& t, const ItemInstance& it, const Inventory& inv) +{ + const ItemDef d = ItemDefOf(it.id); + + const ItemInstance* now = nullptr; + if (IsWeaponItem(it.id)) now = &inv.weapon; + else if (d.kind == ItemKind::ARMOR) now = &inv.armor; + else if (d.kind == ItemKind::TRINKET) now = &inv.trinket; + if (!now || now->Empty() || now->id == it.id) return; + + const ItemDef c = ItemDefOf(now->id); + t.Add("NOW: %s", c.name); + + // Только те строки, где есть РАЗНИЦА: «ARMOR +0%» не говорит ничего. + const int dArmor = int((d.armor - c.armor) * 100.0f + (d.armor >= c.armor ? 0.5f : -0.5f)); + const int dSpeed = int((d.speedMul - c.speedMul) * 100.0f + + (d.speedMul >= c.speedMul ? 0.5f : -0.5f)); + const int dHp = int(d.hpBonus - c.hpBonus); + const int dSight = int((d.sightMul - c.sightMul) * 100.0f + + (d.sightMul >= c.sightMul ? 0.5f : -0.5f)); + const float dWeight = d.weight - c.weight; + + if (dArmor != 0) t.Add(" ARMOR %+d%%", dArmor); + if (dSpeed != 0) t.Add(" SPEED %+d%%", dSpeed); + if (dHp != 0) t.Add(" HP %+d", dHp); + if (dSight != 0) t.Add(" SIGHT %+d%%", dSight); + if (dWeight < -0.05f || dWeight > 0.05f) t.Add(" WEIGHT %+.1f", double(dWeight)); +} + Tip StatTip(int line, const Agent& a) { Tip t; @@ -576,6 +615,8 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) // (продолжение ниже) if (game.profile) { + out.Add(UiPart::STASH_SORT, 0, + UiRect{SQ_SORT_X, SQ_STASH_Y - 12, SQ_SORT_W, SQ_SORT_H}); for (int c = 0; c < SQ_STASH_CELLS; ++c) { const int k = ui.stashScroll + c; @@ -748,6 +789,113 @@ void DropToFloor(Ui& ui, Game& game, UiPart part, int index) SaveLoadout(game, ui.agentIndex); } +// ----------------------------------------------------------------------------- +// Быстрый перенос: SHIFT + клик. +// +// Перетаскивание мышью в кадре 480x270 — это точное попадание в ячейку 74x18 +// с зажатой кнопкой, и делать так тридцать раз подряд после каждой вылазки +// невозможно. SHIFT + клик отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО, и обратно: +// +// схрон -> подходящий слот бойца (что было в слоте, уезжает в схрон); +// слот -> схрон. +// +// Слот выбирается по КЛАССУ вещи, а не по номеру: ствол в руки, плита в броню, +// брелок в карман, расходник в сумку. Это ровно те же правила, что и у +// перетаскивания (SlotAccepts), просто игроку больше не надо их отмерять рукой. +UiPart SlotForItem(ItemId id, bool started) +{ + if (IsWeaponItem(id)) return UiPart::HANDS; + switch (ItemDefOf(id).kind) + { + case ItemKind::ARMOR: return UiPart::ARMOR; + case ItemKind::TRINKET: return UiPart::POCKET; + // Расходник — в сумку, но сумка принадлежит ВЫЛАЗКЕ: вне забега её нет, + // и вещь, положенная туда из меню, исчезла бы вместе с миром. + default: return started ? UiPart::BAG : UiPart::NONE; + } +} + +// Первая пустая ячейка сумки, иначе последняя. Возвращает -1, если сумки нет. +int FreeBagCell(const SquadBag& bag) +{ + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) + if (bag.slots[i].Empty()) return i; + return -1; +} + +void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex); + +void QuickMove(Ui& ui, Game& game, UiPart part, int index) +{ + if (!game.profile) return; + Agent& a = game.squad.agents[ui.agentIndex]; + Inventory* inv = game.world.Get(a.self); + if (!inv) return; + Profile& pr = *game.profile; + + if (part == UiPart::STASH) + { + if (index < 0 || index >= pr.StashCount()) return; + const ItemInstance moved = pr.stash[size_t(index)]; + const UiPart dest = SlotForItem(moved, ui.started); + if (dest == UiPart::NONE) return; + + int destIndex = 0; + if (dest == UiPart::BAG) + { + destIndex = FreeBagCell(game.squad.bag); + if (destIndex < 0) return; // сумка полна — молча терять вещь нельзя + } + + // Дальше — обычный обмен, тот же самый код, что и у перетаскивания. + // Своя копия правил обмена здесь означала бы два места, где вещь может + // потеряться, вместо одного. + ui.dragFrom = UiPart::STASH; + ui.dragIndex = index; + ui.dragItem = moved; + ApplyDrop(ui, game, dest, destIndex); + ui.dragFrom = UiPart::NONE; + ui.dragIndex = -1; + ui.dragItem = ItemId::NONE; + return; + } + + // Обратно в схрон. Из РУК не отправляем: безоружный боец — не боец, и то же + // правило уже стоит на выбрасывании под ноги (DropToFloor). + if (part == UiPart::HANDS || !IsSlot(part)) return; + + ItemInstance* slot = SlotPtr(*inv, game.squad.bag, part, index); + if (!slot || slot->Empty()) return; + if (pr.StashCount() >= STASH_CAP) return; // схрон полон + + ui.dragFrom = part; + ui.dragIndex = index; + ui.dragItem = *slot; + ApplyDrop(ui, game, UiPart::STASH, pr.StashCount()); + ui.dragFrom = UiPart::NONE; + ui.dragIndex = -1; + ui.dragItem = ItemId::NONE; +} + +// ----------------------------------------------------------------------------- +// Сортировка схрона. Схрон на сорок восемь ячеек, вещи в него валятся в порядке +// подбора, и найти в нём нужное — отдельная работа, которой игрок не просил. +// +// Порядок: класс -> редкость (сначала лучшее) -> число аффиксов -> имя. +// Именно в таком, а не по имени: игрок ищет «во что переодеться», то есть +// сначала вид вещи, а внутри вида — лучшее. +void SortStash(Profile& pr) +{ + std::stable_sort(pr.stash.begin(), pr.stash.end(), + [](const ItemInstance& x, const ItemInstance& y) { + const ItemDef dx = ItemDefOf(x.id), dy = ItemDefOf(y.id); + if (dx.kind != dy.kind) return int(dx.kind) < int(dy.kind); + if (x.rarity != y.rarity) return int(x.rarity) > int(y.rarity); + if (x.Count() != y.Count()) return x.Count() > y.Count(); + return std::strcmp(dx.name, dy.name) < 0; + }); +} + void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) { Agent& a = game.squad.agents[ui.agentIndex]; @@ -876,6 +1024,11 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) ui.screen = Screen::GAME; return; + case UiPart::STASH_SORT: + if (game.profile) SortStash(*game.profile); + ui.stashScroll = 0; + return; + case UiPart::HUD_BUTTON: switch (index) { @@ -996,6 +1149,11 @@ void UiApplyDropForTest(Ui& ui, Game& game, UiPart to, int toIndex) ApplyDrop(ui, game, to, toIndex); } +void UiQuickMoveForTest(Ui& ui, Game& game, UiPart part, int index) +{ + QuickMove(ui, game, part, index); +} + int UiMenuItemCount() { return MENU_COUNT; } const char* UiMenuItemName(int index) @@ -1155,6 +1313,19 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) // --- мышь: выбор бойца и перетаскивание предметов --- if (lmbDown && hot) { + // SHIFT + клик — быстрый перенос: вещь уходит туда, где ей + // место, без прицеливания мышью в ячейку 74x18. + const bool quick = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); + if (quick && IsSlot(hot->part) && hot->item != ItemId::NONE) + { + QuickMove(*this, game, hot->part, hot->index); + return true; + } + if (hot->part == UiPart::STASH_SORT) + { + UiActivate(*this, game, hot->part, hot->index); + return true; + } if (hot->part == UiPart::AGENT) { agentIndex = hot->index; @@ -1386,6 +1557,13 @@ void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widg char cap[32]; std::snprintf(cap, sizeof(cap), "STASH %d/%d", game.profile->StashCount(), STASH_CAP); Text(l, cap, SQ_STASH_X, SQ_STASH_Y - 12, 10, COL_TEXT); + if (const Widget* sortBtn = w.Find(UiPart::STASH_SORT, 0)) + { + const bool hot = Hovered(ui, *sortBtn); + BoxLines(l, sortBtn->rect, hot ? COL_TEXT : COL_EDGE); + Text(l, "SORT", sortBtn->rect.x + 5, sortBtn->rect.y + 1, 8, + hot ? COL_TEXT : COL_FAINT); + } // Схрон длиннее сетки: без явной подсказки прокрутка не существует. if (game.profile->StashCount() > SQ_STASH_CELLS) Text(l, "Q/E SCROLL", SQ_STASH_X + 92, SQ_STASH_Y - 11, 8, COL_FAINT); @@ -1409,7 +1587,7 @@ void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widg std::snprintf(line, sizeof(line), "SIGHT %d%%", int(a.sightMul * 100.0f + 0.5f)); Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP * 3, 8, COL_FAINT); - Text(l, "DRAG ITEMS WITH MOUSE RMB ON ITEM DROPS IT", SQ_STAT_X, SQ_HINT_Y, 8, + Text(l, "SHIFT+CLICK MOVES ITEM DRAG ALSO WORKS RMB DROPS", SQ_STAT_X, SQ_HINT_Y, 8, COL_FAINT); Text(l, "A/D FIGHTER W/S SLOT ENTER TAKE ESC BACK", SQ_STAT_X, SQ_HINT_Y + SQ_HINT_STEP, 8, COL_FAINT); @@ -1826,7 +2004,16 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const if (hoverPart == UiPart::STAT) DrawTip(l, StatTip(hoverIndex, game.squad.agents[agentIndex]), int(mx), int(my)); else if (hov->item != ItemId::NONE) - DrawTip(l, ItemTip(hov->item), int(mx), int(my)); + { + Tip t = ItemTip(hov->item); + // Сравниваем только то, что ещё НЕ надето: наводя на слот + // бойца, игрок и так смотрит на надетое. + if (hoverPart == UiPart::STASH || hoverPart == UiPart::BAG) + if (const Inventory* inv = + game.world.Get(game.squad.agents[agentIndex].self)) + AddCompare(t, hov->item, *inv); + DrawTip(l, t, int(mx), int(my)); + } } } diff --git a/src/ui/menu.h b/src/ui/menu.h index 840b287..9c45d18 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -55,6 +55,7 @@ enum class UiPart : uint8_t CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем CRAFT_AFFIX, // строка аффикса на верстаке: цель направленной операции HUD_BUTTON, // кнопка нижней панели в бою: открывает экран + STASH_SORT, // кнопка «разложить схрон по порядку» }; // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне @@ -148,5 +149,9 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index); // нельзя, окна в прогоне нет. void UiApplyDropForTest(Ui& ui, Game& game, UiPart to, int toIndex); +// То же для быстрого переноса (SHIFT + клик): проверка обязана убедиться, что +// вещь доехала до слота, а не пропала по дороге. +void UiQuickMoveForTest(Ui& ui, Game& game, UiPart part, int index); + int UiMenuItemCount(); const char* UiMenuItemName(int index);