#include "editor/editor.h" #include #include #include #include #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 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; } }