diff --git a/engine/level_gen.cpp b/engine/level_gen.cpp index 79902d1..b3e170f 100644 --- a/engine/level_gen.cpp +++ b/engine/level_gen.cpp @@ -50,12 +50,29 @@ int PickRoom(const RoomLibrary& lib, LevelRng& rng, int depth, bool needStart) } // ----------------------------------------------------------------------------- -// Прорубка. Коридор шириной 2: отряд из пяти человек в проходе шириной 1 идёт -// строго гуськом, и весь смысл строя пропадает. Ширина берётся ровно такой же, -// как у проходов захардкоженной карты (docs/04-world.md) — иначе поведение -// отряда в коридоре пришлось бы настраивать заново. +// Прорубка. Ширина прохода — ТРИ тайла. +// +// Два тайла хватало ровно на то, чтобы отряд протиснулся гуськом, и не хватало +// ни на что больше. Пятеро в строю занимают поперёк заметно больше одного тела: +// у каждого свой радиус, между ними интервал строя, а на повороте колонна ещё и +// раздувается наружу. В проходе шириной 2 передний упирается в угол, задние — в +// переднего, и отряд встаёт намертво (прогон `--play 4 5`). +// +// Три — это «двое плечом к плечу и место обойти третьего». Меньше не даёт +// разойтись, больше превращает коридор в комнату и съедает разницу между ними. +// +// Отсюда же требование к КОМНАТАМ: сторона не больше 12 при слоте в 16 тайлов. +// Комната шире оставляет между собой и соседкой меньше четырёх клеток камня, а +// в них полоса шириной 3 уже не ложится — поперечный отрезок начинает резать +// чужую стену. Комнату шире 12 генератор не сломает (полоса прижмётся клампом +// ниже), но зазор станет узким местом, ради устранения которого всё и делалось. // ----------------------------------------------------------------------------- -constexpr int CORRIDOR_W = 2; +constexpr int CORRIDOR_W = 3; + +// Проём в стене комнаты — ровно той же ширины, что коридор. Разойдись эти два +// числа — узкое место осталось бы В САМОМ ПРОЁМЕ, то есть там, где отряд +// заведомо идёт плотно и где его меньше всего ждёшь. +constexpr int DOORWAY_W = CORRIDOR_W; // Внешнее кольцо карты остаётся камнем всегда: вне карты считается стеной, и // коридор, дошедший до края, дал бы отряду выйти в это «вне». @@ -90,6 +107,31 @@ void CarveRun(Tilemap& map, int x0, int y0, int x1, int y1, uint8_t theme) } } +// Квадрат CORRIDOR_W x CORRIDOR_W с началом в (x, y). +// +// Нужен ровно на ПОВОРОТЕ. Два отрезка, сходящиеся углом, дают на стыке не +// широкий поворот, а выемку: полоса вдоль x кончается там, где полоса вдоль y +// начинается, и внешний угол остаётся камнем. Отряд в этом месте снова +// протискивается по одному, и весь выигрыш от широкого коридора съедается на +// первом же изгибе. +void CarveCorner(Tilemap& map, int x, int y, uint8_t theme) +{ + for (int j = 0; j < CORRIDOR_W; ++j) + for (int i = 0; i < CORRIDOR_W; ++i) CarveCell(map, x + i, y + j, theme); +} + +// Начало полосы прохода вдоль стены длиной `extent`. +// +// Полоса растёт от точки контакта в сторону роста координаты, и без клампа она +// вылезала бы за угол комнаты: угол — единственная клетка рамки, за которой +// сразу чужой слот, и дыра в нём читается как обвал, а не как проход. Поэтому +// полоса прижимается к последней НЕугловой клетке стены. +int DoorSpanStart(int contact, int extent, int width) +{ + const int last = extent - 2; // последняя неугловая клетка стороны + return std::max(1, std::min(contact, last - width + 1)); +} + // Где комната встречает коридор со стороны side: дверь, если она есть, иначе // середина стены. Второе — не поблажка: комната без двери на нужной стороне // остаётся легальной, просто генератор пробьёт её сам, и это видно на карте. @@ -121,38 +163,64 @@ void ConnectSlots(Tilemap& map, const RoomLibrary& lib, const GeneratedLevel& lv ContactPoint(ra, dir, ax, ay); ContactPoint(rb, OppositeSide(dir), bx, by); + const int dx = SideDX(dir); + const int dy = SideDY(dir); + + // Полоса проёма идёт ПОПЕРЁК хода коридора: у восточной двери — по y, у + // северной — по x. Точка контакта задаёт её начало, а не середину: комната + // рисует группу дверей '+++' с той же клетки, и проём совпадает с тем, что + // видно в файле комнаты, а не съезжает от него на тайл. + if (dx != 0) + { + ay = DoorSpanStart(ay, ra.h, DOORWAY_W); + by = DoorSpanStart(by, rb.h, DOORWAY_W); + } + else + { + ax = DoorSpanStart(ax, ra.w, DOORWAY_W); + bx = DoorSpanStart(bx, rb.w, DOORWAY_W); + } + // В координаты карты. ax += A.originX; ay += A.originY; bx += B.originX; by += B.originY; - const int dx = SideDX(dir); - const int dy = SideDY(dir); + // Сам проём прорубается всегда и на всю ширину: если его пробили по + // умолчанию, стены на этом месте больше нет, а если это нарисованная дверь — + // она и так пол. + for (int t = 0; t < DOORWAY_W; ++t) + { + CarveCell(map, ax + (dy != 0 ? t : 0), ay + (dx != 0 ? t : 0), theme); + CarveCell(map, bx + (dy != 0 ? t : 0), by + (dx != 0 ? t : 0), theme); + } - // Сама дверь прорубается всегда: если её пробили по умолчанию, стены на - // этом месте больше нет, а если это нарисованная дверь — она и так пол. - CarveCell(map, ax, ay, theme); - CarveCell(map, bx, by, theme); - // Дверь шириной в один тайл упёрлась бы в коридор шириной два: узкое место - // осталось бы ровно в проёме, там, где его меньше всего ждёшь. - CarveCell(map, ax + (dy != 0 ? 1 : 0), ay + (dx != 0 ? 1 : 0), theme); - CarveCell(map, bx + (dy != 0 ? 1 : 0), by + (dx != 0 ? 1 : 0), theme); - - // Шаг наружу из обеих дверей — в зазор между комнатами. + // Шаг наружу из обоих проёмов — в зазор между комнатами. const int aox = ax + dx, aoy = ay + dy; const int box = bx - dx, boy = by - dy; if (dx != 0) { - const int midX = (aox + box) / 2; + // Поперечный отрезок ложится полосой в +x, и удержать его надо В ЗАЗОРЕ: + // вылезь он на тайл — прорезал бы стену соседней комнаты на всю свою + // длину, и вместо комнаты вышел бы сарай с распахнутым боком. + const int lo = std::min(aox, box), hi = std::max(aox, box); + const int midX = std::max(lo, std::min((aox + box) / 2, hi - (CORRIDOR_W - 1))); + CarveRun(map, aox, aoy, midX, aoy, theme); + CarveCorner(map, midX, aoy, theme); CarveRun(map, midX, aoy, midX, boy, theme); + CarveCorner(map, midX, boy, theme); CarveRun(map, midX, boy, box, boy, theme); } else { - const int midY = (aoy + boy) / 2; + const int lo = std::min(aoy, boy), hi = std::max(aoy, boy); + const int midY = std::max(lo, std::min((aoy + boy) / 2, hi - (CORRIDOR_W - 1))); + CarveRun(map, aox, aoy, aox, midY, theme); + CarveCorner(map, aox, midY, theme); CarveRun(map, aox, midY, box, midY, theme); + CarveCorner(map, box, midY, theme); CarveRun(map, box, midY, box, boy, theme); } } diff --git a/projects/descent/rooms/arena.room b/projects/descent/rooms/arena.room index aba0fe3..755dd17 100644 --- a/projects/descent/rooms/arena.room +++ b/projects/descent/rooms/arena.room @@ -1,24 +1,28 @@ # Открытая арена с четырьмя опорами. Здесь работает решатель огневых позиций: # места хватает, и отряд обязан разойтись по линиям, а не сбиться в кучу. +# +# Опоры стали ОДНОРЯДНЫМИ вместо блоков 2x2, а сама арена сузилась до 12: блок +# отнимал у центра столько пола, что пятеро вставали в него впритык и переставали +# расходиться. Укрытие нужно, чтобы за него зайти, а не чтобы об него спотыкаться. name arena -size 14 12 +size 12 12 weight 3 theme 0 start 0 depth 1 0 tiles -####++####++## -#............# -#..##....##..# -#..##....##..# -#............# -+............+ -+............+ -#............# -#..##....##..# -#..##....##..# -#............# -####++####++## +####+++##### +#..........# +#..........# +#..##..##..# +#..........# ++..........+ ++..........+ ++..........+ +#..........# +#..##..##..# +#..........# +####+++##### spawn 4 4 0.90 shambler:4 rusher:3 spitter:2 spawn 9 4 0.85 shambler:4 rusher:3 spawn 4 7 0.85 shambler:3 rusher:2 brute:1 diff --git a/projects/descent/rooms/barracks.room b/projects/descent/rooms/barracks.room index b7772a3..27ad6e0 100644 --- a/projects/descent/rooms/barracks.room +++ b/projects/descent/rooms/barracks.room @@ -1,23 +1,27 @@ -# Казарма: ряды перегородок и один сквозной проход посередине. Твари выходят +# Казарма: ряды перегородок и сквозной проход посередине. Твари выходят # из-за переборок сбоку, а не в лоб, — строй приходится разворачивать на ходу. +# +# Перегородок стало вдвое меньше, а проход посередине — в три ряда. Прежние пять +# рядов по два тайла резали комнату на щели шириной в тело: сбоку выходить было +# откуда, а разворачиваться в ответ уже негде. name barracks -size 13 9 +size 12 9 weight 2 theme 1 start 0 depth 2 0 tiles -#####+++##### -#...........# -#.##.##.##..# -#.##.##.##..# -+...........+ -#.##.##.##..# -#.##.##.##..# -#...........# -#####+++##### +####+++##### +#..........# +#..##..##..# ++..........+ ++..........+ ++..........+ +#..##..##..# +#..........# +####+++##### spawn 4 1 0.75 shambler:3 rusher:2 spawn 8 1 0.60 spitter:2 shambler:1 -spawn 11 4 0.70 rusher:3 lurker:1 +spawn 10 4 0.70 rusher:3 lurker:1 spawn 4 7 0.75 shambler:3 brute:1 spawn 1 4 0.45 crate:2 barrel:2 diff --git a/projects/descent/rooms/corridor_x.room b/projects/descent/rooms/corridor_x.room index d4eb235..7e4bd1f 100644 --- a/projects/descent/rooms/corridor_x.room +++ b/projects/descent/rooms/corridor_x.room @@ -1,25 +1,28 @@ -# Перекрёсток. Рукава шириной три тайла: правило «не стрелять сквозь своих» -# упирается в геометрию, и отряд вытягивается в колонну. +# Перекрёсток. Рукава шириной три тайла — ровно как коридоры между комнатами: +# правило «не стрелять сквозь своих» упирается в геометрию, и отряд вытягивается +# в колонну, но не встаёт в ней намертво. +# +# Середина расширена до четырёх рядов: перекрёсток — единственное место, где +# колонна имеет право снова стать строем, и на трёх рядах она этого не успевала. name corridor_x -size 13 13 +size 12 12 weight 3 theme 0 start 0 depth 1 0 tiles -#####+++##### -#####...##### -#####...##### -#####...##### -#####...##### -+...........+ -+...........+ -+...........+ -#####...##### -#####...##### -#####...##### -#####...##### -#####+++##### +####+++##### +####...##### +####...##### +####...##### ++..........+ ++..........+ ++..........+ +#..........# +####...##### +####...##### +####...##### +####+++##### spawn 6 2 0.60 rusher:3 lurker:1 spawn 6 10 0.60 rusher:3 lurker:1 spawn 2 6 0.50 shambler:2 spitter:1 diff --git a/projects/descent/rooms/hall_start.room b/projects/descent/rooms/hall_start.room index 53aa026..03f551f 100644 --- a/projects/descent/rooms/hall_start.room +++ b/projects/descent/rooms/hall_start.room @@ -1,5 +1,9 @@ # Стартовая: просторно, светло по планировке и почти пусто. # Отряд обязан успеть собраться в строй до первой твари. +# +# Поперёк комнаты идёт сплошная полоса пола в три ряда, и опор осталось по одной +# на угол. Первое, что отряд делает после высадки, — разворачивается; делать это +# должно быть где, иначе первые секунды каждого этажа уходят на распутывание строя. name hall_start size 12 10 weight 2 @@ -7,15 +11,15 @@ theme 0 start 1 depth 1 0 tiles -#####++##### +####+++##### +#..........# #..........# -#..#....#..# #..#....#..# +..........+ +..........+ -#..#....#..# ++..........+ #..#....#..# #..........# -#####++##### +####+++##### spawn 2 2 0.50 crate:2 barrel:1 spawn 9 7 0.40 barrel:1 locker:1 diff --git a/projects/descent/rooms/nest.room b/projects/descent/rooms/nest.room index 0b083b7..02b004a 100644 --- a/projects/descent/rooms/nest.room +++ b/projects/descent/rooms/nest.room @@ -1,5 +1,9 @@ -# Гнездо: кольцевые перегородки и ядро посередине. Линий огня почти нет, +# Гнездо: глухое ядро посередине и кольцо вокруг него. Линий огня почти нет, # зато клинку есть где работать — сектор упирается в стену, а не в союзника. +# +# Кольцо теперь в три тайла со всех сторон, а перегородок внутри него нет вовсе. +# Прежние два вложенных кольца давали проходы шириной в одну клетку: отряд не +# обходил ядро, он в нём застревал, и «нет линий огня» превращалось в «нет игры». name nest size 12 12 weight 2 @@ -7,18 +11,18 @@ theme 2 start 0 depth 4 0 tiles -#####++##### +####+++##### #..........# -#.##....##.# -#.#......#.# -#.#..##..#.# -+....##....+ -+....##....+ -#.#..##..#.# -#.#......#.# -#.##....##.# #..........# -#####++##### +#..........# +#...####...# ++...####...+ ++...####...+ ++..........+ +#..........# +#..........# +#..........# +####+++##### spawn 5 1 0.85 shambler:3 brute:1 spawn 2 5 0.80 lurker:2 rusher:2 spawn 9 5 0.80 lurker:2 rusher:2 diff --git a/projects/descent/rooms/pit.room b/projects/descent/rooms/pit.room index 373deb9..4a50f9e 100644 --- a/projects/descent/rooms/pit.room +++ b/projects/descent/rooms/pit.room @@ -1,28 +1,30 @@ # Яма: колодец посередине с единственным входом снизу. Громилу в нём # приходится разбирать вдвоём, а круг его удара достаёт обоих. +# +# Колодец сузился до двух тайлов, зато обход вокруг него стал трёхрядным. Смысл +# комнаты в том, что В КОЛОДЦЕ тесно, а не в том, что тесно везде: раньше отряд +# вставал ещё на подходе и до ямы просто не доходил. name pit -size 14 14 +size 12 12 weight 2 theme 2 start 0 depth 6 0 tiles -######++###### -#............# -#............# -#...######...# -#...#....#...# -+...#....#...+ -+...#....#...+ -+...#....#...+ -+...#....#...+ -#...#....#...# -#...##..##...# -#............# -#............# -######++###### -spawn 6 6 0.95 brute:3 shambler:2 +####+++##### +#..........# +#..........# +#..........# +#...####...# ++...#..#...+ ++...#..#...+ ++...#..#...+ +#..........# +#..........# +#..........# +####+++##### +spawn 5 6 0.95 brute:3 shambler:2 spawn 2 4 0.70 spitter:2 rusher:2 -spawn 11 4 0.70 spitter:2 rusher:2 -spawn 6 12 0.60 shambler:3 lurker:1 -spawn 2 11 0.40 barrel:2 crate:1 +spawn 9 4 0.70 spitter:2 rusher:2 +spawn 6 10 0.60 shambler:3 lurker:1 +spawn 2 9 0.40 barrel:2 crate:1 diff --git a/projects/descent/rooms/storage.room b/projects/descent/rooms/storage.room index dc10452..20565f4 100644 --- a/projects/descent/rooms/storage.room +++ b/projects/descent/rooms/storage.room @@ -1,5 +1,9 @@ # Склад: обстановки много, тварей мало. Место, где игрок узнаёт, что хлам # ломается, — и тратит на это магазин, которого потом не хватит. +# +# Стеллаж стал одним глухим блоком посередине, а обход вокруг него — трёхрядным. +# Прежняя двойная рамка оставляла по краям щели в один тайл: отряд входил на +# склад и не мог из него выйти иначе как гуськом. name storage size 12 10 weight 2 @@ -7,17 +11,17 @@ theme 1 start 0 depth 1 0 tiles -####++###### +####+++##### #..........# -#.####.###.# -#.#......#.# -+.#......#.+ -+.#......#.+ -#.#......#.# -#.###.####.# #..........# -######++#### -spawn 4 4 0.90 crate:4 barrel:3 locker:2 -spawn 7 4 0.85 crate:3 barrel:2 -spawn 4 6 0.70 barrel:2 locker:2 +#...####...# ++...####...+ ++...####...+ ++..........+ +#..........# +#..........# +####+++##### +spawn 2 4 0.90 crate:4 barrel:3 locker:2 +spawn 9 4 0.85 crate:3 barrel:2 +spawn 5 7 0.70 barrel:2 locker:2 spawn 1 1 0.45 shambler:2 lurker:1 diff --git a/projects/descent/rooms/vault.room b/projects/descent/rooms/vault.room index abe000f..c36c245 100644 --- a/projects/descent/rooms/vault.room +++ b/projects/descent/rooms/vault.room @@ -1,5 +1,9 @@ -# Схрон: тесная камера в камере. Хват здесь опаснее всего — вырванного из -# строя бойца в такой тесноте нечем прикрыть. +# Схрон: тесная комната с глухим сейфом посередине. Хват здесь опаснее всего — +# вырванного из строя бойца в такой тесноте нечем прикрыть. +# +# Тесно тут за счёт РАЗМЕРА комнаты, а не за счёт щелей: камера в камере давала +# обходы шириной в одну клетку, и отряд не столько рисковал, сколько стоял. +# Обход теперь трёхрядный, а схрон всё равно самая маленькая комната набора. name vault size 10 10 weight 1 @@ -7,17 +11,17 @@ theme 3 start 0 depth 8 0 tiles -###++##### +####+++### #........# -#.######.# -#.#....#.# -+.#....#.+ -+.#....#.+ -#.#....#.# -#.##..##.# #........# -#####++### -spawn 4 4 0.90 lurker:3 brute:1 +#........# ++...##...+ ++...##...+ ++...##...+ +#........# +#........# +####+++### +spawn 7 5 0.90 lurker:3 brute:1 spawn 1 1 0.55 rusher:2 shambler:1 spawn 8 8 0.55 spitter:2 spawn 4 8 0.40 locker:2 crate:1 diff --git a/projects/hollow/rooms/cells.room b/projects/hollow/rooms/cells.room index f23b182..25aaee5 100644 --- a/projects/hollow/rooms/cells.room +++ b/projects/hollow/rooms/cells.room @@ -1,19 +1,22 @@ # Ряд камер. Ниши по бокам: в них удобно стоять и невозможно отступать. +# +# Ниш стало меньше, а проход между рядами — трёхрядным: ниша должна быть местом, +# КУДА зашли, а не единственным местом, где помещается тело. name cells -size 13 9 +size 12 9 weight 3 theme 0 depth 1 0 tiles -####+++++#### -#...........# -#.#.#.#.#.#.# -+...........+ -+...........+ -+...........+ -#.#.#.#.#.#.# -#...........# -####+++++#### +####+++##### +#..........# +#.#.#..#.#.# ++..........+ ++..........+ ++..........+ +#.#.#..#.#.# +#..........# +####+++##### spawn 2 1 0.65 rusher:3 shambler:2 spawn 10 7 0.65 rusher:3 shambler:2 spawn 6 4 0.40 bandage:2 knife:1 rifle:1 diff --git a/projects/hollow/rooms/maw.room b/projects/hollow/rooms/maw.room index 4491a5f..152fafe 100644 --- a/projects/hollow/rooms/maw.room +++ b/projects/hollow/rooms/maw.room @@ -4,24 +4,27 @@ # У ядра ДВА прохода, север и юг. Глухая коробка посередине выглядит на сетке # так же, но точка спавна внутри неё недостижима: тварь не дойдёт до отряда и # не будет убита, и этаж тихо станет легче. Ровно это и ловит --check. +# +# Кольцо вокруг ядра — три тайла со всех сторон: встать кругом впятером нельзя +# в обходе шириной в два тела, там можно только выстроиться в очередь. name maw -size 13 11 +size 12 11 weight 2 theme 2 depth 3 0 tiles -#####+++##### -#...........# -#...........# -#...##.##...# -+...#...#...+ -+...#...#...+ -+...##.##...+ -#...........# -#...........# -#...........# -#####+++##### +####+++##### +#..........# +#..........# +#..........# +#...#..#...# ++...#..#...+ ++...#..#...+ ++...#..#...+ +#...#..#...# +#..........# +####+++##### spawn 6 5 0.85 brute:3 lurker:2 spawn 1 1 0.60 spitter:2 rusher:2 -spawn 11 9 0.60 spitter:2 rusher:2 -spawn 11 1 0.45 shotgun:1 plate:1 cleaver:1 +spawn 10 9 0.60 spitter:2 rusher:2 +spawn 10 1 0.45 shotgun:1 plate:1 cleaver:1 diff --git a/projects/hollow/rooms/mouth.room b/projects/hollow/rooms/mouth.room index f876f82..041466d 100644 --- a/projects/hollow/rooms/mouth.room +++ b/projects/hollow/rooms/mouth.room @@ -1,5 +1,9 @@ # Стартовая HOLLOW. Тесная и короткая: вид сверху показывает всю комнату # сразу, и просторный зал первого этажа не оставил бы ничего на потом. +# +# Тесная — по размеру, а не по проходам. От четырёх опор осталась одна: отряд +# высаживается сюда всем составом и первым делом разворачивается, а разворот +# между четырьмя тумбами упирался в них, а не в стены. name mouth size 11 9 weight 2 @@ -9,11 +13,11 @@ depth 1 0 tiles ####+++#### #.........# -#..##.##..# +#.........# +.........+ +....#....+ +.........+ -#..##.##..# +#.........# #.........# ####+++#### spawn 2 1 0.45 barrel:1 diff --git a/projects/hollow/rooms/shaft.room b/projects/hollow/rooms/shaft.room index f799236..a51b167 100644 --- a/projects/hollow/rooms/shaft.room +++ b/projects/hollow/rooms/shaft.room @@ -1,7 +1,11 @@ # Колодец. Узкий и длинный: строй здесь не развернуть, и это единственное, # что комната делает. +# +# «Не развернуть» — это пять тайлов в ширину против трёх рядов строя, а не щель +# в одну клетку. Перемычки теперь поджимают колодец с БОКОВ до трёх тайлов, а не +# перекрывают его посередине: отряд проходит с трудом, но проходит. name shaft -size 7 13 +size 7 12 weight 2 theme 1 depth 2 0 @@ -9,16 +13,15 @@ tiles ##+++## #.....# #.....# -#.###.# +##...## +.....+ +.....+ +.....+ -+.....+ -#.###.# +##...## #.....# #.....# #.....# ##+++## spawn 3 1 0.70 lurker:2 rusher:2 -spawn 3 11 0.70 lurker:2 spitter:1 -spawn 1 6 0.35 vest:1 lantern:1 +spawn 3 10 0.70 lurker:2 spitter:1 +spawn 1 5 0.35 vest:1 lantern:1 diff --git a/save/profile.txt b/save/profile.txt new file mode 100644 index 0000000..4a0a262 --- /dev/null +++ b/save/profile.txt @@ -0,0 +1,14 @@ +# профиль: всё, что переживает вылазку. Формат — sim/profile.h +version 7 +maxdepth 1 +squad 1 +runs 1 +extractions 1 +wipes 0 +xp 63 +agent 0 knife jacket lantern +agent 1 saber jacket - +agent 2 rifle jacket - +agent 3 rifle jacket - +agent 4 pistol shroud - +stash knife knife diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index bde3d51..87c2d31 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -2823,6 +2823,30 @@ int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t see game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal)); game.Step(FIXED_DT); + if (getenv("PLAYDBG") && (steps % 300) == 0) + { + std::printf(" DBG t=%5.1f anchor(%.2f,%.2f) here=%d goal(%.1f,%.1f) in(%.2f,%.2f)" + " state=%d\n", + double(steps) * double(FIXED_DT), double(c.x), double(c.y), here, + double(goal.x), double(goal.y), double(game.in.moveScreen.x), + double(game.in.moveScreen.y), int(game.squad.state)); + for (int k = 0; k < NUM_AGENTS; ++k) + { + const Agent& a = game.squad.agents[k]; + if (!a.present) continue; + std::printf(" #%d pos(%.2f,%.2f) post(%.2f,%.2f) lane=%d tgt=%d melee=%d\n", + k, double(a.pos.x), double(a.pos.y), double(a.postPos.x), + double(a.postPos.y), int(a.hasLane), a.currentTargetId, + int(a.IsMelee())); + } + for (int yy = int(c.y) - 2; yy <= int(c.y) + 2; ++yy) + { + std::printf(" MAP y=%2d ", yy); + for (int xx = int(c.x) - 6; xx <= int(c.x) + 6; ++xx) + std::printf("%c", game.map.IsWall(xx, yy) ? '#' : '.'); + std::printf("\n"); + } + } if (game.atExit) { diff --git a/src/game.cpp b/src/game.cpp index ed5f92e..e606022 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -933,15 +933,26 @@ void Game::UpdateExit() const float r = g_tune.descendRadius; const bool nearExit = Distance(squad.anchor, level.exitPos) <= r; - const bool nearOut = Distance(squad.anchor, level.extractPos) <= r; - - // События пишутся на ФРОНТЕ: иначе журнал забивался бы одним и тем же - // сообщением каждый кадр, пока отряд стоит на месте. - if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{}); - if (nearOut && !atExtract) events.Push(CombatEventKind::EXTRACT, -1, level.extractPos, Vec2{}); + // ЭВАКУАЦИЯ ВОЗМОЖНА С ЛЮБОГО ЭТАЖА И ИЗ ЛЮБОЙ ТОЧКИ. + // + // Раньше уйти можно было, только стоя на кольце аномалии. Это выглядело как + // правило, а работало как ловушка: отряд, который решил уйти на середине + // этажа, всё равно должен был дойти до конкретной клетки — и умирал по + // дороге ровно там, где игрок уже принял решение не рисковать. + // + // Ставка при этом не ослабла ни на грамм: уносишь то, что на выживших, + // теряешь половину при вайпе. Решение «глубже или домой» осталось решением, + // просто перестало требовать пешей прогулки обратно. + // + // Чекпоинты после этого значат ровно то, чем и должны быть: не «где можно + // выйти», а «откуда можно начать следующую вылазку». atExit = nearExit; - atExtract = nearOut; + atExtract = true; + + // Событие пишется на ФРОНТЕ входа в кольцо: журнал не должен забиваться + // одним и тем же сообщением, пока отряд стоит на месте. + if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{}); } void Game::UpdateAim(float dt) diff --git a/src/render/hud.cpp b/src/render/hud.cpp index e9a5dfc..acd5ac2 100644 --- a/src/render/hud.cpp +++ b/src/render/hud.cpp @@ -153,13 +153,15 @@ void DrawRunBar(Framebuffer& fb, const Game& game) // // Разные цвета — те же, что у колец на полу: подсказка обязана читаться // как подпись к тому, на чём стоишь, а не как отдельное сообщение. - const char* hint = nullptr; - uint32_t hintCol = COL_READY; - if (game.atExtract) { hint = "EXTRACT: E"; hintCol = COL_OUT; } - else if (game.atExit) { hint = "DESCEND: F"; hintCol = COL_READY; } - if (!hint) return; + // Уйти можно откуда угодно, поэтому «EXTRACT: E» висит всегда — но тускло и + // мелко, как справка. Спуск наоборот: он привязан к месту, и его подсказка + // появляется только на кольце, зато в полную силу. + if (game.atExit) + MicroTextShadow(fb, (fb.w - MicroTextWidth("DESCEND: F")) / 2, 14, "DESCEND: F", + COL_READY, COL_SHADOW); - MicroTextShadow(fb, (fb.w - MicroTextWidth(hint)) / 2, 14, hint, hintCol, COL_SHADOW); + MicroTextShadow(fb, fb.w - MicroTextWidth("EXTRACT: E") - 5, 5, "EXTRACT: E", COL_OUT, + COL_SHADOW); } } // namespace diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp index 6a457db..13b6abf 100644 --- a/src/sim/squad.cpp +++ b/src/sim/squad.cpp @@ -172,6 +172,30 @@ void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& { anchorPrev = anchor; + // --- теснота --------------------------------------------------------------- + // Сколько свободного места вокруг ТЕЛ. Считается грубо: доля проходимых + // клеток в квадрате рядом. Этого хватает, чтобы отличить зал от коридора и + // от дверного проёма, а честный замер ширины прохода стоил бы поиска пути + // там, где его нет. + // + // Считается ЗДЕСЬ, до всего остального: от тесноты зависят и доворот строя, + // и его ширина, и обе вещи нужны в этом же шаге. + int freeTiles = 0, seenTiles = 0; + { + const Vec2 body = Centroid(); + const int ax = int(body.x), ay = int(body.y); + for (int dy = -2; dy <= 2; ++dy) + for (int dx = -2; dx <= 2; ++dx) + { + const int nx = ax + dx, ny = ay + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + ++seenTiles; + if (!map.IsWall(nx, ny)) ++freeTiles; + } + } + const float openness = (seenTiles > 0) ? float(freeTiles) / float(seenTiles) : 1.0f; + const float tight = Clampf((0.72f - openness) / 0.32f, 0.0f, 1.0f); + const bool moving = LengthSq(moveWorldDir) > 1e-6f; const AgentState prevState = state; const Vec2 c = Centroid(); @@ -213,7 +237,24 @@ void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& anchorVel += ClampLength(dv, std::max(0.1f, g_tune.anchorAccel) * dt); anchor += anchorVel * dt; - if (moving) forward = Normalized(moveWorldDir, forward); + // Ориентация строя ПОВОРАЧИВАЕТСЯ, а не щёлкает. Раньше она мгновенно + // равнялась направлению ввода, и на каждом повороте слоты клина + // перепрыгивали через весь отряд: бойцы бросали дорогу и бежали вбок, + // занимать новые места. Снаружи это и читалось как «строй тормозит» — + // отряд был занят перестроением, а не движением. + if (moving) + { + const Vec2 want = Normalized(moveWorldDir, forward); + + // В ЗАЛЕ доворот плавный: мгновенный заставлял бойцов бросать дорогу и + // бежать вбок за перепрыгнувшими слотами. В ТЕСНОТЕ наоборот — почти + // мгновенный: там строй и так колонна, и колонна, смотрящая в прошлую + // сторону, упирается в стену. Замер поймал это сразу: со сглаживанием + // на всех случаях автопилот перестал проходить даже первый этаж. + const float rate = Lerpf(g_tune.formTurnRate, 40.0f, tight); + const float k = 1.0f - std::exp(-rate * dt); + forward = Normalized(forward + (want - forward) * k, want); + } // Жёсткий предел. Сопротивление обычно не даёт до него дойти, но отряд может // и застрять в стене — тогда курсор без предела уехал бы за полкарты. @@ -243,41 +284,6 @@ void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& if (state == AgentState::HOLDING) holdElapsed += dt; } - // --- теснота --------------------------------------------------------------- - // Сколько свободного места вокруг курсора. Считается грубо: доля - // проходимых клеток в квадрате рядом. Этого хватает, чтобы отличить зал от - // коридора и от дверного проёма, а честный замер ширины прохода стоил бы - // поиска пути там, где его нет. - // - // Зачем: клин шириной в пять тел не пролезает в дверь шириной в одну - // клетку. Бойцы упираются друг в друга, расталкивание держит их на месте, и - // ОТРЯД ВСТАЁТ НАСМЕРТЬ — курсор уходит вперёд, поводок натягивается, - // и всё. Автопилот (`--play`) ловил это как «пятеро не проходят первый - // этаж там, где одиночка проходит». - // - // Поэтому в тесноте строй сжимается сам, до колонны. Это не «удобство»: - // без этого половина комнат набора непроходима для полного отряда. - int freeTiles = 0, seenTiles = 0; - { - // Меряем вокруг ТЕЛ, а не вокруг курсора: курсор к этому моменту уже - // ушёл вперёд в открытый коридор, а упираются в проём именно бойцы. - // С замером у курсора сжатие включалось там, где оно уже не нужно. - const Vec2 body = Centroid(); - const int ax = int(body.x), ay = int(body.y); - for (int dy = -2; dy <= 2; ++dy) - for (int dx = -2; dx <= 2; ++dx) - { - const int nx = ax + dx, ny = ay + dy; - if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; - ++seenTiles; - if (!map.IsWall(nx, ny)) ++freeTiles; - } - } - // 1.0 — открытый зал, 0.0 — щель. Полностью свободный квадрат 5x5 это 25 - // клеток; порог 0.6 отделяет коридор от комнаты. - const float openness = (seenTiles > 0) ? float(freeTiles) / float(seenTiles) : 1.0f; - const float tight = Clampf((0.72f - openness) / 0.32f, 0.0f, 1.0f); - // --- дыхание строя --------------------------------------------------------- const float wantSpacing = (state == AgentState::HOLDING) ? g_tune.holdSpacing diff --git a/src/sim/target.h b/src/sim/target.h index b271980..5c442d8 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -141,6 +141,12 @@ struct EnemyDef EnemyDef EnemyDefOf(EnemyKind kind); const char* EnemyName(EnemyKind kind); +// Сколько «живёт» труп твари. Практически вечность: убитая в вылазке тварь +// не возвращается. Не бесконечность только потому, что плакальщик умеет +// ускорять этот таймер своей аурой, а деление вечности ни на что не даёт +// ничего. +constexpr float ENEMY_STAYS_DEAD = 100000.0f; + struct Target { ecs::Entity self = ecs::NO_ENTITY; @@ -228,14 +234,23 @@ struct Target // напряжения, глаза в темноте, знаменатель покрытия в решателе. bool IsProp() const { return kind >= FIRST_PROP_KIND; } - // Мишень приёмки встаёт быстро — на ней меряют. Тварь не возвращается, - // пока отряд не успеет уйти: иначе зачистка не имеет смысла. - // Разбитый ящик не встаёт НИКОГДА: разрушаемая обстановка, которая - // отрастает обратно, — это не обстановка, а мишень. + // Мишень приёмки встаёт быстро — на ней меряют, и без возврата мерить было + // бы нечего: сценарии длятся дольше, чем живут мишени. + // + // ТВАРЬ НЕ ВСТАЁТ ВООБЩЕ. Убитая в этой вылазке — убита насовсем: зачистка + // обязана что-то значить. С возвратом любая расчищенная комната через + // полминуты снова была полна, и «пробиться и уйти» переставало отличаться + // от «стоять и молотить бесконечную очередь». + // + // Единственное исключение — аура плакальщика (sim/target.cpp): он поднимает + // ПАВШИХ рядом с собой, и это его роль, а не общее правило. Поэтому не + // «мгновенно навсегда», а очень долгий срок, который аура умеет ускорить. + // Разбитый ящик не встаёт никогда: обстановка, которая отрастает, — это + // не обстановка, а мишень. float RespawnDelay() const { if (IsProp()) return -1.0f; - return IsDummy() ? g_tune.targetRespawn : g_tune.enemyRespawn; + return IsDummy() ? g_tune.targetRespawn : ENEMY_STAYS_DEAD; } // Замах виден: единственный признак, по которому игрок успевает среагировать. diff --git a/src/tuning.h b/src/tuning.h index 60094fe..8aa9b1a 100644 --- a/src/tuning.h +++ b/src/tuning.h @@ -71,12 +71,19 @@ constexpr int SOLVER_CANDIDATES = SOLVER_RINGS * SOLVER_DIRS + 1; struct Tuning { // движение агента - float moveSpeed = 1.75f; // юнитов/сек: хоррор, а не шутер-беготня - float moveSpeedFast = 3.6f; // догоняют курсор быстро — иначе отряд отстаёт - float steerAccel = 18.0f; // тяжелее; НИЖЕ 16 нельзя — ломается критерий 11.4 + // Скорость отряда. Была 1.75 при курсоре 3.3: отряд ВСЕГДА отставал, и + // управление ощущалось как «тяну за верёвку», а не «веду людей». Поднято до + // 2.6 — это по-прежнему медленнее курсора (иначе поводок теряет смысл), но + // разрыв закрывается за доли секунды, а не за метры. + float moveSpeed = 2.60f; // юнитов/сек: хоррор, а не шутер-беготня + float moveSpeedFast = 4.4f; // догоняют курсор быстро — иначе отряд отстаёт + float steerAccel = 26.0f; // тяжелее; НИЖЕ 16 нельзя — ломается критерий 11.4 float bodyRadius = 0.28f; float separationRadius = 0.7f; float wSeek = 1.0f; + // Как быстро строй доворачивается к направлению хода. Мгновенный доворот + // заставлял бойцов бегать вбок на каждом повороте (sim/squad.cpp). + float formTurnRate = 7.0f; float wSeparation = 1.0f; float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7) float wWallAvoid = 1.2f; @@ -237,8 +244,8 @@ struct Tuning // anchorLeash — мягкая граница: до неё курсор идёт свободно; // anchorLeashMax — жёсткий предел: страховка, до неё обычно не доходит; // leashDrag — насколько тяжелеет курсор между границами (0..1). - float anchorLeash = 3.0f; - float anchorLeashMax = 5.0f; + float anchorLeash = 2.5f; + float anchorLeashMax = 4.2f; float leashDrag = 0.75f; // на жёсткой границе остаётся 25% скорости // --- натяжение поводка (см. docs/05-squad-ai.md) -------------------------- @@ -246,9 +253,14 @@ struct Tuning // быстрее, чем отряд шагает. Это и есть команда «бегом»: отряд ускоряется, // строй растягивается, а на верхней трети шкалы огонь прекращается вовсе. float urgeStartFrac = 0.50f; // с какой доли поводка начинается натяжение - float urgeAttack = 2.6f; // как быстро натяжение набирается + // Натяжение набирается быстро, но НЕ мгновенно, и порог спринта поднят. + // При urgeAttack 5.2 отряд уходил в спринт от любого движения курсора, а + // спринт растягивает строй и выключает огонь: в коридорах это выглядело как + // «отряд застрял». Поймано автопилотом (--play): первый этаж переставал + // проходиться вообще. + float urgeAttack = 3.4f; // как быстро натяжение набирается float urgeRelease = 3.4f; // и как быстро отпускает - float sprintAt = 0.85f; // выше — спринт: бежим, не атакуем. + float sprintAt = 0.95f; // выше — спринт: бежим, не атакуем. // Порог высокий намеренно: при удержании направления натяжение садится // примерно на 0.54, то есть отряд идёт быстрым шагом И ПРОДОЛЖАЕТ работать // по целям. Спринт остаётся тем, чем должен быть, — отдельным решением @@ -442,6 +454,7 @@ inline const TunableParam TUNABLES[] = { {"predictSlack", &Tuning::shotPredictSlack, 0.05f, 0.0f, 2.0f}, {"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f}, {"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f}, + {"formTurnRate", &Tuning::formTurnRate, 0.5f, 0.5f, 30.0f}, {"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f}, {"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f}, {"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f}, diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 5a51792..f5b873c 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -165,6 +165,28 @@ constexpr int WS_SLOT_CELLS = WS_SLOT_COLS * WS_SLOT_ROWS; // внизу экрана. Кадр 480x270, и место здесь считается по-настоящему. constexpr int WS_OP_X = 252, WS_OP_Y = 84, WS_OP_W = 212, WS_OP_H = 20, WS_OP_STEP = 22; +// ----------------------------------------------------------------------------- +// Нижняя панель боя: четыре кнопки, открывающие экраны. +// +// До неё все экраны открывались только клавишами (I, U, ESC), и знать их было +// неоткуда: игра нигде о них не говорит. Кнопка — это ещё и НАПОМИНАНИЕ, что +// экраны существуют, а мастерская и дерево без такого напоминания просто не +// открываются никогда. +// +// Панель слева, карточки бойцов по центру: они и так занимают середину, и +// класть кнопки под них значило бы драться за то же место. +constexpr int HB_X = 4, HB_W = 40, HB_H = 11, HB_GAP = 2; +constexpr int HB_COUNT = 4; + +// SQUAD, TREE, WORKSHOP, MENU. Порядок — порядок частоты нажатий. +const char* const HUD_BUTTONS[HB_COUNT] = {"SQUAD", "TREE", "WORK", "MENU"}; + +inline UiRect HudButtonRect(int i, int frameH) +{ + const int y = frameH - (HB_COUNT - i) * (HB_H + HB_GAP) - 2; + return UiRect{HB_X, y, HB_W, HB_H}; +} + // Выбор чекпоинта: столбец кнопок «начать с этажа N». constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает @@ -429,6 +451,14 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) switch (ui.screen) { case Screen::GAME: + // Кнопки живут и в бою: экраны вне боя должны открываться мышью, а + // не только клавишами, о которых игра нигде не говорит. + for (int i = 0; i < HB_COUNT; ++i) + { + Widget& b = out.Add(UiPart::HUD_BUTTON, i, HudButtonRect(i, l.fh)); + b.text = HUD_BUTTONS[i]; + b.size = 7; + } return; case Screen::MENU: @@ -595,8 +625,13 @@ bool IsSlot(UiPart p) p == UiPart::BAG || p == UiPart::STASH; } -bool SlotAccepts(UiPart part, ItemId id) +// started — вылазка уже идёт. Вне забега сумки НЕТ: она принадлежит вылазке и +// очищается при сборке этажа. Вещь, положенная в неё из меню, исчезла бы вместе +// с миром — молча, как и пропадал пистолет до этой правки. +bool SlotAccepts(UiPart part, ItemId id, bool started = true) { + if (part == UiPart::BAG && !started) return false; + switch (part) { // Пустых рук не бывает: отряд не носит воздух. ItemId::NONE — не оружие, @@ -610,9 +645,31 @@ bool SlotAccepts(UiPart part, ItemId id) } } -bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there) +bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there, bool started) { - return SlotAccepts(to, moved) && SlotAccepts(from, there); + return SlotAccepts(to, moved, started) && SlotAccepts(from, there, started); +} + +// ----------------------------------------------------------------------------- +// Сохранить снаряжение бойца В ПРОФИЛЬ. +// +// Без этого сборы перед вылазкой не значили НИЧЕГО. Экран отряда правит живой +// мир (компонент Inventory), а вылазка собирает бойцов заново: NewRun пересоздаёт +// ecs::World и раздаёт снаряжение из профиля (Game::ApplyProfileLoadout). Всё, +// что игрок переложил в меню, стиралось при старте, а вещь, вынутая из схрона, +// исчезала вместе с миром — схрон-то её уже отдал. +// +// Снаружи это выглядело ровно так, как в жалобе: «переодел пистолет первому — +// он выходит с ножом, а пистолет вообще пропал». +// +// Пишем ВСЕГДА, а не только вне забега: посреди вылазки профиль всё равно +// перезапишется при эвакуации, а лишняя запись стоит ничего. +void SaveLoadout(Game& game, int slot) +{ + if (!game.profile) return; + if (slot < 0 || slot >= NUM_AGENTS) return; + if (const Inventory* inv = game.world.Get(game.squad.agents[slot].self)) + game.profile->loadout[slot] = *inv; } // Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на @@ -676,6 +733,7 @@ void DropToFloor(Ui& ui, Game& game, UiPart part, int index) // без неё тот же шаг поднял бы её обратно. SpawnFloorItem(game.world, *slot, a.pos, 2.0f); *slot = ItemInstance{}; + SaveLoadout(game, ui.agentIndex); } void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) @@ -728,6 +786,7 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) // поменяться вместе с ними. if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS) EquipWeapon(game.world, a, inv->weapon); + SaveLoadout(game, ui.agentIndex); return; } @@ -743,6 +802,7 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) // а статы, класс и правила стрельбы остались от прежнего ствола. if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS) EquipWeapon(game.world, a, inv->weapon); + SaveLoadout(game, ui.agentIndex); } } // namespace @@ -804,6 +864,16 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) ui.screen = Screen::GAME; return; + case UiPart::HUD_BUTTON: + switch (index) + { + case 0: ui.screen = Screen::SQUAD; break; + case 1: ui.screen = Screen::UPGRADE; break; + case 2: ui.screen = Screen::WORKSHOP; break; + default: ui.screen = Screen::PAUSE; break; + } + return; + case UiPart::CRAFT_SLOT: // Клик по ячейке схрона: обычный — положить вещь на верстак, // повторный по той же — назначить её ДОНОРОМ для отпечатка. @@ -909,6 +979,11 @@ int Step() } // namespace +void UiApplyDropForTest(Ui& ui, Game& game, UiPart to, int toIndex) +{ + ApplyDrop(ui, game, to, toIndex); +} + int UiMenuItemCount() { return MENU_COUNT; } const char* UiMenuItemName(int index) @@ -961,7 +1036,15 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) switch (screen) { case Screen::GAME: + // Клик по кнопке панели забирает ввод себе: иначе он ушёл бы в игру + // и отряд дёрнулся бы к курсору в момент открытия экрана. + if (lmbDown && hot && hot->part == UiPart::HUD_BUTTON) + { + UiActivate(*this, game, hot->part, hot->index); + return true; + } if (Pressed(KEY_ESCAPE)) { screen = Screen::PAUSE; return true; } + if (Pressed(KEY_U)) { screen = Screen::UPGRADE; return true; } // Только I: TAB в бою переключает цель (core/input.h), и отбирать // его под инвентарь нельзя. if (Pressed(KEY_I)) { screen = Screen::SQUAD; return true; } @@ -1094,6 +1177,22 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) else if (!dragging && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && hot && IsSlot(hot->part) && hot->item != ItemId::NONE) DropToFloor(*this, game, hot->part, hot->index); + // Примет ли слот под курсором то, что тащим. Считается КАЖДЫЙ кадр, + // пока предмет на курсоре: по этому же флагу ячейка красится в + // «нельзя» (DrawCell). + // + // Раньше флаг не выставлялся НИГДЕ — только сбрасывался. То есть + // перетаскивание мышью не работало вообще: сброс молча отменялся, и + // единственным рабочим способом переодеться оставался ENTER. Это и + // была вторая половина жалобы «пистолет пропадает»: игрок тащил его + // в руки, отпускал, и не происходило ничего. + if (dragging) + { + const ItemInstance there = hot ? hot->item : ItemInstance{}; + dropOk = hot && IsSlot(hot->part) && + CanDrop(dragFrom, dragItem, hot->part, there, started); + } + if (dragging && lmbUp) { if (dropOk && hot) ApplyDrop(*this, game, hot->part, hot->index); @@ -1148,6 +1247,7 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) { std::swap(cell, inv->weapon); EquipWeapon(game.world, a, inv->weapon); + SaveLoadout(game, agentIndex); } } return true; @@ -1332,7 +1432,8 @@ void DrawPointer(const Layout& l, const Ui& ui) void Ui::Draw(const Game& game, const ScreenRect& fit) const { - if (screen == Screen::GAME) return; + // В бою меню раньше не рисовалось вовсе. Теперь рисуется — но только нижняя + // панель кнопок: она часть боевого экрана, а не «меню поверх игры». const Layout l = Of(fit); const bool pulse = (int(blink * 2.0f) & 1) == 0; @@ -1343,7 +1444,21 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const switch (screen) { case Screen::GAME: - return; + { + // Панель рисуется поверх готового кадра, как и всё остальное меню: + // сам мир в софтверном фреймбуфере про неё не знает. + for (int i = 0; i < w.count; ++i) + { + const Widget& b = w.item[i]; + if (b.part != UiPart::HUD_BUTTON) continue; + + const bool hot = (hoverPart == b.part && hoverIndex == b.index); + Box(l, b.rect, hot ? Color{40, 34, 30, 220} : Color{12, 12, 14, 190}); + BoxLines(l, b.rect, hot ? COL_PICK : COL_EDGE); + Text(l, b.text, b.rect.x + 4, b.rect.y + 3, 7, hot ? COL_TEXT : COL_FAINT); + } + break; + } case Screen::MENU: Shade(l, COL_DIM); diff --git a/src/ui/menu.h b/src/ui/menu.h index f2fc7f6..840b287 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -54,6 +54,7 @@ enum class UiPart : uint8_t CRAFT_ITEM, // строка операции мастерской CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем CRAFT_AFFIX, // строка аффикса на верстаке: цель направленной операции + HUD_BUTTON, // кнопка нижней панели в бою: открывает экран }; // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне @@ -142,5 +143,10 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index); // Сколько пунктов в главном меню и как называется i-й. Нужны той же проверке: // она обязана пройти по ВСЕМ пунктам, а не по тем, о которых знает. +// Применить перетаскивание как будто мышью. Наружу — ради проверки «сборы +// переживают старт вылазки» (--ui-check): собрать её через настоящую мышь +// нельзя, окна в прогоне нет. +void UiApplyDropForTest(Ui& ui, Game& game, UiPart to, int toIndex); + int UiMenuItemCount(); const char* UiMenuItemName(int index);