squad-proto/editor/editor.cpp

1091 lines
47 KiB
C++
Raw Normal View History

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-14 01:07:19 +07:00
#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;
}
}