1091 lines
47 KiB
C++
1091 lines
47 KiB
C++
|
|
#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;
|
|||
|
|
}
|
|||
|
|
}
|