squad-proto/editor/editor.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00

1091 lines
47 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "editor/editor.h"
#include <raylib.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include "engine/pixel.h"
#include "engine/view2d.h"
namespace
{
// --- раскладка кадра ---------------------------------------------------------
constexpr int TOP_H = 12;
constexpr int PANEL_X = 380;
constexpr int PANEL_W = INTERNAL_W - PANEL_X;
constexpr int CANVAS_W = PANEL_X;
constexpr int CANVAS_Y = TOP_H;
constexpr int CANVAS_H = INTERNAL_H - TOP_H;
constexpr int STRIP_H = 38; // нижняя полоса над холстом
constexpr int STRIP_Y = INTERNAL_H - STRIP_H;
constexpr int BTN_H = 11;
constexpr int ROW = 12;
// --- палитра -----------------------------------------------------------------
const Color COL_PANEL = {14, 14, 17, 240};
const Color COL_TEXT = {206, 200, 190, 255};
const Color COL_FAINT = {112, 108, 102, 255};
const Color COL_PICK = {238, 228, 208, 255};
const Color COL_EDGE = {58, 54, 52, 255};
const Color COL_ON = {150, 60, 44, 210}; // включённая кисть
const Color COL_HOVER = {74, 44, 36, 170};
const Color COL_WARN = {198, 84, 66, 255};
const Color COL_OK = {120, 190, 130, 255};
// Служебная разметка холста рисуется в сам фреймбуфер, поэтому цвета — RGBA.
const uint32_t FB_BG = RGBA(9, 9, 11);
const uint32_t FB_GRID = RGBA(46, 46, 54);
const uint32_t FB_HOVER = RGBA(200, 190, 120);
const uint32_t FB_DOOR = RGBA(206, 168, 74);
const uint32_t FB_SPAWN = RGBA(214, 96, 78);
const uint32_t FB_SEL = RGBA(240, 232, 210);
const uint32_t FB_START = RGBA(96, 210, 140);
const char* const TOOL_NAMES[int(Tool::COUNT)] = {"FLOOR", "WALL", "DOOR", "SPAWN", "ERASE"};
// -----------------------------------------------------------------------------
// Кадр 480x270 лежит в окне со сдвигом и ЦЕЛЫМ масштабом. Вся разметка задана
// в пикселях КАДРА и переводится в окно только здесь: считать в пикселях окна
// нельзя, на каждом мониторе множитель свой.
// -----------------------------------------------------------------------------
struct Layout
{
int x = 0, y = 0, u = 1;
};
Layout Of(const ScreenRect& fit)
{
Layout l;
l.u = (fit.scale > 0) ? fit.scale : 1;
l.x = fit.x;
l.y = fit.y;
return l;
}
void Text(const Layout& l, const char* s, int fx, int fy, int size, Color c)
{
DrawText(s, l.x + fx * l.u, l.y + fy * l.u, size * l.u, c);
}
// Ширина строки В ПИКСЕЛЯХ КАДРА. Меряем на том же кегле, каким будем рисовать,
// и делим обратно: у встроенного шрифта raylib межбуквенный интервал зависит от
// размера, и «померить на 8, нарисовать на 24» дало бы другую ширину.
int MeasureFrame(const char* s, int size, int u)
{
if (u < 1) u = 1;
return MeasureText(s, size * u) / u;
}
int TextW(const Layout& l, const char* s, int size) { return MeasureFrame(s, size, l.u); }
void Box(const Layout& l, const EdRect& r, Color c)
{
DrawRectangle(l.x + r.x * l.u, l.y + r.y * l.u, r.w * l.u, r.h * l.u, c);
}
void BoxLines(const Layout& l, const EdRect& r, Color c)
{
const Rectangle rr = {float(l.x + r.x * l.u), float(l.y + r.y * l.u), float(r.w * l.u),
float(r.h * l.u)};
DrawRectangleLinesEx(rr, float(l.u), c);
}
// Капсула-заглушка для сущности, у которой ещё нет собранного спрайта.
// Размер берётся из радиуса каталога: крупная тварь обязана выглядеть крупной
// ещё до того, как её нарисуют.
Sprite MakeMarker(const EntityDesc& d)
{
const int w = std::max(6, std::min(20, int(d.radius * 30.0f)));
const int h = w + w / 2;
Sprite s;
s.Init(w, h, 0);
s.ax = w / 2;
s.ay = h - 2;
const uint32_t body = RGBA(d.r, d.g, d.b);
const uint32_t hi = ShadeColor(body, 1.30f);
const float r = float(w) * 0.5f - 0.5f;
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x)
{
// Капсула: два полукруга и прямая вставка между ними.
const float cx = float(w) * 0.5f - 0.5f;
const float dy = (y < int(r)) ? (float(y) - r)
: (y > h - 1 - int(r)) ? (float(y) - float(h - 1) + r) : 0.0f;
const float dx = float(x) - cx;
if (dx * dx + dy * dy > r * r) continue;
s.Set(x, y, (x < w / 2 && y < h / 2) ? hi : body);
}
OutlineSilhouette(s, ShadeColor(body, 0.30f));
return s;
}
uint32_t AssetIdByName(const SpriteLibrary& lib, const std::string& name)
{
if (name.empty()) return 0;
for (const uint32_t id : lib.Ids())
{
const char* n = lib.NameOf(id);
if (n && name == n) return id;
}
return 0;
}
// Контур плиты в софтверном буфере — для сетки и подсветки поверх пола.
// Форму берёт у проекции: подсветка обязана совпадать с самой плитой, иначе
// в редакторе выделен один тайл, а в игре покрашен другой.
void FbDiamond(Framebuffer& fb, int cx, int cy, uint32_t color)
{
for (int y = 0; y < g_proj.tileH; ++y)
{
int x0 = 0, x1 = 0;
g_proj.TileRow(y, x0, x1);
const int py = cy - g_proj.tileH / 2 + y;
fb.Put(cx - g_proj.tileW / 2 + x0, py, color);
fb.Put(cx - g_proj.tileW / 2 + x1 - 1, py, color);
}
}
} // namespace
// -----------------------------------------------------------------------------
// Подъём
// -----------------------------------------------------------------------------
bool Editor::Init(const std::string& projectName)
{
// Проект — ДО tiles.Generate(): тайлы рисуются под форму плиты, а её
// называет вид отображения проекта. Обратный порядок дал бы ромбы в
// проекте, который просил вид сверху.
const std::string dir = FindProjectDir(projectName);
std::string why;
if (dir.empty() || !project.Load(dir, &why))
{
status = dir.empty() ? "no project found" : why;
return false;
}
project.Apply();
tiles.Generate();
if (!catalog.Load(project.catalogPath, &why))
{
status = why;
return false;
}
// Библиотека спрайтов необязательна: без неё сущности рисуются капсулами
// по цвету каталога, и планировать уровень это не мешает.
const std::string forgeDir = FindSpriteLibraryDir();
forgeOpen = !forgeDir.empty() && forge.Open(forgeDir);
if (forgeOpen) tiles.LoadFromForge(forge);
lib.Load(project.roomsDir, &why);
if (lib.dir.empty()) lib.dir = project.roomsDir;
if (lib.Count() > 0) LoadRoomAt(0);
else NewRoom();
markers.assign(size_t(catalog.Count()), Sprite{});
Say(lib.Count() > 0 ? project.name + ": rooms in set " + std::to_string(lib.Count())
: project.name + ": empty set, started a new room");
return true;
}
void Editor::Say(const std::string& s)
{
status = s;
statusTimer = 4.0f;
}
const Sprite* Editor::MarkerSprite(int catalogIndex) const
{
if (catalogIndex < 0 || catalogIndex >= catalog.Count()) return nullptr;
if (markers.size() != size_t(catalog.Count())) markers.assign(size_t(catalog.Count()), Sprite{});
Sprite& cached = markers[size_t(catalogIndex)];
if (cached.w == 0)
{
const EntityDesc& d = catalog.At(catalogIndex);
// Собранный ассет — первым: редактор обязан показывать то, что игрок
// увидит в бою, а капсула это заведомо не он.
const uint32_t id = forgeOpen ? AssetIdByName(forge, d.sprite) : 0;
if (id == 0 || !forge.Read(id, 0, cached)) cached = MakeMarker(d);
}
return &cached;
}
// -----------------------------------------------------------------------------
// Комнаты
// -----------------------------------------------------------------------------
void Editor::LoadRoomAt(int index)
{
if (index < 0 || index >= lib.Count()) return;
roomIndex = index;
room = lib.rooms[size_t(index)];
dirty = false;
selX = selY = -1;
panX = panY = 0;
}
void Editor::NewRoom()
{
room = Room{};
// Имя обязано быть уникальным: сохранение пишет в <имя>.room, и совпадение
// молча затёрло бы чужую комнату.
for (int n = 1; n < 1000; ++n)
{
const std::string candidate = "room" + std::to_string(n);
if (lib.IndexOf(candidate) < 0) { room.name = candidate; break; }
}
// Комната без дверей никуда не встанет, поэтому одна дверь есть сразу:
// пустая заготовка не должна быть заведомо непригодной.
room.Set(room.w / 2, 0, RoomCell::DOOR);
roomIndex = -1;
dirty = true;
selX = selY = -1;
panX = panY = 0;
Say("new room: " + room.name);
}
void Editor::SaveCurrent()
{
const std::string problem = RoomProblem(room);
if (!problem.empty())
{
// Битую комнату записать можно — она просто не будет выбрана
// генератором, — но молчать об этом нельзя.
Say("saved, but broken: " + problem);
}
const std::string path = lib.dir + "/" + room.name + ".room";
std::string why;
if (!SaveRoom(path, room, &why))
{
Say("save failed: " + why);
return;
}
dirty = false;
if (problem.empty()) Say("saved: " + path);
// Набор перечитывается целиком: сохранённая комната обязана немедленно
// участвовать в предпросмотре уровня, иначе проверять правку не на чем.
const std::string name = room.name;
lib.Load(lib.dir);
roomIndex = lib.IndexOf(name);
if (preview) Regenerate();
}
void Editor::ReloadLibrary()
{
const std::string name = room.name;
std::string why;
if (!lib.Load(lib.dir, &why)) { Say(why); return; }
const int i = lib.IndexOf(name);
if (i >= 0) LoadRoomAt(i);
else if (lib.Count() > 0) LoadRoomAt(0);
Say("reloaded: " + std::to_string(lib.Count()) + " rooms, " +
std::to_string(lib.failures.size()) + " broken");
if (preview) Regenerate();
}
// -----------------------------------------------------------------------------
// Кисти
// -----------------------------------------------------------------------------
void Editor::PaintAt(int x, int y, bool erase)
{
if (!room.InBounds(x, y)) return;
// Рамка держит комнату замкнутой. Единственное, что на ней разрешено, —
// дверь: пробить стену кистью пола означало бы дыру, о которой генератор
// не знает, и комната потекла бы в камень.
const bool onBorder = room.OnBorder(x, y);
const bool onCorner = (x == 0 || x == room.w - 1) && (y == 0 || y == room.h - 1);
if (erase)
{
if (room.SpawnAt(x, y)) { room.RemoveSpawnAt(x, y); dirty = true; }
else if (onBorder && room.At(x, y) == RoomCell::DOOR)
{
room.Set(x, y, RoomCell::WALL);
dirty = true;
}
else if (!onBorder && room.At(x, y) == RoomCell::WALL)
{
room.Set(x, y, RoomCell::EMPTY);
dirty = true;
}
if (selX == x && selY == y) { selX = selY = -1; }
return;
}
switch (tool)
{
case Tool::FLOOR:
if (onBorder) return; // рамку не выскабливаем
if (room.At(x, y) == RoomCell::EMPTY) return;
room.Set(x, y, RoomCell::EMPTY);
dirty = true;
break;
case Tool::WALL:
if (room.At(x, y) == RoomCell::WALL) return;
room.Set(x, y, RoomCell::WALL);
room.RemoveSpawnAt(x, y); // точка в стене не сработает
if (selX == x && selY == y) selX = selY = -1;
dirty = true;
break;
case Tool::DOOR:
if (!onBorder || onCorner) return; // дверь живёт только на рамке
if (room.At(x, y) == RoomCell::DOOR) return;
room.Set(x, y, RoomCell::DOOR);
dirty = true;
break;
case Tool::SPAWN:
{
if (!room.Walkable(x, y)) return;
if (catalog.Count() <= 0) return;
SpawnPoint& p = room.TouchSpawnAt(x, y);
// Новая точка получает текущую кисть палитры весом 1: точка с
// пустой таблицей не даёт никого и в файл не пишется вовсе.
if (p.options.empty()) p.SetWeight(catalog.At(paletteIndex).id, 1);
selX = x;
selY = y;
dirty = true;
break;
}
default: break;
}
}
void Editor::Regenerate()
{
previewError.clear();
std::string why;
// Этаж предпросмотра — тот же, что правится в панели: редактор обязан
// показывать сборку ИМЕННО той глубины, под которую подбирают комнату.
if (!GenerateLevel(lib, catalog, DescribeFloor(previewDepth), seed, previewMap, previewLevel,
&why))
{
previewError = why;
return;
}
}
// -----------------------------------------------------------------------------
// Ввод
// -----------------------------------------------------------------------------
void Editor::Update(const ScreenRect& fit, float dt)
{
statusTimer = std::max(0.0f, statusTimer - dt);
const int u = (fit.scale > 0) ? fit.scale : 1;
const Vector2 m = GetMousePosition();
const float mx = (m.x - float(fit.x)) / float(u);
const float my = (m.y - float(fit.y)) / float(u);
const bool inFrame = mx >= 0.0f && my >= 0.0f && mx < float(INTERNAL_W) && my < float(INTERNAL_H);
BuildWidgets(u);
// --- клавиши --------------------------------------------------------------
for (int i = 0; i < int(Tool::COUNT); ++i)
if (IsKeyPressed(KEY_ONE + i)) tool = Tool(i);
if (IsKeyPressed(KEY_Q) && catalog.Count() > 0)
paletteIndex = (paletteIndex + catalog.Count() - 1) % catalog.Count();
if (IsKeyPressed(KEY_E) && catalog.Count() > 0)
paletteIndex = (paletteIndex + 1) % catalog.Count();
if (IsKeyPressed(KEY_LEFT_BRACKET)) Apply(Act::ROOM_PREV, 0);
if (IsKeyPressed(KEY_RIGHT_BRACKET)) Apply(Act::ROOM_NEXT, 0);
if (IsKeyPressed(KEY_T)) Apply(Act::THEME_CYCLE, 0);
if (IsKeyPressed(KEY_N)) Apply(Act::ROOM_NEW, 0);
if (IsKeyPressed(KEY_F5)) Apply(Act::PREVIEW_TOGGLE, 0);
if (IsKeyPressed(KEY_F6)) Apply(Act::PREVIEW_RESEED, 0);
if (IsKeyPressed(KEY_PAGE_DOWN)) Apply(Act::PREVIEW_DEEPER, 0);
if (IsKeyPressed(KEY_PAGE_UP)) Apply(Act::PREVIEW_SHALLOWER, 0);
if (IsKeyPressed(KEY_S) && (IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL)))
Apply(Act::ROOM_SAVE, 0);
if (IsKeyPressed(KEY_R) && (IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL)))
Apply(Act::ROOM_RELOAD, 0);
// Панорама: комната максимального размера шире холста, и без сдвига её
// дальний угол не достать вообще.
const int panStep = int(240.0f * dt) + 1;
if (IsKeyDown(KEY_LEFT)) panX += panStep;
if (IsKeyDown(KEY_RIGHT)) panX -= panStep;
if (IsKeyDown(KEY_UP)) panY += panStep;
if (IsKeyDown(KEY_DOWN)) panY -= panStep;
if (IsKeyPressed(KEY_HOME)) { panX = 0; panY = 0; }
// --- элементы интерфейса --------------------------------------------------
// Панели перехватывают клик первыми: кисть под кнопкой рисовала бы по
// комнате, которой в этом месте экрана не видно.
bool overWidget = false;
if (inFrame && IsMouseButtonPressed(MOUSE_BUTTON_LEFT))
{
for (const EdWidget& wgt : widgets)
{
if (!wgt.enabled || !wgt.rect.Has(mx, my)) continue;
Apply(wgt.act, wgt.arg);
overWidget = true;
break;
}
}
for (const EdWidget& wgt : widgets)
if (wgt.rect.Has(mx, my)) overWidget = true;
// Колесо листает палитру, когда курсор над правой панелью. Именно над ней,
// а не всегда: над холстом колесо ничего не делает, и листать вслепую
// список, которого не видно, — верный способ сбить выбранную кисть.
if (inFrame && mx >= float(PANEL_X))
{
const float wheel = GetMouseWheelMove();
if (wheel != 0.0f) paletteScroll = std::max(0, paletteScroll - int(wheel));
}
// --- холст ----------------------------------------------------------------
hoverX = hoverY = -1;
const bool overCanvas = inFrame && !overWidget && mx < float(CANVAS_W) && my >= float(CANVAS_Y);
if (overCanvas && !preview)
{
// Экран -> мир: ровно та же проекция, что у игры, с обратным знаком
// сдвига камеры. Своей математики у редактора нет и быть не должно.
const Vec2 origin = v2d::WorldToScreen({float(room.w) * 0.5f, float(room.h) * 0.5f});
const int ox = CANVAS_W / 2 - int(std::lround(origin.x)) + panX;
const int oy = CANVAS_Y + CANVAS_H / 2 - int(std::lround(origin.y)) + panY;
const Vec2 w = v2d::ScreenToWorld({mx - float(ox), my - float(oy)});
const int tx = int(std::floor(w.x));
const int ty = int(std::floor(w.y));
if (room.InBounds(tx, ty)) { hoverX = tx; hoverY = ty; }
}
if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && overCanvas) painting = true;
if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) painting = false;
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && overCanvas) erasing = true;
if (IsMouseButtonReleased(MOUSE_BUTTON_RIGHT)) erasing = false;
if (hoverX >= 0)
{
// Точку спавна тянуть по сетке нельзя: одно нажатие — одна точка,
// иначе мазок кистью засеивает полкомнаты.
if (painting && (tool != Tool::SPAWN || IsMouseButtonPressed(MOUSE_BUTTON_LEFT)))
PaintAt(hoverX, hoverY, false);
else if (erasing)
PaintAt(hoverX, hoverY, true);
}
// Выбранная точка могла исчезнуть вместе с правкой сетки.
if (selX >= 0 && !room.SpawnAt(selX, selY)) selX = selY = -1;
}
// -----------------------------------------------------------------------------
// Действия
// -----------------------------------------------------------------------------
void Editor::Apply(Act act, int arg)
{
switch (act)
{
case Act::TOOL: tool = Tool(arg); break;
case Act::PALETTE: paletteIndex = arg; break;
case Act::PALETTE_TAB:
{
paletteTab = EntityClass(arg);
paletteScroll = 0;
// Выбранная кисть обязана остаться видимой: иначе на вкладке лута
// подсвечено «ничего», а ставится при этом тварь с прошлой вкладки.
if (paletteIndex < catalog.Count() && catalog.At(paletteIndex).cls == paletteTab) break;
for (int i = 0; i < catalog.Count(); ++i)
if (catalog.At(i).cls == paletteTab) { paletteIndex = i; break; }
break;
}
case Act::ROOM_PREV:
if (lib.Count() > 0)
LoadRoomAt((roomIndex <= 0) ? lib.Count() - 1 : roomIndex - 1);
break;
case Act::ROOM_NEXT:
if (lib.Count() > 0)
LoadRoomAt((roomIndex + 1 >= lib.Count()) ? 0 : roomIndex + 1);
break;
case Act::ROOM_NEW: NewRoom(); break;
case Act::ROOM_SAVE: SaveCurrent(); break;
case Act::ROOM_RELOAD: ReloadLibrary(); break;
case Act::W_DEC: room.Resize(room.w - 1, room.h); dirty = true; break;
case Act::W_INC: room.Resize(room.w + 1, room.h); dirty = true; break;
case Act::H_DEC: room.Resize(room.w, room.h - 1); dirty = true; break;
case Act::H_INC: room.Resize(room.w, room.h + 1); dirty = true; break;
case Act::WEIGHT_DEC: room.weight = std::max(1, room.weight - 1); dirty = true; break;
case Act::WEIGHT_INC: room.weight = std::min(99, room.weight + 1); dirty = true; break;
case Act::THEME_CYCLE:
room.theme = uint8_t((room.theme + 1) % THEME_COUNT);
dirty = true;
break;
case Act::START_TOGGLE: room.start = !room.start; dirty = true; break;
case Act::DMIN_DEC:
room.minDepth = std::max(1, room.minDepth - 1);
dirty = true;
break;
case Act::DMIN_INC:
room.minDepth = std::min(99, room.minDepth + 1);
// Верхняя граница не может уползти ниже нижней: диапазон, у
// которого max меньше min, комнату просто выключает, и понять
// это по двум числам на панели невозможно.
if (room.maxDepth > 0 && room.maxDepth < room.minDepth) room.maxDepth = room.minDepth;
dirty = true;
break;
case Act::DMAX_DEC:
// 0 — «до бесконечности», и это шаг ВНИЗ от минимально возможного
// конечного значения: иначе до открытого диапазона не добраться.
if (room.maxDepth == 0) room.maxDepth = 99;
else if (room.maxDepth <= room.minDepth) room.maxDepth = 0;
else --room.maxDepth;
dirty = true;
break;
case Act::DMAX_INC:
if (room.maxDepth == 0) room.maxDepth = room.minDepth;
else if (room.maxDepth >= 99) room.maxDepth = 0;
else ++room.maxDepth;
dirty = true;
break;
case Act::CHANCE_DEC:
case Act::CHANCE_INC:
{
SpawnPoint* p = room.SpawnAt(selX, selY);
if (!p) break;
const float step = (act == Act::CHANCE_INC) ? 0.05f : -0.05f;
p->chance = Clampf(p->chance + step, 0.0f, 1.0f);
dirty = true;
break;
}
case Act::OPT_DEC:
case Act::OPT_INC:
{
SpawnPoint* p = room.SpawnAt(selX, selY);
if (!p || arg < 0 || arg >= catalog.Count()) break;
const std::string& id = catalog.At(arg).id;
const int cur = p->WeightOf(id);
p->SetWeight(id, std::max(0, std::min(99, cur + (act == Act::OPT_INC ? 1 : -1))));
dirty = true;
break;
}
case Act::PREVIEW_TOGGLE:
preview = !preview;
if (preview) Regenerate();
break;
case Act::PREVIEW_RESEED:
seed = seed * 1664525u + 1013904223u;
if (!preview) preview = true;
Regenerate();
break;
case Act::PREVIEW_DEEPER:
case Act::PREVIEW_SHALLOWER:
previewDepth = std::max(1, previewDepth + (act == Act::PREVIEW_DEEPER ? 1 : -1));
if (!preview) preview = true;
Regenerate();
break;
case Act::PLAYTEST:
{
// Проверка правки в БОЮ: редактор запускает игру на этом же наборе
// комнат и с этим же сидом. Отдельным процессом, а не встроенным
// режимом, — иначе редактору пришлось бы знать правила игры, и вся
// граница между целями сборки была бы декоративной.
char cmd[256];
std::snprintf(cmd, sizeof(cmd), "squad_proto --rooms --seed %u", unsigned(seed));
Say("launching: " + std::string(cmd));
std::system(cmd);
break;
}
default: break;
}
}
// -----------------------------------------------------------------------------
// Разметка панелей. Собирается КАЖДЫЙ кадр и в одном месте: попадание мыши и
// отрисовка читают одну и ту же таблицу, поэтому разъехаться им негде.
// -----------------------------------------------------------------------------
// Разметка нижней полосы. Числа стоят здесь, а не по месту, потому что их
// читают в двух местах — при сборке кнопок и при отрисовке подписей, — и
// разъехавшись, они дадут значение поверх кнопки.
namespace strip
{
constexpr int LABEL_X = 4;
constexpr int CHANCE_X = 64;
constexpr int DEC_X = 106;
constexpr int VALUE_X = 119;
constexpr int INC_X = 140;
constexpr int TOTAL_X = 156;
constexpr int BTN_W = 9;
} // namespace strip
void Editor::BuildWidgets(int u)
{
widgets.clear();
auto add = [&](Act act, int arg, EdRect r, std::string label, bool active = false,
bool enabled = true) {
EdWidget w;
w.act = act;
w.arg = arg;
w.rect = r;
w.label = std::move(label);
w.active = active;
w.enabled = enabled;
widgets.push_back(std::move(w));
};
// --- верхняя панель -------------------------------------------------------
int bx = 2;
auto topBtn = [&](Act act, const char* label, int width, bool active = false) {
add(act, 0, EdRect{bx, 1, width, 10}, label, active);
bx += width + 2;
};
topBtn(Act::ROOM_PREV, "<", 12);
topBtn(Act::ROOM_NEXT, ">", 12);
topBtn(Act::ROOM_NEW, "NEW", 26);
topBtn(Act::ROOM_SAVE, "SAVE", 30);
topBtn(Act::ROOM_RELOAD, "RELOAD", 42);
topBtn(Act::PREVIEW_TOGGLE, "LEVEL", 34, preview);
topBtn(Act::PREVIEW_RESEED, "SEED", 30);
topBtn(Act::PREVIEW_SHALLOWER, "D-", 16);
topBtn(Act::PREVIEW_DEEPER, "D+", 16);
topBtn(Act::PLAYTEST, "PLAY", 30);
// --- правая панель --------------------------------------------------------
const int px = PANEL_X + 3;
const int pw = PANEL_W - 6;
// Первая строка панели отдана имени комнаты (рисуется в DrawUi): в верхнюю
// полосу оно не влезает — там кнопки от края до края.
int y = CANVAS_Y + 12;
// Кисти в два столбца. Каталог вырос, и вертикаль панели стала дефицитом:
// пять строк под пять кнопок — это две строки палитры, которых не хватает.
const int halfW = (pw - 2) / 2;
for (int i = 0; i < int(Tool::COUNT); ++i)
{
const int col = i % 2;
const bool last = (i == int(Tool::COUNT) - 1);
const int w = last ? pw : halfW; // нечётная последняя занимает строку целиком
add(Act::TOOL, i, EdRect{px + col * (halfW + 2), y, w, BTN_H}, TOOL_NAMES[i],
tool == Tool(i));
if (col == 1 || last) y += ROW;
}
y += 6;
// Числовой счётчик: подпись слева, [-] и [+] справа. Значение рисуется
// между ними и кнопкой не является.
auto spinnerAt = [&](Act dec, Act inc, const char* label, int sx, int sw) {
add(dec, 0, EdRect{sx + sw - 21, y, 9, BTN_H}, "-");
add(inc, 0, EdRect{sx + sw - 9, y, 9, BTN_H}, "+");
EdWidget lbl;
lbl.act = Act::NONE;
lbl.rect = EdRect{sx, y, sw - 24, BTN_H};
lbl.label = label;
lbl.enabled = false;
widgets.push_back(std::move(lbl));
};
// Парами: размер — это два числа одного свойства, глубина — тоже.
spinnerAt(Act::W_DEC, Act::W_INC, "W", px, halfW);
spinnerAt(Act::H_DEC, Act::H_INC, "H", px + halfW + 2, halfW);
y += ROW;
spinnerAt(Act::DMIN_DEC, Act::DMIN_INC, "D", px, halfW);
spinnerAt(Act::DMAX_DEC, Act::DMAX_INC, "-", px + halfW + 2, halfW);
y += ROW;
spinnerAt(Act::WEIGHT_DEC, Act::WEIGHT_INC, "FREQ", px, pw);
y += ROW;
add(Act::THEME_CYCLE, 0, EdRect{px, y, pw, BTN_H}, ThemeName(room.theme));
y += ROW;
add(Act::START_TOGGLE, 0, EdRect{px, y, pw, BTN_H}, "START ROOM", room.start);
y += ROW + 6;
// --- палитра: вкладки по классу и список со скроллом ----------------------
const EntityClass tabs[3] = {EntityClass::MONSTER, EntityClass::PROP, EntityClass::LOOT};
const char* const tabNames[3] = {"MOB", "PROP", "LOOT"};
const int tabW = pw / 3;
for (int t = 0; t < 3; ++t)
add(Act::PALETTE_TAB, int(tabs[t]), EdRect{px + t * tabW, y, tabW - 1, BTN_H}, tabNames[t],
paletteTab == tabs[t]);
y += ROW + 2;
// Панель идёт до низа кадра: нижняя полоса с таблицей спавна лежит над
// ХОЛСТОМ и заканчивается на PANEL_X, поэтому палитру она не режет.
const int rows = std::max(1, (INTERNAL_H - 2 - y) / ROW);
// Индексы каталога текущей вкладки.
std::vector<int> tabItems;
for (int i = 0; i < catalog.Count(); ++i)
if (catalog.At(i).cls == paletteTab) tabItems.push_back(i);
paletteScroll = std::max(0, std::min(paletteScroll, int(tabItems.size()) - rows));
for (int r = 0; r < rows; ++r)
{
const int k = paletteScroll + r;
if (k >= int(tabItems.size())) break;
const int i = tabItems[size_t(k)];
add(Act::PALETTE, i, EdRect{px, y, pw, BTN_H}, catalog.At(i).name, paletteIndex == i);
y += ROW;
}
// Признак, что список длиннее окна. Без него скролл невидим и потому
// не существует: под последней строкой просто пусто.
if (int(tabItems.size()) > rows)
{
EdWidget more;
more.act = Act::NONE;
more.rect = EdRect{px, y, pw, BTN_H};
more.label = "- WHEEL -";
more.enabled = false;
widgets.push_back(std::move(more));
}
// --- нижняя полоса: таблица выбранной точки -------------------------------
const SpawnPoint* sel = room.SpawnAt(selX, selY);
if (!sel) return;
add(Act::CHANCE_DEC, 0, EdRect{strip::DEC_X, STRIP_Y + 3, strip::BTN_W, BTN_H}, "-");
add(Act::CHANCE_INC, 0, EdRect{strip::INC_X, STRIP_Y + 3, strip::BTN_W, BTN_H}, "+");
// Строка на каждую запись каталога, у которой есть вес, плюс текущая кисть
// палитры: добавить вариант — это тот же [+], а не отдельная операция.
int cx = strip::LABEL_X;
int cy = STRIP_Y + 18;
for (int i = 0; i < catalog.Count(); ++i)
{
const bool present = sel->WeightOf(catalog.At(i).id) > 0;
if (!present && i != paletteIndex) continue;
const int labelW = MeasureFrame(catalog.At(i).name.c_str(), 8, u) + 3;
const int cellW = labelW + strip::BTN_W * 2 + 16 + 8;
if (cx + cellW > CANVAS_W - 4)
{
cx = strip::LABEL_X;
cy += ROW;
if (cy + BTN_H > INTERNAL_H - 2) break;
}
add(Act::OPT_DEC, i, EdRect{cx + labelW, cy, strip::BTN_W, BTN_H}, "-");
add(Act::OPT_INC, i, EdRect{cx + labelW + strip::BTN_W + 16, cy, strip::BTN_W, BTN_H}, "+");
EdWidget lbl;
lbl.act = Act::NONE;
lbl.arg = i;
lbl.rect = EdRect{cx, cy, labelW, BTN_H};
lbl.label = catalog.At(i).name;
lbl.enabled = false;
widgets.push_back(std::move(lbl));
cx += cellW;
}
}
// -----------------------------------------------------------------------------
// Холст
// -----------------------------------------------------------------------------
void Editor::DrawWorld(Framebuffer& fb)
{
fb.Clear(FB_BG);
if (preview)
{
// Уровень целиком в изометрии не помещается на 480x270 даже близко:
// 48x48 тайлов — это полторы тысячи пикселей по ширине. Поэтому
// предпросмотр СВЕРХУ: он отвечает на вопрос «какая вышла планировка»,
// а не «как это выглядит», и для этого вид сверху честнее.
constexpr int PX = 4;
// Две строки цифр над картой — не «сверху экрана», а именно НАД ней:
// на карте они нечитаемы, а карта под ними теряет два ряда тайлов.
constexpr int STATS_H = 24;
const int ox = (CANVAS_W - MAP_W * PX) / 2;
const int oy = CANVAS_Y + STATS_H + (CANVAS_H - STATS_H - 24 - MAP_H * PX) / 2;
for (int ty = 0; ty < MAP_H; ++ty)
for (int tx = 0; tx < MAP_W; ++tx)
{
const bool wall = previewMap.IsWall(tx, ty);
const uint8_t theme = previewMap.ThemeAt(tx, ty);
// Цвет берётся с самого тайла набора: предпросмотр обязан
// показывать ту тему, которая реально ляжет в игре.
const Sprite& s = wall ? tiles.Wall(tx, ty, theme) : tiles.Floor(tx, ty, theme);
const uint32_t c = s.Get(s.w / 2, g_proj.tileH / 2);
// Разница вдвое, а не на четверть: на карте 4 пикселя на тайл
// читается только планировка, и стена обязана быть заметно
// темнее пола, иначе не видно ни комнат, ни коридоров.
fb.FillRect(ox + tx * PX, oy + ty * PX, PX, PX,
ShadeColor(c, wall ? 0.45f : 1.25f));
}
// Границы слотов: видно, какая комната куда встала.
for (int g = 1; g < LEVEL_GRID; ++g)
{
fb.Line(ox + g * ROOM_SLOT * PX, oy, ox + g * ROOM_SLOT * PX, oy + MAP_H * PX, FB_GRID);
fb.Line(ox, oy + g * ROOM_SLOT * PX, ox + MAP_W * PX, oy + g * ROOM_SLOT * PX, FB_GRID);
}
for (const LevelSpawn& sp : previewLevel.spawns)
{
const int i = catalog.IndexOf(sp.id);
const uint32_t c = (i >= 0) ? RGBA(catalog.At(i).r, catalog.At(i).g, catalog.At(i).b)
: FB_SPAWN;
fb.FillRect(ox + int(sp.pos.x) * PX, oy + int(sp.pos.y) * PX, PX, PX, c);
}
fb.CircleFilled(ox + int(previewLevel.squadStart.x) * PX + PX / 2,
oy + int(previewLevel.squadStart.y) * PX + PX / 2, 3, FB_START);
return;
}
// --- изометрия комнаты ----------------------------------------------------
const Vec2 origin = v2d::WorldToScreen({float(room.w) * 0.5f, float(room.h) * 0.5f});
const int ox = CANVAS_W / 2 - int(std::lround(origin.x)) + panX;
const int oy = CANVAS_Y + CANVAS_H / 2 - int(std::lround(origin.y)) + panY;
auto screenOf = [&](int tx, int ty, int& sx, int& sy) {
const Vec2 s = v2d::WorldToScreen({float(tx) + 0.5f, float(ty) + 0.5f});
sx = int(std::lround(s.x)) + ox;
sy = int(std::lround(s.y)) + oy;
};
// Пол сплошным проходом, как в игре (render/scene.cpp).
for (int ty = 0; ty < room.h; ++ty)
for (int tx = 0; tx < room.w; ++tx)
{
if (room.At(tx, ty) == RoomCell::WALL) continue;
int sx = 0, sy = 0;
screenOf(tx, ty, sx, sy);
fb.Blit(tiles.Floor(tx, ty, room.theme), sx, sy);
if (room.At(tx, ty) == RoomCell::DOOR) FbDiamond(fb, sx, sy, FB_DOOR);
}
// Стены и маркеры — одним проходом по возрастанию глубины (tx + ty).
// Отдельные проходы дали бы тварь, нарисованную поверх стены перед ней.
for (int d = 0; d <= room.w + room.h; ++d)
for (int ty = 0; ty < room.h; ++ty)
{
const int tx = d - ty;
if (tx < 0 || tx >= room.w) continue;
int sx = 0, sy = 0;
screenOf(tx, ty, sx, sy);
if (room.At(tx, ty) == RoomCell::WALL)
{
fb.Blit(tiles.Wall(tx, ty, room.theme), sx, sy);
continue;
}
const SpawnPoint* p = room.SpawnAt(tx, ty);
if (!p) continue;
// Показываем самый вероятный вариант: точка со списком из пяти
// тварей всё равно поставит одну, и врать про это не надо.
int best = -1;
int bestW = 0;
for (const SpawnOption& o : p->options)
{
if (o.weight <= bestW) continue;
bestW = o.weight;
best = catalog.IndexOf(o.id);
}
if (const Sprite* spr = MarkerSprite(best)) fb.Blit(*spr, sx, sy);
FbDiamond(fb, sx, sy, (tx == selX && ty == selY) ? FB_SEL : FB_SPAWN);
}
if (hoverX >= 0)
{
int sx = 0, sy = 0;
screenOf(hoverX, hoverY, sx, sy);
FbDiamond(fb, sx, sy, FB_HOVER);
}
}
// -----------------------------------------------------------------------------
// Панели
// -----------------------------------------------------------------------------
void Editor::DrawUi(const ScreenRect& fit) const
{
const Layout l = Of(fit);
const Vector2 m = GetMousePosition();
const int u = (fit.scale > 0) ? fit.scale : 1;
const float mx = (m.x - float(fit.x)) / float(u);
const float my = (m.y - float(fit.y)) / float(u);
Box(l, EdRect{0, 0, INTERNAL_W, TOP_H}, COL_PANEL);
Box(l, EdRect{PANEL_X, TOP_H, PANEL_W, INTERNAL_H - TOP_H}, COL_PANEL);
const SpawnPoint* sel = room.SpawnAt(selX, selY);
if (sel) Box(l, EdRect{0, STRIP_Y, CANVAS_W, STRIP_H}, COL_PANEL);
for (const EdWidget& w : widgets)
{
if (w.act == Act::NONE)
{
Text(l, w.label.c_str(), w.rect.x + 1, w.rect.y + 2, 8, COL_FAINT);
continue;
}
const bool hot = w.rect.Has(mx, my);
if (w.active) Box(l, w.rect, COL_ON);
else if (hot) Box(l, w.rect, COL_HOVER);
BoxLines(l, w.rect, w.active ? COL_PICK : COL_EDGE);
const int tw = TextW(l, w.label.c_str(), 8);
Text(l, w.label.c_str(), w.rect.x + std::max(1, (w.rect.w - tw) / 2), w.rect.y + 2, 8,
w.active ? COL_PICK : COL_TEXT);
}
// --- верхняя панель: что за комната ---------------------------------------
char buf[160];
// Только имя и признак несохранённости: размер, тема, вес и глубина уже
// стоят в панели прямо под этой строкой, и дублировать их значит тратить
// единственное место, где помещается длинное имя файла.
std::snprintf(buf, sizeof(buf), "%s%s", room.name.c_str(), dirty ? "*" : "");
// Верхняя панель занята кнопками целиком, поэтому подпись комнаты живёт над
// правой панелью: там ширина известна и ни с чем не спорит.
Text(l, buf, PANEL_X + 3, TOP_H + 2, 8, dirty ? COL_WARN : COL_TEXT);
// --- значения счётчиков правой панели -------------------------------------
for (const EdWidget& w : widgets)
{
const char* value = nullptr;
char vbuf[16];
if (w.act == Act::W_DEC) { std::snprintf(vbuf, sizeof(vbuf), "%d", room.w); value = vbuf; }
else if (w.act == Act::H_DEC) { std::snprintf(vbuf, sizeof(vbuf), "%d", room.h); value = vbuf; }
else if (w.act == Act::WEIGHT_DEC)
{ std::snprintf(vbuf, sizeof(vbuf), "%d", room.weight); value = vbuf; }
else if (w.act == Act::DMIN_DEC)
{ std::snprintf(vbuf, sizeof(vbuf), "%d", room.minDepth); value = vbuf; }
else if (w.act == Act::DMAX_DEC)
{
// Ноль — «до бесконечности», и показывать его нулём было бы враньём:
// выглядит как «до нулевого этажа», то есть как выключённая комната.
if (room.maxDepth <= 0) value = "*";
else { std::snprintf(vbuf, sizeof(vbuf), "%d", room.maxDepth); value = vbuf; }
}
if (!value) continue;
Text(l, value, w.rect.x - 12, w.rect.y + 2, 8, COL_PICK);
}
// --- нижняя полоса --------------------------------------------------------
if (sel)
{
std::snprintf(buf, sizeof(buf), "SPAWN %d,%d", sel->x, sel->y);
Text(l, buf, strip::LABEL_X, STRIP_Y + 5, 8, COL_PICK);
Text(l, "CHANCE", strip::CHANCE_X, STRIP_Y + 5, 8, COL_FAINT);
std::snprintf(buf, sizeof(buf), "%.0f%%", double(sel->chance) * 100.0);
Text(l, buf, strip::VALUE_X, STRIP_Y + 5, 8, COL_TEXT);
std::snprintf(buf, sizeof(buf), "TOTAL %d", sel->TotalWeight());
Text(l, buf, strip::TOTAL_X, STRIP_Y + 5, 8, COL_FAINT);
// Вес каждого варианта — между его [-] и [+].
for (const EdWidget& w : widgets)
{
if (w.act != Act::OPT_DEC) continue;
const int wgt = sel->WeightOf(catalog.At(w.arg).id);
std::snprintf(buf, sizeof(buf), "%d", wgt);
Text(l, buf, w.rect.x + strip::BTN_W + 5, w.rect.y + 2, 8,
wgt > 0 ? COL_PICK : COL_FAINT);
}
}
else
{
Text(l, "LMB paints, RMB erases. 1-5 tools, Q/E palette, T theme,", 4, INTERNAL_H - 20, 8,
COL_FAINT);
Text(l, "[ ] rooms, Ctrl+S save, F5 level, F6 seed, PgUp/PgDn depth, arrows pan", 4,
INTERNAL_H - 10, 8, COL_FAINT);
}
// --- предпросмотр: цифры сборки -------------------------------------------
// Строки ниже стоят на разной высоте и не пересекаются намеренно: они все
// рисуются поверх холста, и наложившиеся друг на друга сообщения об ошибке
// читаются хуже, чем их отсутствие.
if (preview)
{
if (!previewError.empty())
{
Text(l, previewError.c_str(), 4, CANVAS_Y + 2, 8, COL_WARN);
}
else
{
const GeneratedLevel& p = previewLevel;
std::snprintf(buf, sizeof(buf), "D%d %s SEED %u rooms %d monsters %d(+%d) loot %d",
p.floor.depth, ThemeName(p.floor.theme), unsigned(seed), p.roomsPlaced,
p.monstersPlaced, p.monstersFilled, int(p.loot.size()));
Text(l, buf, 4, CANVAS_Y + 2, 8, COL_TEXT);
// Опасность — главная цифра этажа: по ней видно, успевает ли набор
// комнат за кривой сложности. Сильно меньше бюджета — не успевает.
std::snprintf(buf, sizeof(buf), "danger %.1f / %.1f exit %d steps floor %d tiles",
double(p.dangerSpent), double(p.floor.dangerBudget), p.exitDistance,
p.reachableTiles);
const bool starved = p.dangerSpent < p.floor.dangerBudget * 0.75f;
Text(l, buf, 4, CANVAS_Y + 12, 8, starved ? COL_WARN : COL_FAINT);
// Обе цифры — про то, что уровень собрался НЕ так, как задумано.
// Ноль их не рисует вовсе: постоянная строка "0 ошибок" перестаёт
// читаться через минуту работы.
// Обе беды в одну строку и справа от цифр: по отдельности они
// отнимали бы у карты ещё два ряда, а вместе они не встречаются.
if (previewLevel.spawnsDropped > 0 || previewLevel.spawnsUnreachable > 0)
{
std::snprintf(buf, sizeof(buf), "dropped %d (cap %d) unreachable %d",
previewLevel.spawnsDropped, MAX_LEVEL_SPAWNS,
previewLevel.spawnsUnreachable);
Text(l, buf, 4, CANVAS_Y + 22, 8, COL_WARN);
}
}
}
// --- строка состояния ------------------------------------------------------
// У самого низа: середину холста занимает то карта предпросмотра, то
// комната, и сообщение поверх них не читается ни само, ни вместе с ними.
if (statusTimer > 0.0f && !status.empty())
Text(l, status.c_str(), 4, INTERNAL_H - 30, 8, COL_OK);
// Комнаты, которые не читаются, обязаны быть видны: молча недосчитаться
// комнаты в наборе — это худший из возможных способов узнать об опечатке.
// Показываются только вне предпросмотра: там всё место занято картой.
if (preview) return;
int fy = CANVAS_Y + 4;
for (const std::string& f : lib.failures)
{
Text(l, f.c_str(), 4, fy, 8, COL_WARN);
fy += 10;
if (fy > STRIP_Y - 12) break;
}
}