diff --git a/CMakeLists.txt b/CMakeLists.txt index 401b6e9..6279a64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,30 +24,89 @@ if(NOT raylib_FOUND) endif() # --------------------------------------------------------------------------- -# Исходники +# SpriteForge: загрузчик .sfa — это один сторонний заголовок sfa.h из соседнего +# репозитория. Копий в игре нет; собранные ассеты и generated/ — отдельно. # --------------------------------------------------------------------------- -file(GLOB_RECURSE SQUAD_SOURCES CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") +set(SPRITEFORGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../spriteforge" CACHE PATH + "Каталог репозитория SpriteForge (нужен только его include/sfa.h)") -add_executable(squad_proto ${SQUAD_SOURCES}) +find_path(SPRITEFORGE_INCLUDE_DIR + NAMES sfa.h + HINTS "${SPRITEFORGE_DIR}/include" + NO_DEFAULT_PATH) -target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") -target_link_libraries(squad_proto PRIVATE raylib) - -if(MSVC) - target_compile_options(squad_proto PRIVATE /W4 /utf-8 /permissive-) -else() - target_compile_options(squad_proto PRIVATE -Wall -Wextra) +if(NOT SPRITEFORGE_INCLUDE_DIR) + message(FATAL_ERROR + "sfa.h не найден в ${SPRITEFORGE_DIR}/include.\n" + "Задать путь: cmake -S . -B build -DSPRITEFORGE_DIR=<путь к репозиторию spriteforge>") endif() +# --------------------------------------------------------------------------- +# Tile2D — движок; всё остальное стоит на нём. +# +# tile2d — engine/: софтверный фреймбуфер, проекции 2D, тайлмап, +# тайлсет с темами, формат комнат, генератор уровней, +# каталог сущностей, формат проекта. Про правила боя, +# оружие и отряд не знает ничего. +# tile2d_editor — editor/: редактор комнат. Инструмент ДВИЖКА: открывает +# любой проект, а не один конкретный. +# squad_proto — src/: игра «Descent», один из проектов движка. +# +# Граница проведена ЛИНКОВКОЙ, а не договорённостью: ни движок, ни редактор не +# видят ни одного символа из sim/ и ai/, поэтому «редактор полез в правила +# игры» — ошибка сборки, а не то, что надо заметить на ревью. +# +# Раньше состав движка задавался списком файлов: каталоги src/render и src/world +# делили обе цели, и без списка граница расползлась бы при первом новом .cpp. +# Теперь у движка СВОЙ каталог, и решение «это движок» принимается тем, куда +# положен файл. Список стал бы вторым источником истины о том же самом. +# --------------------------------------------------------------------------- +file(GLOB ENGINE_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/engine/*.cpp") +file(GLOB EDITOR_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/editor/*.cpp") +file(GLOB_RECURSE GAME_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") + +add_library(tile2d STATIC ${ENGINE_SOURCES}) + +target_include_directories(tile2d PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" # engine/..., editor/... + "${CMAKE_CURRENT_SOURCE_DIR}/generated" # spriteforge_assets.h, `sf codegen` + "${SPRITEFORGE_INCLUDE_DIR}") +target_link_libraries(tile2d PUBLIC raylib) + +add_executable(squad_proto ${GAME_SOURCES}) +target_include_directories(squad_proto PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_link_libraries(squad_proto PRIVATE tile2d) + +add_executable(tile2d_editor ${EDITOR_SOURCES}) +target_link_libraries(tile2d_editor PRIVATE tile2d) + +# Пример. Он здесь не «для демонстрации»: это ВТОРОЙ потребитель движка помимо +# игры, и только он ловит случай, когда что-то общее незаметно уехало в src/. +# Игра и редактор такого не заметят — редактор не рисует мир, а игра сама и есть +# то место, куда уезжает. +file(GLOB SAMPLE_WALK_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/samples/walk/*.cpp") +add_executable(tile2d_walk ${SAMPLE_WALK_SOURCES}) +target_link_libraries(tile2d_walk PRIVATE tile2d) + +foreach(target tile2d squad_proto tile2d_editor tile2d_walk) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /utf-8 /permissive-) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra) + endif() +endforeach() + if(WIN32) - target_link_libraries(squad_proto PRIVATE winmm) + target_link_libraries(tile2d PUBLIC winmm) endif() -# Единый каталог для бинарника при любом генераторе (VS/Ninja/Makefiles) -set_target_properties(squad_proto PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" - RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin" - RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" - RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" - RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin") +# Единый каталог для бинарников при любом генераторе (VS/Ninja/Makefiles) +foreach(target squad_proto tile2d_editor tile2d_walk) + set_target_properties(${target} PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin") +endforeach() diff --git a/editor/editor.cpp b/editor/editor.cpp new file mode 100644 index 0000000..e089188 --- /dev/null +++ b/editor/editor.cpp @@ -0,0 +1,1090 @@ +#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; + } +} diff --git a/editor/editor.h b/editor/editor.h new file mode 100644 index 0000000..289afa9 --- /dev/null +++ b/editor/editor.h @@ -0,0 +1,175 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Редактор комнат — ОТДЕЛЬНОЕ приложение поверх движка. +// +// Он линкуется только с tile2d и не видит ни одного символа из sim/ и ai/. +// Поэтому «редактор знает про правила боя» — это ошибка линковки, а не то, что +// надо ловить на ревью. +// +// Ни одной игры по имени он тоже не знает: где содержимое, говорит проект +// (engine/project.h), а что можно ставить в комнату — его каталог сущностей. +// +// Рисует он ровно тем же софтверным фреймбуфером и той же ПРОЕКЦИЕЙ, что и +// игра: комната в редакторе выглядит так же, как в бою, потому что это один и +// тот же код, а не похожий. В проекте с видом сверху редактор тоже показывает +// вид сверху — иначе он планировал бы не то, что получится. +// +// Раскладка кадра 480x270: +// y 0..11 верхняя панель: файл, операции над комнатами +// x 0..379 холст: изометрия комнаты (или карта предпросмотра уровня) +// x 380..479 правая панель: кисти, свойства комнаты, палитра +// y 232..269 нижняя полоса поверх холста: таблица выбранной точки спавна +// ----------------------------------------------------------------------------- +#include +#include + +#include "engine/math.h" +#include "engine/catalog.h" +#include "engine/level_gen.h" +#include "engine/project.h" +#include "engine/room.h" +#include "engine/tileset.h" +#include "engine/framebuffer.h" +#include "engine/sfa_library.h" + +// Кисть. Пол, стена и дверь пишут в сетку; спавн ставит точку; ластик снимает +// и то и другое — иначе «убрать» требовало бы помнить, чем это поставлено. +enum class Tool : uint8_t +{ + FLOOR = 0, + WALL, + DOOR, + SPAWN, + ERASE, + COUNT, +}; + +// Что делает элемент интерфейса. Действие, а не «номер кнопки»: раскладка +// панелей меняется чаще, чем набор операций. +enum class Act : uint8_t +{ + NONE = 0, + TOOL, PALETTE, + ROOM_PREV, ROOM_NEXT, ROOM_NEW, ROOM_SAVE, ROOM_RELOAD, + W_DEC, W_INC, H_DEC, H_INC, + WEIGHT_DEC, WEIGHT_INC, THEME_CYCLE, START_TOGGLE, + DMIN_DEC, DMIN_INC, DMAX_DEC, DMAX_INC, + PALETTE_TAB, + CHANCE_DEC, CHANCE_INC, OPT_DEC, OPT_INC, + PREVIEW_TOGGLE, PREVIEW_RESEED, PREVIEW_DEEPER, PREVIEW_SHALLOWER, PLAYTEST, +}; + +struct EdRect +{ + int x = 0, y = 0, w = 0, h = 0; + bool Has(float fx, float fy) const + { + return fx >= float(x) && fy >= float(y) && fx < float(x + w) && fy < float(y + h); + } +}; + +struct EdWidget +{ + Act act = Act::NONE; + int arg = 0; // номер кисти, индекс каталога, ... + EdRect rect; + std::string label; + bool active = false; // включённое состояние (нажатая кисть) + bool enabled = true; +}; + +struct Editor +{ + // --- данные --------------------------------------------------------------- + // Проект — первое, что поднимается: из него берутся пути к содержимому, + // вид отображения и кривая глубины. Редактор не знает ни одной игры по + // имени и открывает тот проект, который ему назвали. + Project project; + EntityCatalog catalog; + RoomLibrary lib; + TileSet tiles; + SpriteLibrary forge; + bool forgeOpen = false; + + // Рабочая копия правится, а lib.rooms остаётся тем, что лежит на диске: + // без этого «не сохранять» было бы нечем осуществить. + Room room; + int roomIndex = -1; + bool dirty = false; + + // --- инструменты ---------------------------------------------------------- + Tool tool = Tool::WALL; + int paletteIndex = 0; + // Каталог вырос до трёх десятков записей и в столбик больше не помещается. + // Вкладки по классу — не украшение: твари, обстановка и лут ставятся в + // разные моменты работы, и мешать их в одном списке значит листать чужое. + EntityClass paletteTab = EntityClass::MONSTER; + int paletteScroll = 0; + int selX = -1, selY = -1; // выбранная точка спавна; -1 — не выбрана + + // --- вид ------------------------------------------------------------------ + int panX = 0, panY = 0; // сдвиг холста в пикселях + int hoverX = -1, hoverY = -1; + bool painting = false; // ЛКМ зажата: кисть тянется + bool erasing = false; + + // --- предпросмотр этажа --------------------------------------------------- + bool preview = false; + uint32_t seed = 1; + // Какой этаж собирается в предпросмотре. Отдельно от диапазона комнаты: + // проверять надо не только «попадает ли комната», но и «во что превращается + // весь набор» на этой глубине. + int previewDepth = 1; + Tilemap previewMap; + GeneratedLevel previewLevel; + std::string previewError; + + // --- строка состояния ----------------------------------------------------- + std::string status; + float statusTimer = 0.0f; + + std::vector widgets; + + // Поднять проект, каталог, набор комнат и тайлы. false — работать не с чем + // (нет проекта или каталога сущностей), причина уходит в status. + // projectName — имя или путь; пусто — единственный найденный проект. + bool Init(const std::string& projectName = {}); + + // Ввод и действия. fit — куда лёг фреймбуфер в окне. + void Update(const ScreenRect& fit, float dt); + + // Комната (или карта предпросмотра) в софтверный буфер. + void DrawWorld(Framebuffer& fb); + + // Панели поверх готового кадра: примитивы raylib, потому что здесь нужны + // слова, и много. + void DrawUi(const ScreenRect& fit) const; + + // Выполнить действие интерфейса. Публично, потому что через него же + // работают ключи командной строки: «показать сборку уровня» с клавиши и + // из аргумента обязаны делать буквально одно и то же. + void Apply(Act act, int arg); + +private: + // u — целый масштаб кадра в окне. Нужен, чтобы мерить подписи ТЕМ ЖЕ + // шрифтом и кеглем, каким они будут нарисованы: у встроенного шрифта raylib + // межбуквенный интервал зависит от размера, и посчитанная «по пять пикселей + // на букву» ширина кнопки разъезжается с настоящей. + void BuildWidgets(int u); + + void LoadRoomAt(int index); + void SaveCurrent(); + void ReloadLibrary(); + void NewRoom(); + + void PaintAt(int x, int y, bool erase); + void Regenerate(); + void Say(const std::string& s); + + // Спрайт-маркер сущности каталога: кадр из SpriteForge, если он собран, + // иначе капсула по цвету и радиусу. Пустая палитра не должна мешать + // планировать уровень. + const Sprite* MarkerSprite(int catalogIndex) const; + + mutable std::vector markers; // кеш, по индексу каталога +}; diff --git a/editor/main.cpp b/editor/main.cpp new file mode 100644 index 0000000..9bd9e34 --- /dev/null +++ b/editor/main.cpp @@ -0,0 +1,401 @@ +// ----------------------------------------------------------------------------- +// Точка входа редактора комнат. Окно, цикл, финальный блит — и всё. +// +// Приложение отдельное от игры намеренно: редактор линкуется только с tile2d, +// поэтому обратиться к правилам боя он физически не может. Общее у него с игрой +// ровно то, что и должно быть общим, — движок. +// +// Редактор принадлежит ДВИЖКУ, а не игре: он открывает любой проект и ни одной +// игры по имени не знает. +// +// Ключи: +// --windowed не разворачивать окно на весь экран +// --room <имя> открыть эту комнату сразу +// --select N выбрать N-ю точку спавна: иначе панель таблицы весов +// не снять на картинку — она появляется по клику мышью +// --level [сид] сразу показать сборку этажа из набора +// --depth N какой этаж собирать в предпросмотре +// --shot [к] [файл] снять кадр и выйти (для картинок в документации) +// --check проверить набор комнат без окна и выйти +// --project <имя> какой проект открыть (имя каталога в projects/ или путь) +// --tool <команда> инструменты без окна: room, floor, map, validate, ... +// (docs/19-agent.md; --tool help покажет все) +// --term "<кмд>" открыть терминал и сразу выполнить в нём команду +// +// В самом редакторе снизу живёт терминал (клавиша `): те же инструменты, git, +// сборка и запросы к claude/codex прямо из редактора (docs/19-agent.md). +// ----------------------------------------------------------------------------- +#include + +#include +#include +#include +#include + +#include "engine/app.h" +#include "editor/editor.h" +#include "editor/terminal.h" +#include "editor/tools.h" +#include "engine/level_gen.h" +#include "engine/project.h" +#include "engine/framebuffer.h" +#include "engine/config.h" + +namespace +{ + +// ----------------------------------------------------------------------------- +// --check: проверка набора без окна. Это аналог `squad_proto --accept` для +// содержимого: набор комнат — такой же исходник, как код, и ломаться он обязан +// на проверке, а не на глазах у игрока. +// +// Круговой прогон через запись здесь не для полноты, а потому что записывающий +// путь — единственный, которым редактор может УНИЧТОЖИТЬ работу. Читающий +// проверяется сам собой при каждом запуске; пишущий — только здесь. +// ----------------------------------------------------------------------------- +bool SameRoom(const Room& a, const Room& b) +{ + if (a.name != b.name || a.w != b.w || a.h != b.h) return false; + if (a.weight != b.weight || a.theme != b.theme || a.start != b.start) return false; + + for (int y = 0; y < a.h; ++y) + for (int x = 0; x < a.w; ++x) + if (a.At(x, y) != b.At(x, y)) return false; + + if (a.spawns.size() != b.spawns.size()) return false; + for (const SpawnPoint& p : a.spawns) + { + const SpawnPoint* q = b.SpawnAt(p.x, p.y); + if (!q) return false; + // Шанс пишется с двумя знаками после точки, поэтому сравнивать его + // на равенство нельзя: 0.333 вернётся как 0.33, и это не ошибка. + if (std::fabs(p.chance - q->chance) > 0.006f) return false; + if (p.options.size() != q->options.size()) return false; + for (const SpawnOption& o : p.options) + if (q->WeightOf(o.id) != o.weight) return false; + } + return true; +} + +int RunCheck(const char* projectName) +{ + std::string why; + + // Проект — первым: без него неизвестно ни где содержимое, ни по какой + // кривой проверять глубины. У движка проектов несколько, и проверять + // каждый надо ЕГО кривой, а не какой-то одной общей. + Project project; + const std::string projectDir = FindProjectDir(projectName ? projectName : ""); + if (projectDir.empty() || !project.Load(projectDir, &why)) + { + // «Не найден» и «их несколько» — разные беды с одинаковым исходом, и + // угадывать за игрока нельзя ни в том, ни в другом случае. Список + // доступных отвечает на оба вопроса разом. + std::printf("[FAIL] проект: %s\n", + projectDir.empty() ? "не выбран (не найден или их несколько)" : why.c_str()); + for (const std::string& n : AvailableProjects()) + std::printf(" доступен: --project %s\n", n.c_str()); + return 1; + } + project.Apply(); + std::printf("проект: %s (%s), вид %s\n", project.name.c_str(), project.dir.c_str(), + ViewModeName(project.view)); + + EntityCatalog catalog; + if (!catalog.Load(project.catalogPath, &why)) + { + std::printf("[FAIL] каталог: %s\n", why.c_str()); + return 1; + } + std::printf("каталог: %s, записей %d\n", project.catalogPath.c_str(), catalog.Count()); + + RoomLibrary lib; + if (!lib.Load(project.roomsDir, &why)) + { + std::printf("[FAIL] набор комнат: %s\n", why.c_str()); + return 1; + } + + int bad = 0; + int warn = 0; + for (const std::string& f : lib.failures) + { + std::printf("[FAIL] %s\n", f.c_str()); + ++bad; + } + + const std::filesystem::path tmp = + std::filesystem::temp_directory_path() / "tile2d_roundtrip.room"; + + // Заголовок латиницей: ширина в printf считается в БАЙТАХ, и кириллица в + // UTF-8 разъезжается со столбцами ровно вдвое. + std::printf("%-14s %6s %5s %6s %6s %7s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME", + "DEPTH", "STATE"); + for (const Room& r : lib.rooms) + { + std::string state = RoomProblem(r); + + // Записали -> прочитали -> сравнили. Расхождение означает, что кнопка + // SAVE в редакторе тихо теряет часть комнаты. + Room back; + if (state.empty()) + { + if (!SaveRoom(tmp.string(), r, &why)) state = "save failed: " + why; + else if (!LoadRoom(tmp.string(), back, &why)) state = "reload failed: " + why; + else if (!SameRoom(r, back)) state = "roundtrip mismatch"; + } + + // id, которого нет в каталоге, — это точка, которая не даст никого. + for (const SpawnPoint& p : r.spawns) + for (const SpawnOption& o : p.options) + if (catalog.IndexOf(o.id) < 0 && state.empty()) + state = "unknown id `" + o.id + "`"; + + if (!state.empty()) ++bad; + + char depthBuf[16]; + if (r.maxDepth > 0) std::snprintf(depthBuf, sizeof(depthBuf), "%d-%d", r.minDepth, r.maxDepth); + else std::snprintf(depthBuf, sizeof(depthBuf), "%d+", r.minDepth); + + std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %s\n", r.name.c_str(), r.w, r.h, + r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme), depthBuf, + state.empty() ? "OK" : ("[FAIL] " + state).c_str()); + } + std::error_code ec; + std::filesystem::remove(tmp, ec); + + if (!lib.HasStartRoom()) + { + std::printf("[FAIL] в наборе нет ни одной стартовой комнаты\n"); + ++bad; + } + + // ------------------------------------------------------------------------- + // Покрытие глубин. Главный вопрос игры про бесконечный спуск: не «хороша ли + // комната», а «есть ли чем застроить сотый этаж». Набор, у которого на + // глубине не остаётся ни одной подходящей комнаты или ни одной твари, + // обрывает забег — и обрывает молча, если это не проверять. + // + // Проверяется с запасом за последнюю новинку каталога: дальше неё этажи + // собираются из того же набора, и если он там держится, то держится и на + // любой глубине. + // ------------------------------------------------------------------------- + const int probeMax = std::max(catalog.DeepestIntroduction(), 12) + 4; + std::printf("\nпокрытие глубин 1..%d\n", probeMax); + for (int d = 1; d <= probeMax; ++d) + { + const int roomsHere = lib.CountAt(d); + const bool startHere = lib.HasStartRoomAt(d); + const bool monsters = catalog.HasAnyAt(EntityClass::MONSTER, d); + const bool loot = catalog.HasAnyAt(EntityClass::LOOT, d); + + if (roomsHere > 0 && startHere && monsters && loot) continue; + + std::printf("[FAIL] этаж %d:%s%s%s%s\n", d, roomsHere > 0 ? "" : " нет комнат", + startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей", + loot ? "" : " нет лута"); + ++bad; + } + + // Сборка на нескольких сидах И глубинах: комната может быть безупречной + // сама по себе и при этом никогда не стыковаться с соседями, а набор — + // перестать выбирать бюджет опасности на глубине. + Tilemap map; + GeneratedLevel lvl; + std::printf("\n%-6s %-6s %-5s %7s %8s %6s %5s %s\n", "DEPTH", "SEED", "ROOMS", "MONSTER", + "DANGER", "LOOT", "EXIT", "STATE"); + for (const int d : {1, 3, 6, 10, 16, 24}) + { + for (const uint32_t seed : {1u, 42u}) + { + const FloorSpec spec = DescribeFloor(d); + if (!GenerateLevel(lib, catalog, spec, seed, map, lvl, &why)) + { + std::printf("[FAIL] этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str()); + ++bad; + continue; + } + + // Авария и сигнал — разные вещи, и мешать их нельзя. + // + // FAIL — этаж собран НЕПРАВИЛЬНО: точки не встали, что-то + // отрезано от старта, некуда спускаться, нет находок; + // WARN — этаж собран правильно, но набор комнат не успевает за + // кривой сложности, и глубина перестаёт ощущаться. Это + // повод дорисовать комнат, а не поломка. + const bool starved = lvl.dangerSpent < spec.dangerBudget * 0.75f; + const bool broken = lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 || + lvl.loot.empty() || lvl.exitDistance <= 0; + if (broken) ++bad; + else if (starved) ++warn; + + std::printf("%-6d %-6u %5d %7d %4.0f/%-3.0f %6d %5d %s\n", d, unsigned(seed), + lvl.roomsPlaced, lvl.monstersPlaced, double(lvl.dangerSpent), + double(spec.dangerBudget), int(lvl.loot.size()), lvl.exitDistance, + broken ? "[FAIL]" : (starved ? "[WARN] набор отстаёт от кривой" : "OK")); + } + } + + std::printf(bad == 0 ? "\nИТОГ: набор в порядке\n" : "\nИТОГ: замечаний %d\n", bad); + return bad == 0 ? 0 : 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + bool windowed = false; + const char* openRoom = nullptr; + int selectSpawn = -1; + bool showLevel = false; + int previewDepth = 0; // 0 — оставить тот, что в редакторе по умолчанию + uint32_t levelSeed = 1; + int shotFrame = -1; + const char* shotFile = "editor.png"; + const char* projectName = nullptr; + // --term "<команда>": открыть терминал и сразу что-то в нём выполнить. + // Нужен для картинок в документации и для запуска редактора «с делом»: + // открыл — и уже видно, что не сходится. + const char* termCmd = nullptr; + + // Имя проекта разбирается ПЕРВЫМ проходом: --check возвращается из цикла + // сразу, и написанный после него --project иначе бы не действовал. + for (int i = 1; i + 1 < argc; ++i) + if (std::strcmp(argv[i], "--project") == 0) projectName = argv[i + 1]; + + // --tool отдаёт весь хвост командной строки инструментам и выходит. Окна + // здесь тоже нет: этими командами работает не человек с мышью, а скрипт + // или языковая модель (docs/19-agent.md). + for (int i = 1; i < argc; ++i) + if (std::strcmp(argv[i], "--tool") == 0) return RunTool(argc - i - 1, argv + i + 1); + + for (int i = 1; i < argc; ++i) + { + // Проверка идёт без окна и до всего остального: её запускают из + // консоли и из сборки, где никакого дисплея может не быть вовсе. + if (std::strcmp(argv[i], "--check") == 0) return RunCheck(projectName); + else if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) ++i; + else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true; + else if (std::strcmp(argv[i], "--term") == 0 && i + 1 < argc) termCmd = argv[++i]; + else if (std::strcmp(argv[i], "--room") == 0 && i + 1 < argc) openRoom = argv[++i]; + else if (std::strcmp(argv[i], "--select") == 0 && i + 1 < argc) + selectSpawn = TextToInteger(argv[++i]); + else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc) + previewDepth = TextToInteger(argv[++i]); + else if (std::strcmp(argv[i], "--level") == 0) + { + showLevel = true; + if (i + 1 < argc && argv[i + 1][0] != '-') + levelSeed = uint32_t(std::strtoul(argv[++i], nullptr, 10)); + } + else if (std::strcmp(argv[i], "--shot") == 0) + { + shotFrame = (i + 1 < argc && argv[i + 1][0] != '-') ? TextToInteger(argv[i + 1]) : 30; + if (i + 2 < argc && argv[i + 2][0] != '-') shotFile = argv[i + 2]; + } + } + + // Окно и кадр — движковые (engine/app.h). Esc здесь обрабатывается вручную: + // пока открыт терминал, он закрывает терминал, а не редактор — отдать Esc + // raylib значило бы терять набранную команду вместе со всем приложением. + App app; + AppConfig cfg; + cfg.title = "Tile2D — редактор комнат"; + cfg.borderless = !windowed; + if (!app.Init(cfg)) return 1; + + // Редактор большой (кеш маркеров, набор комнат, карта предпросмотра) — на куче. + static Editor editor; + const bool ready = editor.Init(projectName ? projectName : ""); + if (!ready) std::printf("редактор: %s\n", editor.status.c_str()); + + if (openRoom) + { + const int i = editor.lib.IndexOf(openRoom); + if (i >= 0) editor.room = editor.lib.rooms[size_t(i)], editor.roomIndex = i; + else std::printf("нет такой комнаты: %s\n", openRoom); + } + + if (selectSpawn >= 0 && selectSpawn < int(editor.room.spawns.size())) + { + editor.selX = editor.room.spawns[size_t(selectSpawn)].x; + editor.selY = editor.room.spawns[size_t(selectSpawn)].y; + } + + if (showLevel && ready) + { + editor.seed = levelSeed; + if (previewDepth > 0) editor.previewDepth = previewDepth; + editor.Apply(Act::PREVIEW_TOGGLE, 0); + } + + // Терминал работает в КОРНЕ РЕПОЗИТОРИЯ, а не в каталоге проекта: работают + // не только над содержимым — рядом код движка, документация и git. + static Terminal term; + { + std::error_code ec; + std::filesystem::path root = ready ? std::filesystem::path(editor.project.dir) : "."; + if (ready) root = root.parent_path().parent_path(); // projects/<имя> -> корень + if (root.empty()) root = "."; + root = std::filesystem::absolute(root, ec).lexically_normal(); + + // Имя проекта, а не путь: по нему инструменты найдут его сами, откуда + // бы их ни запустили. + const std::string pname = + ready ? std::filesystem::path(editor.project.dir).filename().string() : std::string(); + term.Init(root.string(), pname); + } + if (termCmd) + { + term.open = true; + term.RunShellFromArgv(termCmd); + } + + while (app.NextFrame()) + { + // Терминал забирает ввод целиком, пока открыт: иначе набранная в строке + // `w` вставала бы кистью стены на холст. + const bool termAte = term.Update(app.winW, app.winH, app.frameDt); + if (!termAte && IsKeyPressed(KEY_ESCAPE)) app.quit = true; + + if (ready) + { + if (!termAte) editor.Update(app.fit, app.frameDt); + editor.DrawWorld(app.fb); + } + else + { + app.fb.Clear(RGBA(9, 9, 11)); + } + + app.Present(); + if (ready) + { + editor.DrawUi(app.fit); + } + else + { + // Без каталога сущностей ставить в комнату нечего, и делать вид, + // что редактор работает, нельзя. + DrawText("no project (see projects/*/project.txt)", app.fit.x + 16, app.fit.y + 16, + 8 * app.fit.scale, Color{198, 84, 66, 255}); + DrawText(editor.status.c_str(), app.fit.x + 16, app.fit.y + 32, 8 * app.fit.scale, + Color{206, 200, 190, 255}); + } + // Терминал рисуется ПОВЕРХ всего и в пикселях окна, а не кадра: это + // инструмент, а не часть пиксельной картинки, и строк в нём должно + // помещаться столько, сколько позволяет монитор. + term.Draw(app.winW, app.winH); + app.Finish(); + + if (shotFrame >= 0 && --shotFrame <= 0) + { + app.Screenshot(shotFile); + break; + } + } + + term.Shutdown(); + app.Shutdown(); + return ready ? 0 : 1; +} diff --git a/editor/terminal.cpp b/editor/terminal.cpp new file mode 100644 index 0000000..0addb6e --- /dev/null +++ b/editor/terminal.cpp @@ -0,0 +1,682 @@ +#include "editor/terminal.h" + +#include + +#include +#include + +#include "engine/framebuffer.h" + +#ifdef _WIN32 +// raylib и Win32 объявляют CloseWindow, ShowCursor и DrawTextEx каждый по-своему, +// и в одном файле они не уживаются. NOGDI и NOUSER выкидывают из windows.h +// именно те две части, где это происходит: рисование и оконный интерфейс нам +// отсюда не нужны — нужны процессы и каналы. +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#define NOGDI +#define NOUSER +#include +#endif + +namespace +{ + +// Сколько строк держим. Не «на всякий случай»: прогон --tool map печатает +// полсотни строк, сборка — сотни, и без потолка панель съедала бы память тем +// быстрее, чем активнее ей пользуются. +constexpr size_t SCROLLBACK_MAX = 4000; + +constexpr int FONT_SIZE = 16; +constexpr int LINE_H = 18; +constexpr int PAD = 8; +constexpr int BTN_H = 20; + +const Color COL_BG = {10, 10, 13, 255}; // непрозрачно: подложка редактора не должна просвечивать +const Color COL_EDGE = {52, 50, 56, 255}; +const Color COL_TEXT = {198, 196, 190, 255}; +const Color COL_DIM = {112, 110, 106, 255}; +const Color COL_ECHO = {150, 190, 230, 255}; // что мы сами запустили +const Color COL_ERR = {214, 108, 88, 255}; +const Color COL_OK = {130, 200, 140, 255}; +const Color COL_BTN = {26, 26, 31, 255}; +const Color COL_BTN_HI = {74, 44, 36, 255}; + +struct Button +{ + const char* label; + const char* cmd; // что запустить; nullptr — особое действие + int x = 0, w = 0; +}; + +// Кнопки — это не «удобство», а список того, что при работе с моделью +// запускают чаще всего. Каждая из них — команда, которую можно набрать руками; +// ничего, кроме экономии нажатий, они не делают. +Button BUTTONS[] = { + {"VALIDATE", "tile2d_editor --tool validate", 0, 0}, + {"ROOMS", "tile2d_editor --tool rooms", 0, 0}, + {"FLOOR", "tile2d_editor --tool floor 6", 0, 0}, + {"MAP", "tile2d_editor --tool map 6", 0, 0}, + {"CURVE", "tile2d_editor --tool curve 1 12", 0, 0}, + {"PLAY", "squad_proto --rooms", 0, 0}, + {"STOP", nullptr, 0, 0}, + {"EXTERNAL", nullptr, 0, 0}, +}; +constexpr int BUTTON_COUNT = int(sizeof(BUTTONS) / sizeof(BUTTONS[0])); + +// --- UTF-8 ------------------------------------------------------------------- +// Ввод у raylib приходит кодовыми точками, вывод процессов — байтами UTF-8. +// Между ними нужен перевод, иначе набранная кириллица уедет в мусор. +void AppendUtf8(std::string& s, int cp) +{ + if (cp < 0x80) s += char(cp); + else if (cp < 0x800) + { + s += char(0xC0 | (cp >> 6)); + s += char(0x80 | (cp & 0x3F)); + } + else if (cp < 0x10000) + { + s += char(0xE0 | (cp >> 12)); + s += char(0x80 | ((cp >> 6) & 0x3F)); + s += char(0x80 | (cp & 0x3F)); + } +} + +// Backspace обязан снимать СИМВОЛ, а не байт: иначе от кириллической буквы +// остаётся половина, и строка перестаёт быть валидным UTF-8. +void PopUtf8(std::string& s) +{ + while (!s.empty() && (uint8_t(s.back()) & 0xC0) == 0x80) s.pop_back(); + if (!s.empty()) s.pop_back(); +} + +// Убрать управляющие последовательности ANSI. Мы не эмулятор терминала: +// цвета и адресация курсора нам не нужны, а мусор вида `ESC[0m` в строке — +// нужен ещё меньше. +std::string StripAnsi(const std::string& in) +{ + std::string out; + out.reserve(in.size()); + for (size_t i = 0; i < in.size(); ++i) + { + const char c = in[i]; + if (c == '\x1B') + { + if (i + 1 < in.size() && in[i + 1] == '[') + { + i += 2; + while (i < in.size() && !(in[i] >= '@' && in[i] <= '~')) ++i; + } + else + { + ++i; // односимвольная последовательность + } + continue; + } + if (c == '\r') continue; + if (c == '\t') { out += " "; continue; } + if (uint8_t(c) < 0x20) continue; + out += c; + } + return out; +} + +std::string Quote(const std::string& s) +{ + std::string out = "\""; + for (const char c : s) + { + if (c == '"' || c == '\\') out += '\\'; + out += c; + } + out += '"'; + return out; +} + +// --- состояние, которому нужны windows.h и raylib ---------------------------- +struct Impl +{ + Font font{}; + bool fontLoaded = false; + std::string pending; // недобранный хвост строки + +#ifdef _WIN32 + HANDLE proc = nullptr; + HANDLE outRead = nullptr; +#endif +}; + +Impl g_impl; + +// Шрифт с кириллицей. Встроенный шрифт raylib её не знает, и вывод модели, +// которая отвечает по-русски, превратился бы в частокол вопросов. Правило +// «тексты на экране латиницей» здесь не нарушается: оно про ИГРУ, где текст +// пишет разработчик, а тут текст приходит извне и выбора нет. +void EnsureFont() +{ + if (g_impl.fontLoaded) return; + g_impl.fontLoaded = true; + + std::vector cps; + for (int c = 32; c < 127; ++c) cps.push_back(c); + for (int c = 0x0400; c <= 0x045F; ++c) cps.push_back(c); + cps.push_back(0x2014); // тире + cps.push_back(0x2026); // многоточие + cps.push_back(0x00AB); + cps.push_back(0x00BB); + + static const char* const CANDIDATES[] = { + "C:/Windows/Fonts/consola.ttf", + "C:/Windows/Fonts/cour.ttf", + "C:/Windows/Fonts/lucon.ttf", + }; + for (const char* path : CANDIDATES) + { + if (!FileExists(path)) continue; + g_impl.font = LoadFontEx(path, FONT_SIZE * 2, cps.data(), int(cps.size())); + if (g_impl.font.texture.id != 0) + { + SetTextureFilter(g_impl.font.texture, TEXTURE_FILTER_BILINEAR); + return; + } + } + g_impl.font = GetFontDefault(); // латиница есть, и на том спасибо +} + +void DrawLine(const char* text, int x, int y, Color c) +{ + DrawTextEx(g_impl.font, text, Vector2{float(x), float(y)}, float(FONT_SIZE), 1.0f, c); +} + +int MeasureLine(const char* text) +{ + return int(MeasureTextEx(g_impl.font, text, float(FONT_SIZE), 1.0f).x); +} + +// Разбить строку на куски по cols символов. Ответ модели — это абзац на +// несколько экранных ширин, и без переноса он просто уезжает за правый край: +// прочитать нельзя, а что текст оборван — не видно. +// +// Считаем СИМВОЛЫ, а не байты: кириллица в UTF-8 занимает два байта, и разрез +// по байтам оставил бы половину буквы, то есть сломанный UTF-8 в середине. +void WrapInto(const std::string& s, int cols, std::vector& out) +{ + if (cols < 8) cols = 8; + + size_t i = 0; + while (i < s.size()) + { + size_t j = i; + int chars = 0; + while (j < s.size() && chars < cols) + { + ++j; + while (j < s.size() && (uint8_t(s[j]) & 0xC0) == 0x80) ++j; // хвост символа + ++chars; + } + out.push_back(s.substr(i, j - i)); + i = j; + } + if (s.empty()) out.push_back(std::string()); +} + +} // namespace + +const char* AskModeName(AskMode m) +{ + switch (m) + { + case AskMode::SHELL: return "shell"; + case AskMode::CLAUDE: return "claude"; + case AskMode::CODEX: return "codex"; + default: return "?"; + } +} + +void Terminal::Init(const std::string& dir, const std::string& project) +{ + workDir = dir; + projectName = project; + EnsureFont(); + Print("Tile2D terminal. Tab — режим (shell/claude/codex), Enter — выполнить."); + if (!projectName.empty()) Print("проект " + projectName + " (TILE2D_PROJECT), каталог " + workDir); + Print("`--tool help` покажет инструменты движка; F12 — интерактивный CLI отдельным окном."); +} + +void Terminal::Shutdown() +{ + Stop(); +} + +bool Terminal::Running() const +{ +#ifdef _WIN32 + return g_impl.proc != nullptr; +#else + return false; +#endif +} + +void Terminal::Print(const std::string& s) +{ + lines.push_back(s); + if (lines.size() > SCROLLBACK_MAX) + lines.erase(lines.begin(), lines.begin() + int(lines.size() - SCROLLBACK_MAX)); + scroll = 0; // новая строка возвращает к концу: иначе вывод «не идёт» +} + +int Terminal::PanelTop(int windowH) const +{ + if (!open) return windowH; + return windowH - std::max(160, windowH * 2 / 5); +} + +// ----------------------------------------------------------------------------- +// Процесс +// ----------------------------------------------------------------------------- +#ifdef _WIN32 + +namespace +{ + +void CloseChild() +{ + if (g_impl.proc) { CloseHandle(g_impl.proc); g_impl.proc = nullptr; } + if (g_impl.outRead) { CloseHandle(g_impl.outRead); g_impl.outRead = nullptr; } +} + +} // namespace + +void Terminal::RunShell(const std::string& cmd) +{ + if (cmd.empty()) return; + if (Running()) + { + Print("занято: команда уже выполняется (STOP прервёт)"); + return; + } + + Print("$ " + cmd); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE outWrite = nullptr; + if (!CreatePipe(&g_impl.outRead, &outWrite, &sa, 0)) + { + Print("не удалось создать канал"); + CloseChild(); + return; + } + // Наш конец канала дочернему процессу не наследуется: иначе он держал бы + // его открытым, и признак конца вывода не пришёл бы никогда. + SetHandleInformation(g_impl.outRead, HANDLE_FLAG_INHERIT, 0); + + // Ввод — устройство NUL, а не канал. Канал, в который никто никогда не + // пишет, выглядит для потомка как «данные вот-вот придут»: claude честно + // ждёт три секунды и печатает предупреждение. Пустой ввод — это EOF, и + // сказать это надо сразу. + HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, 0, nullptr); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = outWrite; + si.hStdError = outWrite; + si.hStdInput = nul; + + PROCESS_INFORMATION pi{}; + + // Через cmd.exe, а не напрямую: нужны PATH, встроенные команды и то, что + // человек привык набирать в консоли. Кодовая страница 65001 — иначе + // кириллица из вывода приезжает в CP866 и рисуется мусором. + // + // Каталог с бинарниками добавляется в PATH: команды выполняются в корне + // репозитория (там содержимое, документация и git), а `tile2d_editor` и + // `squad_proto` лежат в build/bin. Без этого простое `--tool validate` + // требовало бы писать путь к самому себе. + char exePath[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string binDir = exePath; + const size_t slash = binDir.find_last_of("\\/"); + binDir = (slash == std::string::npos) ? std::string(".") : binDir.substr(0, slash); + + std::string line = "cmd.exe /d /c chcp 65001>nul & set \"PATH=" + binDir + ";%PATH%\" & "; + if (!projectName.empty()) line += "set \"TILE2D_PROJECT=" + projectName + "\" & "; + line += cmd; + std::vector mutableLine(line.begin(), line.end()); + mutableLine.push_back('\0'); + + const BOOL ok = CreateProcessA(nullptr, mutableLine.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, nullptr, + workDir.empty() ? nullptr : workDir.c_str(), &si, &pi); + CloseHandle(outWrite); + if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul); + + if (!ok) + { + Print("не запустилось: " + cmd); + CloseChild(); + return; + } + + CloseHandle(pi.hThread); + g_impl.proc = pi.hProcess; +} + +void Terminal::RunShellFromArgv(const char* cmd) +{ + if (!cmd) return; + + const int wide = MultiByteToWideChar(CP_ACP, 0, cmd, -1, nullptr, 0); + if (wide <= 0) { RunShell(cmd); return; } + + // Не `std::vector w(size_t(wide))`: это объявление ФУНКЦИИ, + // а не вектора (most vexing parse), и компилятор скажет об этом невнятно. + std::vector w; + w.resize(size_t(wide)); + MultiByteToWideChar(CP_ACP, 0, cmd, -1, w.data(), wide); + + const int utf8 = WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, nullptr, 0, nullptr, nullptr); + if (utf8 <= 0) { RunShell(cmd); return; } + + std::vector u; + u.resize(size_t(utf8)); + WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, u.data(), utf8, nullptr, nullptr); + RunShell(u.data()); +} + +void Terminal::Stop() +{ + if (!Running()) return; + TerminateProcess(g_impl.proc, 1); + WaitForSingleObject(g_impl.proc, 2000); + CloseChild(); + Print("[прервано]"); +} + +void Terminal::Poll() +{ + if (!g_impl.outRead) return; + + for (;;) + { + DWORD avail = 0; + if (!PeekNamedPipe(g_impl.outRead, nullptr, 0, nullptr, &avail, nullptr)) break; + if (avail == 0) break; + + char buf[8192]; + DWORD got = 0; + const DWORD want = (avail < sizeof(buf)) ? avail : DWORD(sizeof(buf)); + if (!ReadFile(g_impl.outRead, buf, want, &got, nullptr) || got == 0) break; + + g_impl.pending.append(buf, got); + for (;;) + { + const size_t nl = g_impl.pending.find('\n'); + if (nl == std::string::npos) break; + Print(StripAnsi(g_impl.pending.substr(0, nl))); + g_impl.pending.erase(0, nl + 1); + } + // Очень длинная строка без перевода — не повод копить её вечно. + if (g_impl.pending.size() > 4096) + { + Print(StripAnsi(g_impl.pending)); + g_impl.pending.clear(); + } + } + + if (g_impl.proc && WaitForSingleObject(g_impl.proc, 0) == WAIT_OBJECT_0) + { + if (!g_impl.pending.empty()) + { + Print(StripAnsi(g_impl.pending)); + g_impl.pending.clear(); + } + DWORD code = 0; + GetExitCodeProcess(g_impl.proc, &code); + Print(code == 0 ? "[готово]" : ("[код " + std::to_string(int(code)) + "]")); + CloseChild(); + } +} + +void Terminal::LaunchExternal() +{ + // Полный интерактивный CLI — во внешнем окне: у него альтернативный экран + // и рамки из псевдографики, и рисовать это здесь было бы враньём. + const char* cli = (mode == AskMode::CODEX) ? "codex" : "claude"; + const std::string cmd = std::string("cmd.exe /c start \"Tile2D agent\" cmd /k ") + cli; + + std::vector line(cmd.begin(), cmd.end()); + line.push_back('\0'); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (CreateProcessA(nullptr, line.data(), nullptr, nullptr, FALSE, 0, nullptr, + workDir.empty() ? nullptr : workDir.c_str(), &si, &pi)) + { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + Print(std::string("внешнее окно: ") + cli + " в " + workDir); + } + else + { + Print(std::string("не удалось открыть внешний терминал с ") + cli); + } +} + +#else // не Windows + +void Terminal::RunShell(const std::string& cmd) +{ + Print("терминал реализован только под Windows: " + cmd); +} +void Terminal::Stop() {} +void Terminal::Poll() {} +void Terminal::LaunchExternal() { Print("внешний терминал: только под Windows"); } +void Terminal::RunShellFromArgv(const char* cmd) { if (cmd) RunShell(cmd); } + +#endif + +void Terminal::Submit() +{ + const std::string line = input; + if (line.empty()) return; + + input.clear(); + histPos = -1; + if (history.empty() || history.back() != line) history.push_back(line); + + switch (mode) + { + case AskMode::CLAUDE: + RunShell("claude -p " + Quote(line)); + break; + case AskMode::CODEX: + RunShell("codex exec " + Quote(line)); + break; + default: + RunShell(line); + break; + } +} + +// ----------------------------------------------------------------------------- +// Ввод +// ----------------------------------------------------------------------------- +bool Terminal::Update(int windowW, int windowH, float dt) +{ + (void)dt; + + if (IsKeyPressed(KEY_GRAVE)) open = !open; + if (IsKeyPressed(KEY_F12)) { open = true; LaunchExternal(); } + if (!open) return false; + + Poll(); + + const int top = PanelTop(windowH); + + // --- кнопки --------------------------------------------------------------- + int bx = PAD; + for (int i = 0; i < BUTTON_COUNT; ++i) + { + BUTTONS[i].w = MeasureLine(BUTTONS[i].label) + 14; + BUTTONS[i].x = bx; + bx += BUTTONS[i].w + 6; + } + + const Vector2 m = GetMousePosition(); + const bool inPanel = m.y >= float(top); + if (inPanel && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) + { + const int by = top + PAD; + for (int i = 0; i < BUTTON_COUNT; ++i) + { + const bool hit = m.x >= float(BUTTONS[i].x) && + m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) && + m.y < float(by + BTN_H); + if (!hit) continue; + + if (BUTTONS[i].cmd) RunShell(BUTTONS[i].cmd); + else if (std::strcmp(BUTTONS[i].label, "STOP") == 0) Stop(); + else LaunchExternal(); + } + } + + // --- клавиши -------------------------------------------------------------- + const bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); + + for (int cp = GetCharPressed(); cp > 0; cp = GetCharPressed()) + if (cp != '`') AppendUtf8(input, cp); + + if (IsKeyPressed(KEY_BACKSPACE) || IsKeyPressedRepeat(KEY_BACKSPACE)) PopUtf8(input); + if (IsKeyPressed(KEY_TAB)) mode = AskMode((int(mode) + 1) % int(AskMode::COUNT)); + if (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_KP_ENTER)) Submit(); + if (ctrl && IsKeyPressed(KEY_C)) Stop(); + if (IsKeyPressed(KEY_ESCAPE)) { if (Running()) Stop(); else open = false; } + + // История: вверх — назад по своим командам. Ровно то поведение, которое + // есть в любой оболочке; своё придумывать здесь незачем. + if (IsKeyPressed(KEY_UP) && !history.empty()) + { + if (histPos < 0) histPos = int(history.size()); + histPos = std::max(0, histPos - 1); + input = history[size_t(histPos)]; + } + if (IsKeyPressed(KEY_DOWN) && histPos >= 0) + { + ++histPos; + if (histPos >= int(history.size())) { histPos = -1; input.clear(); } + else input = history[size_t(histPos)]; + } + + const int rows = std::max(1, (windowH - top - PAD * 3 - BTN_H - LINE_H) / LINE_H); + const int maxScroll = std::max(0, int(lines.size()) - rows); + if (IsKeyPressed(KEY_PAGE_UP)) scroll = std::min(maxScroll, scroll + rows / 2); + if (IsKeyPressed(KEY_PAGE_DOWN)) scroll = std::max(0, scroll - rows / 2); + if (inPanel) + { + const float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) + scroll = std::max(0, std::min(maxScroll, scroll + int(wheel * 3.0f))); + } + + (void)windowW; + return true; // терминал открыт — весь ввод его +} + +void Terminal::Draw(int windowW, int windowH) const +{ + if (!open) return; + + const int top = PanelTop(windowH); + DrawRectangle(0, top, windowW, windowH - top, COL_BG); + DrawRectangle(0, top, windowW, 1, COL_EDGE); + + // --- кнопки --------------------------------------------------------------- + const int by = top + PAD; + const Vector2 m = GetMousePosition(); + for (int i = 0; i < BUTTON_COUNT; ++i) + { + const bool hover = m.x >= float(BUTTONS[i].x) && + m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) && + m.y < float(by + BTN_H); + DrawRectangle(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, hover ? COL_BTN_HI : COL_BTN); + DrawRectangleLines(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, COL_EDGE); + DrawLine(BUTTONS[i].label, BUTTONS[i].x + 7, by + 2, COL_TEXT); + } + + // Состояние справа от кнопок: выполняется ли что-то и где именно. + { + const std::string right = (Running() ? std::string("выполняется ") : std::string()) + + workDir; + DrawLine(right.c_str(), windowW - PAD - MeasureLine(right.c_str()), by + 2, + Running() ? COL_OK : COL_DIM); + } + + // --- вывод ---------------------------------------------------------------- + const int bodyTop = by + BTN_H + PAD; + const int bodyBottom = windowH - PAD - LINE_H; + const int rows = std::max(1, (bodyBottom - bodyTop) / LINE_H); + + // Сколько символов влезает в строку. Шаг буквы меряется по РАЗНОСТИ двух + // строк, а не по одной букве: в шаг входит межбуквенный интервал, и без + // него расчёт занижает ширину символа, а строка вылезает за край. + const int charW = std::max(1, MeasureLine("WW") - MeasureLine("W")); + const int cols = (windowW - PAD * 2) / charW; + + // Переносим с конца назад, пока не набрали экран: так отматывать можно по + // ЭКРАННЫМ строкам, а не по исходным, и абзац на пять ширин не проскакивает + // мимо одним щелчком колеса. + std::vector view; + std::vector colors; + view.reserve(size_t(rows) + 8); + + int skip = scroll; + for (int i = int(lines.size()) - 1; i >= 0 && int(view.size()) < rows + skip; --i) + { + const std::string& s = lines[size_t(i)]; + Color c = COL_TEXT; + if (!s.empty() && s[0] == '$') c = COL_ECHO; + else if (s.rfind("[код", 0) == 0 || s.rfind("СЛОМАНО", 0) == 0 || + s.rfind("ОШИБКА", 0) == 0) + c = COL_ERR; + else if (s.rfind("[готово]", 0) == 0) c = COL_OK; + + std::vector wrapped; + WrapInto(s, cols, wrapped); + for (int k = int(wrapped.size()) - 1; k >= 0; --k) + { + view.push_back(wrapped[size_t(k)]); + colors.push_back(c); + } + } + + int y = bodyTop; + const int firstRow = std::min(int(view.size()), rows + skip) - 1; + for (int i = firstRow; i >= skip && y < bodyBottom; --i, y += LINE_H) + DrawLine(view[size_t(i)].c_str(), PAD, y, colors[size_t(i)]); + + if (scroll > 0) + { + const std::string tag = "^ отмотано на " + std::to_string(scroll); + DrawLine(tag.c_str(), windowW - PAD - MeasureLine(tag.c_str()), bodyTop, COL_DIM); + } + + // --- строка ввода --------------------------------------------------------- + const int iy = windowH - PAD - LINE_H + 2; + std::string prompt = std::string(AskModeName(mode)) + (mode == AskMode::SHELL ? "$ " : "> "); + DrawLine(prompt.c_str(), PAD, iy, mode == AskMode::SHELL ? COL_DIM : COL_ECHO); + + const int px = PAD + MeasureLine(prompt.c_str()); + DrawLine(input.c_str(), px, iy, COL_TEXT); + + // Курсор мигает от системного времени, а не от кадра: на 60 fps счётчик + // кадров дал бы разную частоту на разных мониторах. + if (int(GetTime() * 2.0) % 2 == 0) + DrawRectangle(px + MeasureLine(input.c_str()) + 1, iy + 2, 8, FONT_SIZE - 2, COL_TEXT); +} diff --git a/editor/terminal.h b/editor/terminal.h new file mode 100644 index 0000000..77c6d17 --- /dev/null +++ b/editor/terminal.h @@ -0,0 +1,90 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Терминал в редакторе: работать над проектом, не выходя из него. +// +// ЧТО ЭТО НЕ ТАКОЕ. Это не эмулятор терминала. Полноценный VT100 (альтернативный +// экран, адресация курсора, рамки из псевдографики) нужен ровно одному сорту +// программ — интерактивным TUI, и `claude` без ключей именно такой. Написать +// его поверх raylib можно, но результат всё равно рассыпался бы на первом же +// символе рамки: встроенный шрифт их не знает. +// +// Поэтому здесь СТРОЧНЫЙ вывод, и это не упрощение, а выбор в пользу того, +// как с моделью работают из редактора на самом деле: +// +// * `claude -p` и `codex exec` печатают обычный текст — им эмулятор не нужен; +// * инструменты Tile2D (--tool) печатают таблицы и карты — тоже текст; +// * git, cmake, прогоны игры — текст. +// +// Полный интерактивный CLI открывается одной кнопкой во ВНЕШНЕМ терминале, в +// том же рабочем каталоге. Так у каждого способа остаётся то, в чём он хорош. +// +// Строка ввода работает в трёх режимах (Tab): команда оболочки, вопрос Claude, +// вопрос Codex. Одно поле, а не три разных — потому что работа выглядит как +// чередование: спросил, посмотрел, проверил инструментом, снова спросил. +// ----------------------------------------------------------------------------- +#include +#include +#include + +struct ScreenRect; + +enum class AskMode : uint8_t +{ + SHELL = 0, // команда оболочки как есть + CLAUDE, // claude -p "..." + CODEX, // codex exec "..." + COUNT, +}; + +const char* AskModeName(AskMode m); + +struct Terminal +{ + bool open = false; + + // Скроллбек. Строки, а не поток байт: их листают, ищут глазами и копируют + // в отчёт, и все три действия требуют границ строк. + std::vector lines; + std::string input; + std::vector history; + int histPos = -1; // -1 — набирается новая строка, не из истории + int scroll = 0; // на сколько строк отмотано вверх от конца + AskMode mode = AskMode::SHELL; + + // Где выполняются команды. Корень репозитория, а не каталог проекта: + // работа идёт над всем — код движка, содержимое, документация. + std::string workDir; + + // Какой проект открыт в редакторе. Уходит детям переменной окружения + // TILE2D_PROJECT, поэтому `--tool validate` без ключей проверяет ИМЕННО + // его — и то же самое видит агент, запущенный отсюда. + std::string projectName; + + void Init(const std::string& dir, const std::string& project = {}); + void Shutdown(); + + bool Running() const; + + // Ввод и кнопки. true — терминал забрал ввод себе, редактору его не + // отдавать: иначе набранная в строке `w` встанет кистью стены на холст. + bool Update(int windowW, int windowH, float dt); + void Draw(int windowW, int windowH) const; + + // Забрать то, что процесс успел напечатать. Звать каждый кадр: вывод + // обязан идти потоком, а не появляться целиком по завершении. + void Poll(); + + void Submit(); // выполнить строку ввода по режиму + void RunShell(const std::string& cmd); // выполнить команду напрямую (UTF-8) + // То же, но строка пришла из argv. Аргументы командной строки приходят в + // системной кодировке, а всё остальное здесь — UTF-8: без перевода русская + // команда из --term отображалась бы вопросами. + void RunShellFromArgv(const char* cmd); + void Stop(); // прервать текущий процесс + void Print(const std::string& s); // своя строка в скроллбек + void LaunchExternal(); // интерактивный CLI отдельным окном + + // Занимаемая часть окна снизу, в пикселях ОКНА. Редактор спрашивает её, + // чтобы не рисовать и не ловить мышь под панелью. + int PanelTop(int windowH) const; +}; diff --git a/editor/tools.cpp b/editor/tools.cpp new file mode 100644 index 0000000..f0e07cc --- /dev/null +++ b/editor/tools.cpp @@ -0,0 +1,591 @@ +#include "editor/tools.h" + +#include +#include +#include +#include +#include + +#include "engine/catalog.h" +#include "engine/level_gen.h" +#include "engine/progression.h" +#include "engine/project.h" +#include "engine/room.h" +#include "engine/tilemap.h" + +namespace +{ + +// --- разбор аргументов ------------------------------------------------------- + +struct Args +{ + std::string cmd; + std::vector rest; // позиционные, без ключей + std::string project; + + const std::string& At(size_t i) const + { + static const std::string empty; + return (i < rest.size()) ? rest[i] : empty; + } + + int Int(size_t i, int fallback) const + { + const std::string& s = At(i); + if (s.empty()) return fallback; + return std::atoi(s.c_str()); + } +}; + +Args Parse(int argc, char** argv) +{ + Args a; + for (int i = 0; i < argc; ++i) + { + const char* s = argv[i]; + if (std::strcmp(s, "--project") == 0 && i + 1 < argc) { a.project = argv[++i]; continue; } + if (std::strcmp(s, "--tool") == 0) continue; + if (s[0] == '-') continue; + if (a.cmd.empty()) a.cmd = s; + else a.rest.push_back(s); + } + return a; +} + +// --- общая часть: поднять проект --------------------------------------------- +// +// Проект нужен почти каждой команде, и «не выбран» — самая частая ошибка +// вызова. Поэтому текст отказа один на все команды и всегда со списком. +struct Loaded +{ + Project project; + EntityCatalog catalog; + RoomLibrary rooms; + bool ok = false; +}; + +bool OpenProject(const Args& a, Loaded& out, bool needRooms = true) +{ + std::string why; + const std::string dir = FindProjectDir(a.project); + if (dir.empty()) + { + std::printf("ОШИБКА: проект не выбран (не найден или их несколько)\n"); + for (const std::string& n : AvailableProjects()) + std::printf(" доступен: --project %s\n", n.c_str()); + return false; + } + if (!out.project.Load(dir, &why)) + { + std::printf("ОШИБКА: %s\n", why.c_str()); + return false; + } + out.project.Apply(); + + if (!out.catalog.Load(out.project.catalogPath, &why)) + { + std::printf("ОШИБКА: каталог сущностей: %s\n", why.c_str()); + return false; + } + if (needRooms && !out.rooms.Load(out.project.roomsDir, &why)) + { + std::printf("ОШИБКА: набор комнат: %s\n", why.c_str()); + return false; + } + out.ok = true; + return true; +} + +std::string DepthRange(int minDepth, int maxDepth) +{ + char buf[24]; + if (maxDepth > 0) std::snprintf(buf, sizeof(buf), "%d-%d", minDepth, maxDepth); + else std::snprintf(buf, sizeof(buf), "%d+", minDepth); + return buf; +} + +// --- команды ----------------------------------------------------------------- + +int CmdHelp() +{ + std::printf( + "Tile2D tools — работа с проектом без окна.\n" + "\n" + " tile2d_editor --tool <команда> [аргументы] [--project <имя>]\n" + "\n" + "ЧТО ЕСТЬ В ПРОЕКТЕ\n" + " projects какие проекты видны отсюда\n" + " project манифест: вид, пути, кривая глубины\n" + " rooms набор комнат таблицей\n" + " room <имя> одна комната: двери, точки спавна, проблемы\n" + " ascii <имя> сетка комнаты текстом\n" + " catalog [класс] [этаж] что вообще можно ставить, с ценами и глубинами\n" + "\n" + "ЧТО ИЗ ЭТОГО ПОЛУЧАЕТСЯ\n" + " curve <от> <до> во что кривая превращает этажи с <от> по <до>\n" + " floor <этаж> [сид] собрать этаж и показать цифры\n" + " map <этаж> [сид] собранный этаж картой текстом\n" + " validate весь набор: комнаты, покрытие глубин, сборка\n" + "\n" + "Код возврата 1 означает «не выполнено или найдена поломка» — на это можно\n" + "вешать хуки и скрипты, а не читать вывод глазами.\n"); + return 0; +} + +int CmdProjects() +{ + const std::vector names = AvailableProjects(); + if (names.empty()) + { + std::printf("проектов не найдено (ожидается projects/<имя>/project.txt)\n"); + return 1; + } + for (const std::string& n : names) + { + Project p; + std::string why; + const std::string dir = FindProjectDir(n); + if (dir.empty() || !p.Load(dir, &why)) + { + std::printf("%-12s [СЛОМАН] %s\n", n.c_str(), why.c_str()); + continue; + } + std::printf("%-12s %-8s %-8s %s\n", n.c_str(), p.name.c_str(), ViewModeName(p.view), + p.dir.c_str()); + } + return 0; +} + +int CmdProject(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + const Project& p = L.project; + const FloorCurve& c = p.curve; + + std::printf("имя %s\n", p.name.c_str()); + std::printf("каталог %s\n", p.dir.c_str()); + std::printf("вид %s\n", ViewModeName(p.view)); + std::printf("комнаты %s (%d шт.)\n", p.roomsDir.c_str(), L.rooms.Count()); + std::printf("сущности %s (%d шт.)\n", p.catalogPath.c_str(), L.catalog.Count()); + std::printf("первый этаж %d\n", p.startDepth); + std::printf("\nкривая глубины\n"); + std::printf(" danger %.2f + %.2f*(d-1) + %.3f*(d-1)^2\n", double(c.dangerBase), + double(c.dangerLinear), double(c.dangerQuad)); + std::printf(" density 1 + %.3f*(d-1), не выше %.2f\n", double(c.densityPerFloor), + double(c.densityCap)); + std::printf(" loot %d + d/%d предметов, тир каждые %d этажа\n", c.lootBase, + c.floorsPerDrop, c.floorsPerTier); + std::printf(" sight 1 - %.3f*(d-1), не ниже %.2f\n", double(c.sightPerFloor), + double(c.sightFloorAt)); + std::printf(" xp 1 + %.2f*(d-1)\n", double(c.xpPerFloor)); + std::printf(" themes "); + for (int i = 0; i < THEME_COUNT; ++i) + std::printf(" %s c %d", ThemeName(uint8_t(i)), c.themeFrom[i]); + std::printf("\n"); + return 0; +} + +int CmdRooms(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + // Заголовки латиницей: ширина в printf считается в БАЙТАХ, и кириллица + // в UTF-8 разъезжается со столбцами ровно вдвое. + std::printf("%-14s %6s %5s %6s %6s %7s %6s %s\n", "ROOM", "SIZE", "DOORS", "SPAWNS", "THEME", + "DEPTH", "WEIGHT", "STATE"); + int bad = 0; + for (const Room& r : L.rooms.rooms) + { + std::string state = RoomProblem(r); + if (state.empty() && r.start) state = "start"; + if (!RoomProblem(r).empty()) ++bad; + + std::printf("%-14s %3dx%-2d %5d %6d %6s %7s %6d %s\n", r.name.c_str(), r.w, r.h, + r.CountDoors(), int(r.spawns.size()), ThemeName(r.theme), + DepthRange(r.minDepth, r.maxDepth).c_str(), r.weight, state.c_str()); + } + for (const std::string& f : L.rooms.failures) + { + std::printf("НЕ ЧИТАЕТСЯ %s\n", f.c_str()); + ++bad; + } + return bad > 0 ? 1 : 0; +} + +const Room* FindRoom(const RoomLibrary& lib, const std::string& name) +{ + const int i = lib.IndexOf(name); + return (i >= 0) ? &lib.rooms[size_t(i)] : nullptr; +} + +int CmdRoom(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + const Room* r = FindRoom(L.rooms, a.At(0)); + if (!r) + { + std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str()); + return 1; + } + + std::printf("комната %s\n", r->name.c_str()); + std::printf("размер %dx%d, дверей %d, вес %d\n", r->w, r->h, r->CountDoors(), r->weight); + std::printf("тема %s\n", ThemeName(r->theme)); + std::printf("глубина %s%s\n", DepthRange(r->minDepth, r->maxDepth).c_str(), + r->start ? ", стартовая" : ""); + + const std::string problem = RoomProblem(*r); + std::printf("состояние %s\n", problem.empty() ? "в порядке" : problem.c_str()); + + std::printf("\nточки спавна (%d)\n", int(r->spawns.size())); + for (const SpawnPoint& p : r->spawns) + { + std::printf(" (%2d,%2d) шанс %.2f :", p.x, p.y, double(p.chance)); + const int total = p.TotalWeight(); + for (const SpawnOption& o : p.options) + { + const int idx = L.catalog.IndexOf(o.id); + const double share = (total > 0) ? 100.0 * double(o.weight) / double(total) : 0.0; + std::printf(" %s:%d(%.0f%%%s)", o.id.c_str(), o.weight, share, + idx < 0 ? ", НЕТ В КАТАЛОГЕ" : ""); + } + std::printf("\n"); + } + return problem.empty() ? 0 : 1; +} + +int CmdAscii(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + const Room* r = FindRoom(L.rooms, a.At(0)); + if (!r) + { + std::printf("ОШИБКА: нет комнаты `%s`\n", a.At(0).c_str()); + return 1; + } + + // Точка спавна рисуется поверх сетки цифрой — её номером в списке. Так по + // карте видно и ГДЕ она, и какую строку `spawn` смотреть. + std::printf("%s %dx%d\n\n", r->name.c_str(), r->w, r->h); + std::printf(" "); + for (int x = 0; x < r->w; ++x) std::printf("%d", x % 10); + std::printf("\n"); + + for (int y = 0; y < r->h; ++y) + { + std::printf("%3d ", y); + for (int x = 0; x < r->w; ++x) + { + char c = '.'; + switch (r->At(x, y)) + { + case RoomCell::WALL: c = '#'; break; + case RoomCell::DOOR: c = '+'; break; + default: break; + } + for (size_t i = 0; i < r->spawns.size(); ++i) + if (r->spawns[i].x == x && r->spawns[i].y == y) c = char('0' + int(i % 10)); + std::printf("%c", c); + } + std::printf("\n"); + } + std::printf("\n#стена +дверь .пол цифра — точка спавна (её номер в списке)\n"); + return 0; +} + +int CmdCatalog(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L, false)) return 1; + + bool byClass = false; + EntityClass want = EntityClass::MONSTER; + const std::string& cls = a.At(0); + if (cls == "monster") { byClass = true; want = EntityClass::MONSTER; } + else if (cls == "prop") { byClass = true; want = EntityClass::PROP; } + else if (cls == "loot") { byClass = true; want = EntityClass::LOOT; } + else if (!cls.empty()) + { + std::printf("ОШИБКА: класс бывает monster, prop или loot\n"); + return 1; + } + + const int depth = a.Int(1, 0); // 0 — не фильтровать по глубине + + std::printf("%-12s %-8s %7s %7s %5s %s\n", "ID", "CLASS", "DEPTH", "DANGER", "TIER", "NAME"); + int shown = 0; + for (int i = 0; i < L.catalog.Count(); ++i) + { + const EntityDesc& d = L.catalog.At(i); + if (byClass && d.cls != want) continue; + if (depth > 0 && !(depth >= d.minDepth && (d.maxDepth <= 0 || depth <= d.maxDepth))) + continue; + + std::printf("%-12s %-8s %7s %7.1f %5d %s\n", d.id.c_str(), EntityClassName(d.cls), + DepthRange(d.minDepth, d.maxDepth).c_str(), double(d.danger), d.tier, + d.name.c_str()); + ++shown; + } + if (depth > 0) std::printf("\nна этаже %d доступно записей: %d\n", depth, shown); + return 0; +} + +int CmdCurve(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L, false)) return 1; + + const int from = std::max(1, a.Int(0, 1)); + const int to = std::max(from, a.Int(1, from + 11)); + + std::printf("%-6s %-7s %8s %8s %6s %5s %7s %6s\n", "DEPTH", "THEME", "DANGER", "DENSITY", + "LOOT", "TIER", "SIGHT", "XP"); + for (int d = from; d <= to; ++d) + { + const FloorSpec s = DescribeFloor(d); + std::printf("%-6d %-7s %8.1f %8.2f %6d %5d %7.2f %6.2f\n", s.depth, ThemeName(s.theme), + double(s.dangerBudget), double(s.spawnChanceScale), s.lootDrops, s.lootTier, + double(s.sightScale), double(s.xpScale)); + } + return 0; +} + +// Сборка этажа — общая часть для floor и map. +bool BuildFloor(Loaded& L, int depth, uint32_t seed, Tilemap& map, GeneratedLevel& lvl) +{ + const FloorSpec spec = DescribeFloor(depth); + std::string why; + if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why)) + { + std::printf("ОШИБКА: этаж %d сид %u не собрался: %s\n", depth, unsigned(seed), why.c_str()); + return false; + } + return true; +} + +int CmdFloor(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + const int depth = std::max(1, a.Int(0, 1)); + const uint32_t seed = uint32_t(a.Int(1, 1)); + + static Tilemap map; + static GeneratedLevel lvl; + if (!BuildFloor(L, depth, seed, map, lvl)) return 1; + + std::printf("этаж %d, сид %u, тема %s\n", lvl.floor.depth, unsigned(seed), + ThemeName(lvl.floor.theme)); + std::printf("комнат %d, коридоров %d, достижимо тайлов %d, до спуска %d шагов\n", + lvl.roomsPlaced, lvl.corridors, lvl.reachableTiles, lvl.exitDistance); + std::printf("опасность %.1f из %.1f, тварей %d (долив %d), лута %d из тира %d\n", + double(lvl.dangerSpent), double(lvl.floor.dangerBudget), lvl.monstersPlaced, + lvl.monstersFilled, int(lvl.loot.size()), lvl.floor.lootTier); + std::printf("старт (%.1f,%.1f), спуск (%.1f,%.1f), эвакуация (%.1f,%.1f)\n", + double(lvl.squadStart.x), double(lvl.squadStart.y), double(lvl.exitPos.x), + double(lvl.exitPos.y), double(lvl.extractPos.x), double(lvl.extractPos.y)); + + // Всё, из-за чего этаж населён не так, как задумано, печатается отдельно: + // молчаливая усушка населения — это не «баланс». + int bad = 0; + if (lvl.spawnsDropped > 0) + { + std::printf("ПОТЕРИ: срезано потолком %d: %d\n", MAX_LEVEL_SPAWNS, lvl.spawnsDropped); + ++bad; + } + if (lvl.spawnsUnreachable > 0) + { + std::printf("ПОТЕРИ: недостижимо от старта: %d\n", lvl.spawnsUnreachable); + ++bad; + } + if (lvl.dangerSpent < lvl.floor.dangerBudget * 0.75f) + std::printf("ЗАМЕЧАНИЕ: набор отстаёт от кривой, бюджет выбран не полностью\n"); + + // Кого именно выкатило — по количеству, а не списком: список из полусотни + // строк невозможно сравнить с таким же списком другого сида. + std::vector> tally; + for (const LevelSpawn& s : lvl.spawns) + { + auto it = std::find_if(tally.begin(), tally.end(), + [&](const std::pair& p) { return p.first == s.id; }); + if (it == tally.end()) tally.push_back({s.id, 1}); + else ++it->second; + } + std::sort(tally.begin(), tally.end(), + [](const std::pair& x, const std::pair& y) { + return x.second > y.second; + }); + std::printf("\nнаселение:"); + for (const std::pair& p : tally) std::printf(" %s x%d", p.first.c_str(), p.second); + std::printf("\nлут: "); + for (const LevelSpawn& s : lvl.loot) std::printf(" %s", s.id.c_str()); + std::printf("\n"); + return bad > 0 ? 1 : 0; +} + +int CmdMap(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + const int depth = std::max(1, a.Int(0, 1)); + const uint32_t seed = uint32_t(a.Int(1, 1)); + + static Tilemap map; + static GeneratedLevel lvl; + if (!BuildFloor(L, depth, seed, map, lvl)) return 1; + + // Карта строится в буфер символов, потом на неё кладутся сущности: иначе + // тварь, стоящая в дверях, потерялась бы под символом двери. + std::vector grid(size_t(MAP_W) * size_t(MAP_H), ' '); + for (int y = 0; y < MAP_H; ++y) + for (int x = 0; x < MAP_W; ++x) + grid[size_t(y) * MAP_W + x] = map.IsWall(x, y) ? '#' : '.'; + + auto put = [&](Vec2 p, char c) { + const int x = int(p.x); + const int y = int(p.y); + if (Tilemap::InBounds(x, y)) grid[size_t(y) * MAP_W + x] = c; + }; + + for (const LevelSpawn& s : lvl.spawns) + { + const EntityDesc* d = L.catalog.Find(s.id); + put(s.pos, (d && d->cls == EntityClass::PROP) ? 'o' : 'm'); + } + for (const LevelSpawn& s : lvl.loot) put(s.pos, '$'); + put(lvl.exitPos, '>'); + put(lvl.extractPos, '<'); + put(lvl.squadStart, '@'); + + std::printf("этаж %d, сид %u, тема %s, комнат %d\n\n", lvl.floor.depth, unsigned(seed), + ThemeName(lvl.floor.theme), lvl.roomsPlaced); + for (int y = 0; y < MAP_H; ++y) + { + std::printf("%3d ", y); + for (int x = 0; x < MAP_W; ++x) std::printf("%c", grid[size_t(y) * MAP_W + x]); + std::printf("\n"); + } + std::printf("\n# стена . пол @ старт > спуск < эвакуация m тварь o обстановка $ лут\n"); + return 0; +} + +int CmdValidate(const Args& a) +{ + Loaded L; + if (!OpenProject(a, L)) return 1; + + int bad = 0; + int warn = 0; + + for (const Room& r : L.rooms.rooms) + { + const std::string problem = RoomProblem(r); + if (!problem.empty()) + { + std::printf("СЛОМАНО %s: %s\n", r.name.c_str(), problem.c_str()); + ++bad; + } + for (const SpawnPoint& p : r.spawns) + for (const SpawnOption& o : p.options) + if (L.catalog.IndexOf(o.id) < 0) + { + std::printf("СЛОМАНО %s (%d,%d): id `%s` нет в каталоге\n", r.name.c_str(), + p.x, p.y, o.id.c_str()); + ++bad; + } + } + for (const std::string& f : L.rooms.failures) + { + std::printf("СЛОМАНО не читается %s\n", f.c_str()); + ++bad; + } + if (!L.rooms.HasStartRoom()) + { + std::printf("СЛОМАНО в наборе нет ни одной стартовой комнаты\n"); + ++bad; + } + + // Покрытие глубин: главный вопрос бесконечного спуска — не «хороша ли + // комната», а «есть ли чем застроить сотый этаж». + const int probeMax = std::max(L.catalog.DeepestIntroduction(), 12) + 4; + for (int d = 1; d <= probeMax; ++d) + { + const bool roomsHere = L.rooms.CountAt(d) > 0; + const bool startHere = L.rooms.HasStartRoomAt(d); + const bool monsters = L.catalog.HasAnyAt(EntityClass::MONSTER, d); + const bool loot = L.catalog.HasAnyAt(EntityClass::LOOT, d); + if (roomsHere && startHere && monsters && loot) continue; + + std::printf("СЛОМАНО этаж %d:%s%s%s%s\n", d, roomsHere ? "" : " нет комнат", + startHere ? "" : " нет стартовой", monsters ? "" : " нет тварей", + loot ? "" : " нет лута"); + ++bad; + } + + static Tilemap map; + static GeneratedLevel lvl; + for (const int d : {1, 3, 6, 10, 16, 24}) + for (const uint32_t seed : {1u, 42u}) + { + const FloorSpec spec = DescribeFloor(d); + std::string why; + if (!GenerateLevel(L.rooms, L.catalog, spec, seed, map, lvl, &why)) + { + std::printf("СЛОМАНО этаж %d сид %u: %s\n", d, unsigned(seed), why.c_str()); + ++bad; + continue; + } + if (lvl.spawnsUnreachable > 0 || lvl.spawnsDropped > 0 || lvl.loot.empty() || + lvl.exitDistance <= 0) + { + std::printf("СЛОМАНО этаж %d сид %u: недостижимых %d, срезано %d, лута %d, " + "до спуска %d\n", + d, unsigned(seed), lvl.spawnsUnreachable, lvl.spawnsDropped, + int(lvl.loot.size()), lvl.exitDistance); + ++bad; + } + else if (lvl.dangerSpent < spec.dangerBudget * 0.75f) + { + std::printf("ЗАМЕЧАНИЕ этаж %d сид %u: опасность %.0f из %.0f\n", d, unsigned(seed), + double(lvl.dangerSpent), double(spec.dangerBudget)); + ++warn; + } + } + + std::printf("\n%s: комнат %d, сущностей %d, поломок %d, замечаний %d\n", + L.project.name.c_str(), L.rooms.Count(), L.catalog.Count(), bad, warn); + return bad > 0 ? 1 : 0; +} + +} // namespace + +int RunTool(int argc, char** argv) +{ + const Args a = Parse(argc, argv); + + if (a.cmd.empty() || a.cmd == "help") return CmdHelp(); + if (a.cmd == "projects") return CmdProjects(); + if (a.cmd == "project") return CmdProject(a); + if (a.cmd == "rooms") return CmdRooms(a); + if (a.cmd == "room") return CmdRoom(a); + if (a.cmd == "ascii") return CmdAscii(a); + if (a.cmd == "catalog") return CmdCatalog(a); + if (a.cmd == "curve") return CmdCurve(a); + if (a.cmd == "floor") return CmdFloor(a); + if (a.cmd == "map") return CmdMap(a); + if (a.cmd == "validate") return CmdValidate(a); + + std::printf("ОШИБКА: нет команды `%s`\n\n", a.cmd.c_str()); + CmdHelp(); + return 1; +} diff --git a/editor/tools.h b/editor/tools.h new file mode 100644 index 0000000..b089bd3 --- /dev/null +++ b/editor/tools.h @@ -0,0 +1,26 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Инструменты Tile2D: то, чем работают с проектом БЕЗ окна и без мыши. +// +// Зачем это существует отдельно от редактора. Содержимое проекта — текст: +// комнаты, каталог сущностей и манифест лежат в git и правятся руками. Значит, +// править их умеет кто угодно, включая языковую модель: открыл файл, изменил, +// сохранил. Чего у неё НЕТ — обратной связи. Собралась ли комната в этаж, +// достижима ли точка спавна, выбрал ли этаж бюджет опасности, во что +// превращается набор на двадцатом этаже — на эти вопросы нельзя ответить, +// глядя на текст комнаты. +// +// Поэтому инструменты — это не «API редактора», а ЗАМКНУТАЯ ПЕТЛЯ: правку +// делают файлами, а последствия читают отсюда. Всё выводится текстом, включая +// карту собранного этажа: тем же правилом живут ассеты +// (docs/12-assets.md — «проверка только текстом»), и по той же причине — +// картинку нельзя ни продиффать, ни процитировать в отчёте. +// +// tile2d_editor --tool <команда> [аргументы] [--project <имя>] +// +// Код возврата: 0 — всё в порядке, 1 — команда не выполнена или проверка +// нашла поломку. Это делает инструменты пригодными для скриптов и хуков. +// ----------------------------------------------------------------------------- + +// Разбирает свой хвост командной строки целиком. Возвращает код процесса. +int RunTool(int argc, char** argv); diff --git a/engine/app.cpp b/engine/app.cpp new file mode 100644 index 0000000..3fd2c9e --- /dev/null +++ b/engine/app.cpp @@ -0,0 +1,107 @@ +#include "engine/app.h" + +#include + +#include "engine/config.h" + +namespace +{ + +// Безрамочное окно на весь монитор. Отдельно, потому что порядок здесь важен: +// raylib разворачивает окно по ТЕКУЩЕМУ монитору, поэтому сначала размер по +// монитору, потом снятие рамки. +void GoBorderless() +{ + const int mon = GetCurrentMonitor(); + const int w = GetMonitorWidth(mon); + const int h = GetMonitorHeight(mon); + if (w > 0 && h > 0) SetWindowSize(w, h); + ToggleBorderlessWindowed(); +} + +} // namespace + +bool App::Init(const AppConfig& cfg) +{ + if (cfg.vsync) SetConfigFlags(FLAG_VSYNC_HINT); + + const int w = (cfg.frameW > 0) ? cfg.frameW : INTERNAL_W; + const int h = (cfg.frameH > 0) ? cfg.frameH : INTERNAL_H; + + InitWindow(w * WINDOW_SCALE, h * WINDOW_SCALE, cfg.title); + + // Esc не закрывает окно сам. Приложение решает, что он значит: в игре это + // пауза, в редакторе — закрыть терминал. Выход по случайному нажатию + // посреди вылазки — не то, за что игрок скажет спасибо. + SetExitKey(KEY_NULL); + + if (cfg.borderless) GoBorderless(); + + fb.Init(w, h); + screen.Init(w, h); + return IsWindowReady(); +} + +void App::Shutdown() +{ + screen.Shutdown(); + CloseWindow(); +} + +bool App::NextFrame() +{ + if (quit || WindowShouldClose()) return false; + + if (IsKeyPressed(KEY_F11)) ToggleBorderlessWindowed(); + + winW = GetScreenWidth(); + winH = GetScreenHeight(); + fit = ScreenFitRect(fb.w, fb.h, winW, winH); + frameDt = GetFrameTime(); + return true; +} + +int App::Advance(bool halted) +{ + if (halted) + { + accumulator = 0.0; + return 0; + } + + accumulator += double(frameDt); + + int steps = 0; + while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME) + { + accumulator -= FIXED_DT; + ++steps; + } + + // Не успели за кадр — остаток выбрасывается целиком. Копить его значит + // копить долг, который никогда не отдать: следующий кадр будет таким же. + if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0; + return steps; +} + +float App::Alpha() const +{ + return float(accumulator / FIXED_DT); +} + +void App::Present() +{ + BeginDrawing(); + ClearBackground(BLACK); + screen.Present(fb, winW, winH); +} + +void App::Finish() +{ + EndDrawing(); +} + +void App::Screenshot(const char* file) const +{ + if (file) TakeScreenshot(file); +} diff --git a/engine/app.h b/engine/app.h new file mode 100644 index 0000000..7fcb8bd --- /dev/null +++ b/engine/app.h @@ -0,0 +1,92 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Приложение Tile2D: окно, кадр и ход времени. +// +// До этого окно, безрамочный режим, фреймбуфер, блиттер и fixed timestep жили +// двумя почти одинаковыми копиями — в игре и в редакторе. Третьему приложению +// пришлось бы написать третью, и она бы уже отличалась: где-то забытый +// `SetExitKey`, где-то другой потолок шагов, где-то апскейл не целым числом. +// +// Здесь НЕ инверсия управления: цикл остаётся у приложения, а App отвечает на +// вопросы «пора ли закрываться», «сколько шагов симуляции прошло» и «куда лёг +// кадр в окне». Колбэки сделали бы порядок «шаг — рендер — интерфейс» +// невидимым, а он у каждого приложения свой и в нём вся суть. +// +// Типичный цикл целиком: +// +// App app; +// app.Init({"Моя игра"}); +// while (app.NextFrame()) +// { +// for (int i = 0, n = app.Advance(false); i < n; ++i) game.Step(FIXED_DT); +// Render(game, app.fb, app.Alpha()); +// app.Present(); // кадр на экран +// DrawUi(app.fit); // поверх кадра, примитивами raylib +// app.Finish(); +// } +// app.Shutdown(); +// ----------------------------------------------------------------------------- +#include "engine/config.h" +#include "engine/framebuffer.h" + +struct AppConfig +{ + const char* title = "Tile2D"; + + // Безрамочное на весь монитор — обычный режим. Окно нужно для отладки и + // для съёмки картинок: на полном экране их не сравнить между собой. + bool borderless = true; + bool vsync = true; + + // Разрешение кадра. По умолчанию — размер из engine/config.h. + int frameW = INTERNAL_W; + int frameH = INTERNAL_H; +}; + +struct App +{ + Framebuffer fb; + ScreenBlitter screen; + + // --- состояние текущего кадра --------------------------------------------- + int winW = 0; + int winH = 0; + // Куда лёг кадр в окне: на безрамочном экране он центрирован и вокруг него + // чёрные поля. Интерфейс и оверлей обязаны считать координаты ЧЕРЕЗ это. + ScreenRect fit; + float frameDt = 0.0f; + + // Приложение может попроситься наружу само (пункт меню «выход», Esc в + // редакторе). Отдельный флаг, а не возврат из NextFrame: решение о выходе + // принимается в середине кадра, а закрыться надо в его конце. + bool quit = false; + + bool Init(const AppConfig& cfg); + void Shutdown(); + + // Начало кадра: закрытие окна, F11, размеры, ScreenFitRect, dt. + // false — пора выходить. + bool NextFrame(); + + // Сколько раз выполнить шаг симуляции в этом кадре. Шаг постоянный + // (FIXED_DT), лишнее время копится до следующего кадра, потолок + // MAX_STEPS_PER_FRAME спасает от спирали смерти. + // + // halted — пауза: время НЕ копится. Иначе после меню симуляция догоняла бы + // пропущенные секунды одним рывком. + int Advance(bool halted); + + // Доля незакрытого шага, [0..1). По ней интерполируется картинка: рендер + // идёт чаще симуляции, и без неё движение дёргалось бы на 60+ Гц. + float Alpha() const; + + void Present(); // BeginDrawing + кадр на экран; дальше можно рисовать UI + void Finish(); // EndDrawing + + // Снять кадр в файл. Здесь, а не в приложении, потому что снимать надо + // ПОСЛЕ Finish, и об этом легко забыть. + void Screenshot(const char* file) const; + +private: + double accumulator = 0.0; +}; diff --git a/engine/catalog.cpp b/engine/catalog.cpp new file mode 100644 index 0000000..e1e1baf --- /dev/null +++ b/engine/catalog.cpp @@ -0,0 +1,215 @@ +#include "engine/catalog.h" + +#include +#include +#include + +#include "engine/text_parse.h" + +namespace fs = std::filesystem; + +namespace +{ + +// color=116,52,52 — три байта через запятую. Отдельным ключом, а не тремя: +// цвет это одно свойство, и разносить его по трём ключам значило бы разрешить +// написать два из трёх. +bool ParseColor(const std::string& v, EntityDesc& out) +{ + const size_t c1 = v.find(','); + if (c1 == std::string::npos) return false; + const size_t c2 = v.find(',', c1 + 1); + if (c2 == std::string::npos) return false; + + out.r = uint8_t(text::ToInt(v.substr(0, c1), 180)); + out.g = uint8_t(text::ToInt(v.substr(c1 + 1, c2 - c1 - 1), 180)); + out.b = uint8_t(text::ToInt(v.substr(c2 + 1), 180)); + return true; +} + +// depth=3 с третьего этажа и глубже +// depth=1..4 с первого по четвёртый включительно +bool ParseDepth(const std::string& v, EntityDesc& out) +{ + const size_t dots = v.find(".."); + if (dots == std::string::npos) + { + out.minDepth = std::max(1, text::ToInt(v, 1)); + out.maxDepth = 0; + return true; + } + out.minDepth = std::max(1, text::ToInt(v.substr(0, dots), 1)); + out.maxDepth = text::ToInt(v.substr(dots + 2), 0); + return out.maxDepth <= 0 || out.maxDepth >= out.minDepth; +} + +// entity ключ=значение ... +bool ParseEntity(const std::vector& p, EntityDesc& out, std::string& why) +{ + if (p.size() < 2) + { + why = "у записи нет id"; + return false; + } + out.id = p[1]; + out.name = p[1]; + + bool haveClass = false; + for (size_t i = 2; i < p.size(); ++i) + { + std::string key, value; + if (!text::SplitKeyValue(p[i], key, value) || key.empty() || value.empty()) + { + why = "не ключ=значение: " + p[i]; + return false; + } + + if (key == "class") + { + if (value == "monster") out.cls = EntityClass::MONSTER; + else if (value == "prop") out.cls = EntityClass::PROP; + else if (value == "loot") out.cls = EntityClass::LOOT; + else { why = "неизвестный класс: " + value; return false; } + haveClass = true; + } + else if (key == "name") out.name = value; + else if (key == "sprite") out.sprite = (value == "-") ? std::string{} : value; + else if (key == "radius") out.radius = text::ToFloat(value, 0.34f); + else if (key == "danger") out.danger = text::ToFloat(value, 1.0f); + else if (key == "tier") out.tier = text::ToInt(value, 0); + else if (key == "color") + { + if (!ParseColor(value, out)) { why = "цвет не разбирается: " + value; return false; } + } + else if (key == "depth") + { + if (!ParseDepth(value, out)) { why = "глубина не разбирается: " + value; return false; } + } + else + { + // Молча глотать незнакомый ключ нельзя: опечатка в `dangre=3` + // означала бы тварь с ценой по умолчанию и ровно ноль признаков + // того, что что-то не так. + why = "неизвестный ключ: " + key; + return false; + } + } + + if (!haveClass) + { + why = "не указан class"; + return false; + } + + // Обстановка и предметы в бюджет опасности не входят по определению: + // ящик не делает этаж страшнее, он делает его обжитее. + if (out.cls != EntityClass::MONSTER) out.danger = 0.0f; + return true; +} + +} // namespace + +const char* EntityClassName(EntityClass cls) +{ + switch (cls) + { + case EntityClass::MONSTER: return "MONSTER"; + case EntityClass::PROP: return "PROP"; + default: return "LOOT"; + } +} + +bool EntityCatalog::Load(const std::string& path, std::string* err) +{ + std::ifstream in(path); + if (!in) + { + if (err) *err = "cannot open catalog: " + path; + return false; + } + + // Разбираем в СВОЙ вектор и подменяем только на успехе: битая правка + // каталога не должна оставлять редактор с половиной палитры. + std::vector parsed; + std::string line; + int lineNo = 0; + + while (std::getline(in, line)) + { + ++lineNo; + const std::vector p = text::Split(text::StripComment(line)); + if (p.empty()) continue; + + if (p[0] != "entity") + { + if (err) *err = "line " + std::to_string(lineNo) + ": expected `entity`"; + return false; + } + + EntityDesc d; + std::string why; + if (!ParseEntity(p, d, why)) + { + if (err) *err = "line " + std::to_string(lineNo) + ": " + why; + return false; + } + + // Дубль id молча перекрыл бы предыдущую запись, и в редакторе появились + // бы две одинаковые кнопки палитры с разным поведением. + for (const EntityDesc& prev : parsed) + { + if (prev.id != d.id) continue; + if (err) *err = "line " + std::to_string(lineNo) + ": duplicate id `" + d.id + "`"; + return false; + } + + parsed.push_back(std::move(d)); + } + + if (parsed.empty()) + { + if (err) *err = "empty catalog: " + path; + return false; + } + + entries = std::move(parsed); + return true; +} + +int EntityCatalog::IndexOf(const std::string& id) const +{ + for (size_t i = 0; i < entries.size(); ++i) + if (entries[i].id == id) return int(i); + return -1; +} + +const EntityDesc* EntityCatalog::Find(const std::string& id) const +{ + const int i = IndexOf(id); + return (i >= 0) ? &entries[size_t(i)] : nullptr; +} + +int EntityCatalog::CountOf(EntityClass cls) const +{ + int n = 0; + for (const EntityDesc& d : entries) + if (d.cls == cls) ++n; + return n; +} + +bool EntityCatalog::HasAnyAt(EntityClass cls, int depth) const +{ + for (const EntityDesc& d : entries) + if (d.cls == cls && d.InDepth(depth)) return true; + return false; +} + +int EntityCatalog::DeepestIntroduction() const +{ + int deepest = 1; + for (const EntityDesc& d : entries) deepest = std::max(deepest, d.minDepth); + return deepest; +} + +// FindCatalogPath() здесь БОЛЬШЕ НЕТ — по той же причине, что и FindRoomsDir: +// путь к содержимому называет проект (engine/project.h). diff --git a/engine/catalog.h b/engine/catalog.h new file mode 100644 index 0000000..c6d6721 --- /dev/null +++ b/engine/catalog.h @@ -0,0 +1,111 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Каталог размещаемых сущностей: ЧТО вообще можно поставить в комнату и на +// какой глубине это встречается. +// +// Это граница между движком и игрой. Редактор про EnemyKind и ItemId не знает +// и знать не может — он линкуется только с движком. Всё, что он видит, — +// строковые id из этого каталога: `shambler`, `crate`, `plate`. Игра переводит +// id в свои перечисления одной таблицей (sim/spawn_catalog.h), и это +// ЕДИНСТВЕННОЕ место перевода. +// +// Почему не общий enum на движок и игру: тогда добавление твари правило бы +// заголовок движка, редактор пришлось бы пересобирать вместе с игрой, а +// «движок» превратился бы в подкаталог игры. Здесь же новая тварь — это строка +// в текстовом файле плюс одна строка в таблице перевода. +// +// --- почему формат key=value ------------------------------------------------- +// Первая версия каталога была позиционной: цвет, радиус и спрайт стояли по +// местам. Игра про бесконечный спуск потребовала добавить сущности глубину, +// опасность и качество, и позиционный формат на этом кончился: каждое новое +// свойство ломало бы все уже написанные строки. Ключи же дописываются с краю +// и не трогают ничего. +// ----------------------------------------------------------------------------- +#include +#include +#include + +// Роль сущности. Редактору важны эти три случая: они по-разному ставятся и +// по-разному попадают в уровень. +enum class EntityClass : uint8_t +{ + MONSTER = 0, // тварь: ходит, бьёт, участвует в зачистке и тратит бюджет опасности + PROP, // обстановка: ящик, бочка, шкафчик. Опасности не несёт + LOOT, // предмет: лежит на полу, подбирается на ходу +}; + +const char* EntityClassName(EntityClass cls); + +struct EntityDesc +{ + std::string id; // то, что попадает в файл комнаты + std::string name; // подпись в палитре редактора + EntityClass cls = EntityClass::MONSTER; + + uint8_t r = 180, g = 180, b = 180; + float radius = 0.34f; + + // Имя ассета SpriteForge (`sf list`), пусто — рисовать капсулу по цвету. + // Имя, а не числовой id: каталог читают данными, а generated-заголовок + // с константами SF_ASSET_* принадлежит игре, а не движку. + std::string sprite; + + // --- глубина -------------------------------------------------------------- + // С какого этажа сущность вообще может появиться. Это главный рычаг всей + // игры: «чем глубже, тем страшнее» — это в первую очередь ЗДЕСЬ, а не в + // множителях урона. Громила на первом этаже ломает не баланс, а обещание. + int minDepth = 1; + // По какой этаж включительно; 0 — до бесконечности. Нужен, чтобы мелочь + // первых этажей перестала занимать место на глубине. + int maxDepth = 0; + + // Цена в бюджете опасности этажа (engine/progression.h). Не урон и не HP, + // а «сколько места в голове игрока эта тварь занимает»: бюджет — это то, + // чем этаж отличается по СЛОЖНОСТИ, а не по числу тел. + float danger = 1.0f; + + // Качество предмета для class=loot. Этаж выдаёт лут своего тира и ниже, + // поэтому карабин не исчезает на глубине — он просто перестаёт быть + // находкой. + int tier = 0; + + bool InDepth(int depth) const + { + return depth >= minDepth && (maxDepth <= 0 || depth <= maxDepth); + } +}; + +// Порядок записей — порядок палитры в редакторе. Он же порядок в файле: +// каталог правится руками, и перетасовывать его при загрузке нельзя. +struct EntityCatalog +{ + std::vector entries; + + // Разобрать текстовый каталог. false — файла нет или он битый; err (если + // передан) получает первую же осечку с номером строки. + bool Load(const std::string& path, std::string* err = nullptr); + + int Count() const { return int(entries.size()); } + const EntityDesc& At(int i) const { return entries[size_t(i)]; } + + // -1 — такого id в каталоге нет. Комната, ссылающаяся на выпавший id, + // не должна ронять ни редактор, ни игру: точка просто не сработает. + int IndexOf(const std::string& id) const; + const EntityDesc* Find(const std::string& id) const; + + // Сколько записей данного класса. Палитра редактора разбита на вкладки. + int CountOf(EntityClass cls) const; + + // Есть ли на этой глубине хоть одна тварь и хоть один предмет. Отвечает на + // единственный вопрос, который может убить забег: «а есть ли чем населить + // сотый этаж». + bool HasAnyAt(EntityClass cls, int depth) const; + + // Самая глубокая точка, дальше которой каталог перестаёт что-либо + // добавлять: максимум minDepth по всем записям. Дальше неё этаж собирается + // из того же набора, и это нормально — бесконечность не требует + // бесконечного каталога, она требует, чтобы он не кончался. + int DeepestIntroduction() const; +}; + +// Где лежит каталог — знает проект (engine/project.h). Движок сам его не ищет. diff --git a/engine/config.h b/engine/config.h new file mode 100644 index 0000000..6b7f933 --- /dev/null +++ b/engine/config.h @@ -0,0 +1,51 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Tile2D — размеры, которые нельзя поменять, не пересобрав движок. +// +// Это НЕ настройки проекта. Настройки живут в project.txt и читаются в рантайме +// (engine/project.h); сюда попадает только то, от чего зависят размеры массивов +// и что поэтому обязано быть известно компилятору. +// +// Раньше эти числа лежали в src/tuning.h — то есть движок собирался только +// вместе с игрой и знал её имя. Теперь наоборот: tuning.h включает этот файл, +// а движок про игру не знает ничего. +// ----------------------------------------------------------------------------- + +// --- кадр -------------------------------------------------------------------- +// Внутреннее разрешение софтверного фреймбуфера. Апскейл до окна — ЦЕЛЫМ +// множителем, поэтому окно кратно кадру. +constexpr int INTERNAL_W = 480; +constexpr int INTERNAL_H = 270; +constexpr int WINDOW_SCALE = 3; +constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE; +constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE; + +// --- геометрия тайла --------------------------------------------------------- +// Размер тайла в ИЗОМЕТРИИ: ромб 32x16 (наклон 2:1) и стена 32x32. +// +// Проекция может взять другой размер (engine/projection.h): вид сверху рисует +// квадрат 32x32. Эти константы — размер по умолчанию и верхняя оценка для +// отсечения по краю кадра, а не единственно возможный тайл. +constexpr int TILE_W = 32; +constexpr int TILE_H = 16; +constexpr int TILE_HALF_W = TILE_W / 2; +constexpr int TILE_HALF_H = TILE_H / 2; +constexpr int WALL_H = 32; + +// Самый большой тайл, который может выдать любая проекция. По нему считается +// запас отсечения: спрайт, торчащий в кадр краем, обязан дорисоваться. +constexpr int TILE_MAX_W = 32; +constexpr int TILE_MAX_H = 32; + +// --- шаг симуляции ----------------------------------------------------------- +// Fixed timestep: шаг постоянный, кадр интерполируется. MAX_STEPS_PER_FRAME — +// защита от спирали смерти (долгий кадр не должен догоняться вечно). +constexpr float FIXED_DT = 1.0f / 60.0f; +constexpr int MAX_STEPS_PER_FRAME = 5; + +// --- карта ------------------------------------------------------------------- +// Тайлмап — плоский массив фиксированного размера, поэтому размер карты +// компилтаймовый. Проект меньше этого предела занимает часть карты; больше — +// не соберётся, и это честнее, чем молча обрезать уровень. +constexpr int MAP_W = 48; +constexpr int MAP_H = 48; diff --git a/src/render/framebuffer.cpp b/engine/framebuffer.cpp similarity index 91% rename from src/render/framebuffer.cpp rename to engine/framebuffer.cpp index b4902e4..295b361 100644 --- a/src/render/framebuffer.cpp +++ b/engine/framebuffer.cpp @@ -1,4 +1,4 @@ -#include "render/framebuffer.h" +#include "engine/framebuffer.h" #include @@ -319,6 +319,27 @@ void ScreenBlitter::Shutdown() tex = nullptr; } +// Прямоугольник кадра в окне: ЦЕЛЫЙ масштаб и центрирование. +// +// На borderless fullscreen окно почти никогда не кратно 480x270, и дробное +// растяжение размывает пиксель: одни строки удваиваются, соседние нет. +// Поэтому берём наибольший целый масштаб, а остаток отдаём чёрным полям. +ScreenRect ScreenFitRect(int fbW, int fbH, int windowW, int windowH) +{ + if (fbW <= 0 || fbH <= 0) return {}; + + int scale = std::min(windowW / fbW, windowH / fbH); + if (scale < 1) scale = 1; // окно меньше кадра: лучше обрезать, чем мылить + + ScreenRect r; + r.scale = scale; + r.w = fbW * scale; + r.h = fbH * scale; + r.x = (windowW - r.w) / 2; + r.y = (windowH - r.h) / 2; + return r; +} + void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) const { if (!tex) return; @@ -326,7 +347,8 @@ void ScreenBlitter::Present(const Framebuffer& fb, int windowW, int windowH) con Texture2D* t = static_cast(tex); UpdateTexture(*t, fb.px.data()); + const ScreenRect fit = ScreenFitRect(fb.w, fb.h, windowW, windowH); const Rectangle src = {0.0f, 0.0f, float(fb.w), float(fb.h)}; - const Rectangle dst = {0.0f, 0.0f, float(windowW), float(windowH)}; + const Rectangle dst = {float(fit.x), float(fit.y), float(fit.w), float(fit.h)}; DrawTexturePro(*t, src, dst, {0.0f, 0.0f}, 0.0f, WHITE); } diff --git a/src/render/framebuffer.h b/engine/framebuffer.h similarity index 95% rename from src/render/framebuffer.h rename to engine/framebuffer.h index b6348c4..00bf048 100644 --- a/src/render/framebuffer.h +++ b/engine/framebuffer.h @@ -179,6 +179,17 @@ struct Framebuffer // Финальный вывод: UpdateTexture + DrawTexturePro, фильтрация POINT. // Единственное место рендера, знающее про raylib. // ----------------------------------------------------------------------------- + +// Куда лёг кадр внутри окна. Оверлей и UI обязаны считать координаты ЧЕРЕЗ него: +// на borderless fullscreen кадр центрирован и вокруг него чёрные поля. +struct ScreenRect +{ + int x = 0, y = 0, w = 0, h = 0; + int scale = 1; +}; + +ScreenRect ScreenFitRect(int fbW, int fbH, int windowW, int windowH); + struct ScreenBlitter { void Init(int width, int height); diff --git a/src/core/hash.h b/engine/hash.h similarity index 100% rename from src/core/hash.h rename to engine/hash.h diff --git a/engine/level_gen.cpp b/engine/level_gen.cpp new file mode 100644 index 0000000..e237885 --- /dev/null +++ b/engine/level_gen.cpp @@ -0,0 +1,665 @@ +#include "engine/level_gen.h" + +#include + +namespace +{ + +// Индекс слота и обратно. Слоты нумеруются построчно, как тайлы карты. +inline int SlotIdx(int gx, int gy) { return gy * LEVEL_GRID + gx; } + +// Доля лишних связей поверх остовного дерева. Ноль — этаж-дерево: из любой +// комнаты ровно один путь назад, и отряд ходит по нему туда-обратно. Единица — +// решётка без тупиков, в которой не читается планировка. Треть — компромисс: +// петли есть, тупики тоже. +constexpr float EXTRA_LINK_CHANCE = 0.33f; + +// Ближе этого к точке старта долитые твари не ставятся. Отряд обязан успеть +// встать в строй: тварь, выросшая вплотную к точке входа, отнимает не +// сложностью, а тем, что решение принять негде. +constexpr float FILL_MIN_DIST_FROM_START = 9.0f; + +// Сколько раз генератор пытается долить бюджет, прежде чем сдаться. Защита от +// вырожденного этажа, где свободных далёких клеток просто нет. +constexpr int FILL_ATTEMPTS = 200; + +// Выбрать комнату по весу среди разрешённых на этом этаже. Вес — это «как +// часто она встречается», и он живёт в самом файле комнаты: набор правится +// добавлением файла, а не правкой кода. +int PickRoom(const RoomLibrary& lib, LevelRng& rng, int depth, bool needStart) +{ + int total = 0; + for (const Room& r : lib.rooms) + { + if (!r.InDepth(depth)) continue; + if (needStart && !r.start) continue; + total += std::max(1, r.weight); + } + if (total <= 0) return -1; + + int roll = rng.Below(total); + for (size_t i = 0; i < lib.rooms.size(); ++i) + { + const Room& r = lib.rooms[i]; + if (!r.InDepth(depth)) continue; + if (needStart && !r.start) continue; + roll -= std::max(1, r.weight); + if (roll < 0) return int(i); + } + return -1; +} + +// ----------------------------------------------------------------------------- +// Прорубка. Коридор шириной 2: отряд из пяти человек в проходе шириной 1 идёт +// строго гуськом, и весь смысл строя пропадает. Ширина берётся ровно такой же, +// как у проходов захардкоженной карты (docs/04-world.md) — иначе поведение +// отряда в коридоре пришлось бы настраивать заново. +// ----------------------------------------------------------------------------- +constexpr int CORRIDOR_W = 2; + +// Внешнее кольцо карты остаётся камнем всегда: вне карты считается стеной, и +// коридор, дошедший до края, дал бы отряду выйти в это «вне». +inline bool CarveOk(int x, int y) +{ + return x >= 1 && y >= 1 && x < MAP_W - 1 && y < MAP_H - 1; +} + +void CarveCell(Tilemap& map, int x, int y, uint8_t theme) +{ + if (!CarveOk(x, y)) return; + map.Set(x, y, Tile::EMPTY); + map.SetTheme(x, y, theme); +} + +// Отрезок по оси, шириной CORRIDOR_W в перпендикулярном направлении. +void CarveRun(Tilemap& map, int x0, int y0, int x1, int y1, uint8_t theme) +{ + const int stepX = (x1 > x0) ? 1 : (x1 < x0 ? -1 : 0); + const int stepY = (y1 > y0) ? 1 : (y1 < y0 ? -1 : 0); + // Толщина набирается поперёк хода: у вертикального прогона — по x. + const int thickX = (stepX == 0) ? 1 : 0; + const int thickY = (stepY == 0) ? 1 : 0; + + int x = x0, y = y0; + for (;;) + { + for (int t = 0; t < CORRIDOR_W; ++t) CarveCell(map, x + t * thickX, y + t * thickY, theme); + if (x == x1 && y == y1) break; + x += stepX; + y += stepY; + } +} + +// Где комната встречает коридор со стороны side: дверь, если она есть, иначе +// середина стены. Второе — не поблажка: комната без двери на нужной стороне +// остаётся легальной, просто генератор пробьёт её сам, и это видно на карте. +void ContactPoint(const Room& r, RoomSide side, int& outX, int& outY) +{ + if (r.FirstDoorOn(side, outX, outY)) return; + r.DefaultBreachOn(side, outX, outY); +} + +// Сшить два соседних слота. Путь — «вышли из двери, добрались до середины +// зазора, повернули, дошли до чужой двери»: три отрезка по осям, никакой +// диагонали. Диагональный коридор в изометрии читается как лестница и стоит +// вдвое больше тайлов. +// +// Коридор берёт тему ЭТАЖА, а не комнаты: комнаты на этаже могут быть разных +// тем, и если коридор наследует чью-то, переход между комнатами читается как +// продолжение одной из них, а не как переход. +void ConnectSlots(Tilemap& map, const RoomLibrary& lib, const GeneratedLevel& lvl, int ga, int gb, + RoomSide dir, uint8_t theme) +{ + const LevelSlot& A = lvl.slots[ga]; + const LevelSlot& B = lvl.slots[gb]; + if (A.roomIndex < 0 || B.roomIndex < 0) return; + + const Room& ra = lib.rooms[size_t(A.roomIndex)]; + const Room& rb = lib.rooms[size_t(B.roomIndex)]; + + int ax = 0, ay = 0, bx = 0, by = 0; + ContactPoint(ra, dir, ax, ay); + ContactPoint(rb, OppositeSide(dir), bx, by); + + // В координаты карты. + ax += A.originX; ay += A.originY; + bx += B.originX; by += B.originY; + + const int dx = SideDX(dir); + const int dy = SideDY(dir); + + // Сама дверь прорубается всегда: если её пробили по умолчанию, стены на + // этом месте больше нет, а если это нарисованная дверь — она и так пол. + CarveCell(map, ax, ay, theme); + CarveCell(map, bx, by, theme); + // Дверь шириной в один тайл упёрлась бы в коридор шириной два: узкое место + // осталось бы ровно в проёме, там, где его меньше всего ждёшь. + CarveCell(map, ax + (dy != 0 ? 1 : 0), ay + (dx != 0 ? 1 : 0), theme); + CarveCell(map, bx + (dy != 0 ? 1 : 0), by + (dx != 0 ? 1 : 0), theme); + + // Шаг наружу из обеих дверей — в зазор между комнатами. + const int aox = ax + dx, aoy = ay + dy; + const int box = bx - dx, boy = by - dy; + + if (dx != 0) + { + const int midX = (aox + box) / 2; + CarveRun(map, aox, aoy, midX, aoy, theme); + CarveRun(map, midX, aoy, midX, boy, theme); + CarveRun(map, midX, boy, box, boy, theme); + } + else + { + const int midY = (aoy + boy) / 2; + CarveRun(map, aox, aoy, aox, midY, theme); + CarveRun(map, aox, midY, box, midY, theme); + CarveRun(map, box, midY, box, boy, theme); + } +} + +// Отпечатать комнату в карту. Дверь — это пол: генератор сам решает, куда +// от неё тянуть коридор, и оставлять её стеной означало бы запереть комнату. +void StampRoom(Tilemap& map, const Room& r, int ox, int oy) +{ + for (int y = 0; y < r.h; ++y) + for (int x = 0; x < r.w; ++x) + { + const int mx = ox + x; + const int my = oy + y; + if (!Tilemap::InBounds(mx, my)) continue; + map.Set(mx, my, (r.At(x, y) == RoomCell::WALL) ? Tile::WALL : Tile::EMPTY); + map.SetTheme(mx, my, r.theme); + } +} + +// Ближайший к (cx, cy) проходимый тайл. Спираль, а не поиск в ширину: искать +// надо на два-три тайла, и очередь на такой дистанции дороже перебора. +bool NearestFloor(const Tilemap& map, int cx, int cy, int& outX, int& outY) +{ + for (int rad = 0; rad < ROOM_SLOT; ++rad) + { + for (int dy = -rad; dy <= rad; ++dy) + for (int dx = -rad; dx <= rad; ++dx) + { + if (std::max(std::abs(dx), std::abs(dy)) != rad) continue; + const int x = cx + dx; + const int y = cy + dy; + if (!Tilemap::InBounds(x, y) || map.IsWall(x, y)) continue; + outX = x; + outY = y; + return true; + } + } + return false; +} + +// Поиск в ширину от точки старта: расстояние в шагах до каждого тайла, -1 — +// недостижим. Одна заливка отвечает сразу на три вопроса: что достижимо, где +// поставить спуск (самый дальний тайл) и куда можно класть лут. +// +// Своя очередь вместо рекурсии: у карты 48x48 худший случай — 2300 вложенных +// вызовов. +void DistancesFrom(const Tilemap& map, int sx, int sy, std::vector& dist) +{ + dist.assign(size_t(MAP_W) * MAP_H, -1); + if (!Tilemap::InBounds(sx, sy) || map.IsWall(sx, sy)) return; + + std::vector queue; + queue.reserve(size_t(MAP_W) * MAP_H); + queue.push_back(sy * MAP_W + sx); + dist[size_t(queue.back())] = 0; + + for (size_t head = 0; head < queue.size(); ++head) + { + const int cur = queue[head]; + const int cx = cur % MAP_W; + const int cy = cur / MAP_W; + const int d = dist[size_t(cur)]; + + const int dx[4] = {1, -1, 0, 0}; + const int dy[4] = {0, 0, 1, -1}; + for (int k = 0; k < 4; ++k) + { + const int nx = cx + dx[k]; + const int ny = cy + dy[k]; + if (!Tilemap::InBounds(nx, ny) || map.IsWall(nx, ny)) continue; + const size_t ni = size_t(ny) * MAP_W + nx; + if (dist[ni] >= 0) continue; + dist[ni] = d + 1; + queue.push_back(int(ni)); + } + } +} + +// --- население --------------------------------------------------------------- + +// Один вариант, доживший до фильтра по этажу и бюджету. +struct Allowed +{ + const EntityDesc* desc; + int weight; +}; + +// Отобрать из таблицы точки то, что разрешено на этом этаже и по карману. +// Обстановка бюджета не тратит и проходит всегда. +void CollectAllowed(const SpawnPoint& p, const EntityCatalog& catalog, int depth, float budget, + std::vector& out) +{ + out.clear(); + for (const SpawnOption& o : p.options) + { + if (o.weight <= 0) continue; + const EntityDesc* d = catalog.Find(o.id); + if (!d || !d->InDepth(depth)) continue; + if (d->danger > budget) continue; + out.push_back(Allowed{d, o.weight}); + } +} + +const EntityDesc* PickAllowed(const std::vector& list, float roll01) +{ + int total = 0; + for (const Allowed& a : list) total += a.weight; + if (total <= 0) return nullptr; + + float acc = roll01 * float(total); + for (const Allowed& a : list) + { + acc -= float(a.weight); + if (acc < 0.0f) return a.desc; + } + return list.back().desc; +} + +// Самая дешёвая тварь, доступная на этаже. Ею доливается остаток бюджета, и +// ею же проверяется, есть ли смысл продолжать долив. +const EntityDesc* CheapestMonster(const EntityCatalog& catalog, int depth) +{ + const EntityDesc* best = nullptr; + for (int i = 0; i < catalog.Count(); ++i) + { + const EntityDesc& d = catalog.At(i); + if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue; + if (!best || d.danger < best->danger) best = &d; + } + return best; +} + +// Тварь для долива. Здесь живёт главное правило глубины: когда МЕСТ мало, а +// бюджета много, этаж обязан становиться злее, а не многолюднее. +// +// Тел в мире не может быть больше MAX_LEVEL_SPAWNS, и на глубине бюджет +// неизбежно перерастает это число. Если бы долив продолжал брать кого попало, +// глубже определённого этажа игра переставала бы меняться вовсе: все места +// заняты мелочью, бюджет не выбран, двадцатый этаж равен пятнадцатому. +// +// Поэтому на каждое место считается, сколько опасности ОБЯЗАНО на него +// прийтись, и всё, что дешевле, отбрасывается. По мере заполнения карты порог +// растёт сам собой, и хвост этажа набирается самыми дорогими тварями. +const EntityDesc* PickFillMonster(const EntityCatalog& catalog, int depth, float budget, + int slotsLeft, float roll01) +{ + const float needPerSlot = (slotsLeft > 0) ? budget / float(slotsLeft) : budget; + + std::vector list; + std::vector fallback; + + for (int i = 0; i < catalog.Count(); ++i) + { + const EntityDesc& d = catalog.At(i); + if (d.cls != EntityClass::MONSTER || d.danger <= 0.0f || !d.InDepth(depth)) continue; + if (d.danger > budget) continue; + + // Вес пропорционален цене: дорогое выпадает чаще, но дешёвое не + // исчезает — этаж из одних громил тоже перестаёт быть интересным. + const Allowed a{&d, std::max(1, int(d.danger * 2.0f))}; + fallback.push_back(a); + if (d.danger >= needPerSlot) list.push_back(a); + } + + // Порог недостижим (самая дорогая тварь дешевле, чем нужно на место) — + // берём что есть: недобрать бюджет лучше, чем не поставить никого. + return PickAllowed(list.empty() ? fallback : list, roll01); +} + +// Предмет для этажа: тир не выше, чем у этажа, и глубина подходит. Ближе к +// потолку тира — вероятнее: находка обязана становиться лучше с глубиной, но +// бинт не должен исчезать совсем. +const EntityDesc* PickLoot(const EntityCatalog& catalog, int depth, int tierCap, float roll01) +{ + std::vector list; + for (int i = 0; i < catalog.Count(); ++i) + { + const EntityDesc& d = catalog.At(i); + if (d.cls != EntityClass::LOOT || !d.InDepth(depth)) continue; + if (d.tier > tierCap) continue; + const int gap = tierCap - d.tier; + list.push_back(Allowed{&d, (gap == 0) ? 4 : (gap == 1 ? 2 : 1)}); + } + return PickAllowed(list, roll01); +} + +} // namespace + +bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor, + uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err) +{ + if (lib.rooms.empty()) + { + if (err) *err = "room set is empty"; + return false; + } + + const int depth = std::max(1, floor.depth); + if (lib.CountAt(depth) <= 0) + { + if (err) *err = "no rooms allowed at depth " + std::to_string(depth); + return false; + } + + // Сид этажа мешается с глубиной: иначе один и тот же сид давал бы на всех + // этажах одну планировку, отличающуюся только населением. + LevelRng rng(seed ^ (uint32_t(depth) * 0x9E3779B9u)); + + GeneratedLevel lvl; + lvl.seed = seed; + lvl.floor = floor; + + // --- 1. кто где стоит ---------------------------------------------------- + for (int gy = 0; gy < LEVEL_GRID; ++gy) + for (int gx = 0; gx < LEVEL_GRID; ++gx) + { + const int idx = SlotIdx(gx, gy); + const int pick = PickRoom(lib, rng, depth, false); + if (pick < 0) continue; + + const Room& r = lib.rooms[size_t(pick)]; + lvl.slots[idx].roomIndex = pick; + // Комната центрируется в слоте: зазор до соседей одинаков со всех + // сторон, и коридору есть куда лечь при любой её ширине. + lvl.slots[idx].originX = gx * ROOM_SLOT + (ROOM_SLOT - r.w) / 2; + lvl.slots[idx].originY = gy * ROOM_SLOT + (ROOM_SLOT - r.h) / 2; + ++lvl.roomsPlaced; + } + + if (lvl.roomsPlaced == 0) + { + if (err) *err = "no room could be picked"; + return false; + } + + // --- 2. стартовая комната ------------------------------------------------ + // Отряд обязан начинать там, где это задумано автором комнаты. Если после + // розыгрыша ни одной стартовой не выпало — ставим её принудительно в + // случайный слот, а не двигаем отряд в первую попавшуюся комнату. + int startSlot = -1; + for (int i = 0; i < LEVEL_SLOTS; ++i) + { + const int ri = lvl.slots[i].roomIndex; + if (ri >= 0 && lib.rooms[size_t(ri)].start) { startSlot = i; break; } + } + if (startSlot < 0 && lib.HasStartRoomAt(depth)) + { + const int forced = PickRoom(lib, rng, depth, true); + if (forced >= 0) + { + startSlot = rng.Below(LEVEL_SLOTS); + const Room& r = lib.rooms[size_t(forced)]; + if (lvl.slots[startSlot].roomIndex < 0) ++lvl.roomsPlaced; + lvl.slots[startSlot].roomIndex = forced; + lvl.slots[startSlot].originX = + (startSlot % LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.w) / 2; + lvl.slots[startSlot].originY = + (startSlot / LEVEL_GRID) * ROOM_SLOT + (ROOM_SLOT - r.h) / 2; + } + } + if (startSlot < 0) + for (int i = 0; i < LEVEL_SLOTS && startSlot < 0; ++i) + if (lvl.slots[i].roomIndex >= 0) startSlot = i; + + // --- 3. камень, потом комнаты ------------------------------------------- + for (int y = 0; y < MAP_H; ++y) + for (int x = 0; x < MAP_W; ++x) + { + map.Set(x, y, Tile::WALL); + map.SetTheme(x, y, floor.theme); + } + + for (int i = 0; i < LEVEL_SLOTS; ++i) + { + const LevelSlot& s = lvl.slots[i]; + if (s.roomIndex < 0) continue; + StampRoom(map, lib.rooms[size_t(s.roomIndex)], s.originX, s.originY); + } + + // --- 4. остовное дерево по слотам --------------------------------------- + // Обход в глубину со случайным порядком соседей. Дерево гарантирует, что + // из стартовой комнаты достижима каждая: этаж, часть которого отрезана, + // это не «интересная планировка», а брак. + bool visited[LEVEL_SLOTS] = {}; + std::vector stack; + stack.push_back(startSlot >= 0 ? startSlot : 0); + visited[stack.back()] = true; + + // Связи запоминаются и режутся ПОСЛЕ обхода: прорубка на ходу меняла бы + // карту под ногами у следующих шагов, и порядок обхода стал бы важен. + struct Link { int a; int b; RoomSide dir; }; + std::vector links; + + while (!stack.empty()) + { + const int cur = stack.back(); + const int cx = cur % LEVEL_GRID; + const int cy = cur / LEVEL_GRID; + + // Непосещённые соседи в случайном порядке. + int cand[4]; + RoomSide dirs[4]; + int n = 0; + for (int d = 0; d < 4; ++d) + { + const RoomSide side = RoomSide(d); + const int nx = cx + SideDX(side); + const int ny = cy + SideDY(side); + if (nx < 0 || ny < 0 || nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue; + const int ni = SlotIdx(nx, ny); + if (visited[ni] || lvl.slots[ni].roomIndex < 0) continue; + cand[n] = ni; + dirs[n] = side; + ++n; + } + + if (n == 0) { stack.pop_back(); continue; } + + const int k = rng.Below(n); + links.push_back(Link{cur, cand[k], dirs[k]}); + visited[cand[k]] = true; + stack.push_back(cand[k]); + } + + // --- 5. лишние связи ----------------------------------------------------- + for (int gy = 0; gy < LEVEL_GRID; ++gy) + for (int gx = 0; gx < LEVEL_GRID; ++gx) + { + const int a = SlotIdx(gx, gy); + if (lvl.slots[a].roomIndex < 0) continue; + + // Только восток и юг: иначе каждая пара рассматривалась бы дважды. + const RoomSide sides[2] = {RoomSide::EAST, RoomSide::SOUTH}; + for (const RoomSide side : sides) + { + const int nx = gx + SideDX(side); + const int ny = gy + SideDY(side); + if (nx >= LEVEL_GRID || ny >= LEVEL_GRID) continue; + + const int b = SlotIdx(nx, ny); + if (lvl.slots[b].roomIndex < 0) continue; + + const bool already = + std::any_of(links.begin(), links.end(), [a, b](const Link& l) { + return (l.a == a && l.b == b) || (l.a == b && l.b == a); + }); + if (already) continue; + if (rng.Float01() >= EXTRA_LINK_CHANCE) continue; + + links.push_back(Link{a, b, side}); + } + } + + for (const Link& l : links) + { + ConnectSlots(map, lib, lvl, l.a, l.b, l.dir, floor.theme); + ++lvl.corridors; + } + + // --- 6. где встаёт отряд и где спуск ------------------------------------- + // Старт считается ДО населения: и долив тварей, и лут смотрят на дистанцию + // от него, а заливка карты ни одного броска не тратит, поэтому сид от + // перестановки не страдает. + // Запасная точка — центр карты, а не место старта отряда из игры: движок + // не знает, что за отряд к нему приедет. Реально она берётся только если + // стартовой комнаты нет, а без неё этаж и так не собирается. + lvl.squadStart = Vec2{float(MAP_W) * 0.5f, float(MAP_H) * 0.5f}; + if (startSlot >= 0) + { + const LevelSlot& s = lvl.slots[startSlot]; + const Room& r = lib.rooms[size_t(s.roomIndex)]; + int sx = 0, sy = 0; + if (NearestFloor(map, s.originX + r.w / 2, s.originY + r.h / 2, sx, sy)) + lvl.squadStart = Vec2{float(sx) + 0.5f, float(sy) + 0.5f}; + } + + // Вошли и выйдем наверх в одном месте: лестница одна. + lvl.extractPos = lvl.squadStart; + + std::vector dist; + DistancesFrom(map, int(lvl.squadStart.x), int(lvl.squadStart.y), dist); + + // Спуск — самый дальний ДОСТИЖИМЫЙ тайл. Не «дальняя комната»: комната + // может оказаться отрезанной, а этот выбор недостижимым быть не может + // по построению. + int exitTile = -1; + std::vector freeTiles; // достижимый пол: пригодится луту и доливу + freeTiles.reserve(1024); + for (int i = 0; i < MAP_W * MAP_H; ++i) + { + if (dist[size_t(i)] < 0) continue; + ++lvl.reachableTiles; + freeTiles.push_back(i); + if (exitTile < 0 || dist[size_t(i)] > dist[size_t(exitTile)]) exitTile = i; + } + + lvl.exitPos = lvl.squadStart; + if (exitTile >= 0) + { + lvl.exitPos = Vec2{float(exitTile % MAP_W) + 0.5f, float(exitTile / MAP_W) + 0.5f}; + lvl.exitDistance = dist[size_t(exitTile)]; + } + + // Занятые клетки: на спуск и друг на друга ничего не кладём. + std::vector taken(size_t(MAP_W) * MAP_H, 0); + if (exitTile >= 0) taken[size_t(exitTile)] = 1; + + // --- 7. население комнатных точек ---------------------------------------- + // Обход слотов в фиксированном порядке: розыгрыш обязан зависеть от сида, + // а не от того, в каком порядке мы решили пройтись по карте. + float budget = floor.dangerBudget; + std::vector allowed; + + for (int i = 0; i < LEVEL_SLOTS; ++i) + { + const LevelSlot& s = lvl.slots[i]; + if (s.roomIndex < 0) continue; + const Room& r = lib.rooms[size_t(s.roomIndex)]; + + for (const SpawnPoint& p : r.spawns) + { + // Броски делаются ВСЕГДА и в том же порядке, даже когда точка не + // сработает: иначе полный список уводил бы последовательность вбок + // относительно неполного, и сид перестал бы значить одно и то же. + const float rollChance = rng.Float01(); + const float rollPick = rng.Float01(); + + // Плотность этажа — это множитель к шансу точки. Шанс из комнаты + // остаётся тем, что задумал автор; глубина его только сгущает. + const float chance = std::min(1.0f, p.chance * floor.spawnChanceScale); + if (rollChance >= chance) continue; + + CollectAllowed(p, catalog, depth, budget, allowed); + const EntityDesc* d = PickAllowed(allowed, rollPick); + if (!d) continue; + + const int mx = s.originX + p.x; + const int my = s.originY + p.y; + if (!Tilemap::InBounds(mx, my) || map.IsWall(mx, my)) continue; + + const size_t ti = size_t(my) * MAP_W + mx; + if (dist[ti] < 0) { ++lvl.spawnsUnreachable; continue; } + + // Потолок — свойство симуляции (маска задетых целей в клинке, + // tuning.h). Молча срезать хвост нельзя: цифра уходит в отчёт. + if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) { ++lvl.spawnsDropped; continue; } + + budget -= d->danger; + lvl.dangerSpent += d->danger; + if (d->cls == EntityClass::MONSTER && d->danger > 0.0f) ++lvl.monstersPlaced; + taken[ti] = 1; + + lvl.spawns.push_back(LevelSpawn{d->id, Vec2{float(mx) + 0.5f, float(my) + 0.5f}}); + } + } + + // --- 8. долив бюджета ---------------------------------------------------- + // Без этого шага глубина упирается в число точек, нарисованных в комнатах: + // на двадцатом этаже все точки уже срабатывают и уже выдают самое дорогое, + // и дальше этаж не становится страшнее вообще. Долив превращает бюджет из + // потолка в норму, которую этаж обязан выбрать. + const EntityDesc* cheapest = CheapestMonster(catalog, depth); + for (int attempt = 0; attempt < FILL_ATTEMPTS && cheapest && budget >= cheapest->danger; + ++attempt) + { + if (int(lvl.spawns.size()) >= MAX_LEVEL_SPAWNS) break; + if (freeTiles.empty()) break; + + const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))]; + const float rollPick = rng.Float01(); + + if (taken[size_t(tile)]) continue; + + const Vec2 pos{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f}; + if (Distance(pos, lvl.squadStart) < FILL_MIN_DIST_FROM_START) continue; + + const EntityDesc* d = + PickFillMonster(catalog, depth, budget, MAX_LEVEL_SPAWNS - int(lvl.spawns.size()), rollPick); + if (!d) break; + + budget -= d->danger; + lvl.dangerSpent += d->danger; + ++lvl.monstersPlaced; + ++lvl.monstersFilled; + taken[size_t(tile)] = 1; + lvl.spawns.push_back(LevelSpawn{d->id, pos}); + } + + // --- 9. лут -------------------------------------------------------------- + const int wantLoot = std::min(MAX_LEVEL_LOOT, std::max(0, floor.lootDrops)); + for (int attempt = 0; attempt < FILL_ATTEMPTS && int(lvl.loot.size()) < wantLoot; ++attempt) + { + if (freeTiles.empty()) break; + + const int tile = freeTiles[size_t(rng.Below(int(freeTiles.size())))]; + const float rollPick = rng.Float01(); + + if (taken[size_t(tile)]) continue; + + const EntityDesc* d = PickLoot(catalog, depth, floor.lootTier, rollPick); + if (!d) break; + + taken[size_t(tile)] = 1; + lvl.loot.push_back( + LevelSpawn{d->id, Vec2{float(tile % MAP_W) + 0.5f, float(tile / MAP_W) + 0.5f}}); + } + + out = std::move(lvl); + return true; +} diff --git a/engine/level_gen.h b/engine/level_gen.h new file mode 100644 index 0000000..f3d194e --- /dev/null +++ b/engine/level_gen.h @@ -0,0 +1,155 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Сборка ЭТАЖА из комнат. Детерминирована по сиду и глубине, и только по ним: +// один и тот же набор комнат плюс тот же сид и тот же этаж дают побайтово ту же +// карту и то же население. +// +// Где живёт случайность. В симуляции её нет и не будет (docs/02-architecture.md, +// «Детерминизм»): RNG крутится ЗДЕСЬ, один раз, при сборке этажа, и к моменту +// первого Game::Step всё уже разложено. Поэтому прогон остаётся воспроизводимым, +// а `--accept` вообще не проходит по этому коду — приёмка меряется на прежней +// захардкоженной планировке. +// +// Раскладка. Карта 48x48 делится на сетку LEVEL_GRID x LEVEL_GRID слотов по +// ROOM_SLOT тайлов. В каждый слот ставится комната (не больше ROOM_MAX_*), она +// центрируется, и вокруг неё остаётся камень. Слоты сшиваются коридорами: сперва +// остовное дерево — оно гарантирует, что дойти можно до каждой комнаты, — потом +// несколько лишних связей, чтобы этаж не был деревом-кишкой. +// +// Что делает глубина. Всё, кроме планировки: +// * из набора отбираются комнаты, разрешённые на этом этаже; +// * из таблиц спавна отбираются сущности, разрешённые на этом этаже; +// * население тратит БЮДЖЕТ ОПАСНОСТИ, а не просто выпадает по шансу; +// * остаток бюджета доливается тварями вне комнатных точек — иначе глубина +// упиралась бы в число точек, нарисованных в комнатах, и переставала расти; +// * лут раскладывается тиром этажа. +// Сама кривая — в engine/progression.h, здесь только её применение. +// ----------------------------------------------------------------------------- +#include +#include +#include + +#include "engine/math.h" +#include "engine/catalog.h" +#include "engine/progression.h" +#include "engine/room.h" +#include "engine/tilemap.h" + +constexpr int LEVEL_GRID = MAP_W / ROOM_SLOT; +constexpr int LEVEL_SLOTS = LEVEL_GRID * LEVEL_GRID; + +// Потолок на лежащие предметы. Не «на всякий случай»: каждый предмет — +// сущность ECS и иконка в списке отрисовки, а этаж, засыпанный лутом, +// перестаёт быть находкой. +constexpr int MAX_LEVEL_LOOT = 24; + +// Потолок на населённость этажа. Раньше здесь стояла игровая константа +// MAX_TARGETS из src/tuning.h — то есть генератор уровней знал, сколько битов +// в маске удара клинком. Теперь у движка своё число, а игра проверяет, что оно +// влезает в её пределы (static_assert в src/tuning.h). +// +// Это не формальность: бюджет опасности на глубине неизбежно перерастает любое +// такое число, и лишнее не теряется молча — оно уходит в GeneratedLevel:: +// spawnsDropped и в отчёт --check. +constexpr int MAX_LEVEL_SPAWNS = 64; + +static_assert(MAP_W == MAP_H, "генератор рассчитан на квадратную карту"); +static_assert(MAP_W % ROOM_SLOT == 0, "карта обязана делиться на слоты нацело"); + +// ----------------------------------------------------------------------------- +// Xorshift32. Свой, а не : стандартные распределения не обязаны давать +// одинаковую последовательность на разных реализациях библиотеки, а сид этажа +// обязан значить одно и то же везде. +// ----------------------------------------------------------------------------- +struct LevelRng +{ + uint32_t state; + + explicit LevelRng(uint32_t seed) : state(seed ? seed : 0x9E3779B9u) {} + + uint32_t Next() + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return state; + } + + // [0, 1). Двадцать четыре бита: больше во float всё равно не влезает. + float Float01() { return float(Next() >> 8) * (1.0f / 16777216.0f); } + + // [0, n). n <= 0 — ноль: вызывающему не приходится проверять пустой список. + int Below(int n) { return (n > 0) ? int(Next() % uint32_t(n)) : 0; } +}; + +// Одна сущность, которую генератор выкатил в мир. id — строка каталога: +// движок не знает, во что она превратится в игре. +struct LevelSpawn +{ + std::string id; + Vec2 pos{}; +}; + +struct LevelSlot +{ + int roomIndex = -1; // индекс в RoomLibrary; -1 — слот пуст + int originX = 0; // угол комнаты в тайлах карты + int originY = 0; +}; + +struct GeneratedLevel +{ + uint32_t seed = 0; + FloorSpec floor; + + Vec2 squadStart{}; + // Спуск на следующий этаж. Самая дальняя от старта ДОСТИЖИМАЯ точка: этаж + // обязан заставить пройти себя, а не открываться в двух шагах от входа. + Vec2 exitPos{}; + // Эвакуация: та же точка, откуда отряд вошёл на этаж, — лестница наверх. + // Отдельным полем, а не «это же squadStart», потому что смысл у них разный + // и разойтись они могут: точку входа однажды захочется двигать, а выход + // наверх — нет. + // + // Совпадение входа и выхода — не экономия, а суть напряжения: спуск лежит + // на дальнем конце этажа, и решив уйти, отряд идёт обратно через всё, что + // успел растревожить, — раненый и с полными руками. + Vec2 extractPos{}; + + std::vector spawns; // твари и обстановка + std::vector loot; // предметы на полу + + LevelSlot slots[LEVEL_SLOTS]; + int roomsPlaced = 0; + int corridors = 0; + + // Сколько опасности реально выпущено на этаж. Не равно бюджету: точек может + // не хватить, и это нормально. Сильно меньше бюджета на глубине — признак, + // что набор комнат перестал успевать за кривой. + float dangerSpent = 0.0f; + int monstersPlaced = 0; + // Сколько тварей долито вне комнатных точек, чтобы выбрать бюджет. + int monstersFilled = 0; + + // Сколько точек спавна выпало из-за потолка MAX_LEVEL_SPAWNS. Не ноль — этаж + // населён не так, как задумано, и это обязано быть видно, а не молча съедено. + int spawnsDropped = 0; + // Сколько выпало как недостижимые от точки старта. Не ноль — где-то в наборе + // комнат есть карман, отрезанный от собственной двери: тварь в нём не сможет + // ни дойти до отряда, ни быть убитой, и этаж тихо станет легче. + int spawnsUnreachable = 0; + // Сколько тайлов пола достижимо от старта, и как далеко до спуска (в шагах + // по сетке). Обе цифры — про то, «есть ли на этаже что проходить». + int reachableTiles = 0; + int exitDistance = 0; + + const LevelSlot& Slot(int gx, int gy) const { return slots[gy * LEVEL_GRID + gx]; } +}; + +// Собирает планировку прямо в map (включая слой тем) и население в out. +// false — из набора нельзя собрать этаж; map при этом не трогается. +// +// catalog нужен, потому что глубина и цена сущности живут в нём: без каталога +// генератор не может ни отобрать тварей по этажу, ни посчитать бюджет. +bool GenerateLevel(const RoomLibrary& lib, const EntityCatalog& catalog, const FloorSpec& floor, + uint32_t seed, Tilemap& map, GeneratedLevel& out, std::string* err = nullptr); diff --git a/src/core/math.h b/engine/math.h similarity index 100% rename from src/core/math.h rename to engine/math.h diff --git a/engine/microfont.cpp b/engine/microfont.cpp new file mode 100644 index 0000000..f93f165 --- /dev/null +++ b/engine/microfont.cpp @@ -0,0 +1,112 @@ +#include "engine/microfont.h" + +#include + +namespace +{ + +// Знак — пять строк по три бита, старший бит слева. Читается как картинка, +// если смотреть на единицы: именно поэтому таблица записана двоичными +// литералами, а не шестнадцатеричными. +struct Glyph +{ + char c; + uint8_t rows[MICRO_H]; +}; + +const Glyph GLYPHS[] = { + {'0', {0b111, 0b101, 0b101, 0b101, 0b111}}, + {'1', {0b010, 0b110, 0b010, 0b010, 0b111}}, + {'2', {0b111, 0b001, 0b111, 0b100, 0b111}}, + {'3', {0b111, 0b001, 0b011, 0b001, 0b111}}, + {'4', {0b101, 0b101, 0b111, 0b001, 0b001}}, + {'5', {0b111, 0b100, 0b111, 0b001, 0b111}}, + {'6', {0b111, 0b100, 0b111, 0b101, 0b111}}, + {'7', {0b111, 0b001, 0b010, 0b010, 0b010}}, + {'8', {0b111, 0b101, 0b111, 0b101, 0b111}}, + {'9', {0b111, 0b101, 0b111, 0b001, 0b111}}, + + {'A', {0b111, 0b101, 0b111, 0b101, 0b101}}, + {'B', {0b110, 0b101, 0b110, 0b101, 0b110}}, + {'C', {0b111, 0b100, 0b100, 0b100, 0b111}}, + {'D', {0b110, 0b101, 0b101, 0b101, 0b110}}, + {'E', {0b111, 0b100, 0b110, 0b100, 0b111}}, + {'F', {0b111, 0b100, 0b110, 0b100, 0b100}}, + {'G', {0b111, 0b100, 0b101, 0b101, 0b111}}, + {'H', {0b101, 0b101, 0b111, 0b101, 0b101}}, + {'I', {0b111, 0b010, 0b010, 0b010, 0b111}}, + {'J', {0b001, 0b001, 0b001, 0b101, 0b111}}, + {'K', {0b101, 0b101, 0b110, 0b101, 0b101}}, + {'L', {0b100, 0b100, 0b100, 0b100, 0b111}}, + {'M', {0b101, 0b111, 0b111, 0b101, 0b101}}, + {'N', {0b110, 0b101, 0b101, 0b101, 0b101}}, + {'O', {0b111, 0b101, 0b101, 0b101, 0b111}}, + {'P', {0b111, 0b101, 0b111, 0b100, 0b100}}, + {'Q', {0b111, 0b101, 0b101, 0b111, 0b011}}, + {'R', {0b111, 0b101, 0b110, 0b101, 0b101}}, + {'S', {0b111, 0b100, 0b111, 0b001, 0b111}}, + {'T', {0b111, 0b010, 0b010, 0b010, 0b010}}, + {'U', {0b101, 0b101, 0b101, 0b101, 0b111}}, + {'V', {0b101, 0b101, 0b101, 0b101, 0b010}}, + {'W', {0b101, 0b101, 0b111, 0b111, 0b101}}, + {'X', {0b101, 0b101, 0b010, 0b101, 0b101}}, + {'Y', {0b101, 0b101, 0b010, 0b010, 0b010}}, + {'Z', {0b111, 0b001, 0b010, 0b100, 0b111}}, + + {'-', {0b000, 0b000, 0b111, 0b000, 0b000}}, + {'+', {0b000, 0b010, 0b111, 0b010, 0b000}}, + {':', {0b000, 0b010, 0b000, 0b010, 0b000}}, + {'.', {0b000, 0b000, 0b000, 0b000, 0b010}}, + {'/', {0b001, 0b001, 0b010, 0b100, 0b100}}, + {'%', {0b101, 0b001, 0b010, 0b100, 0b101}}, + {'!', {0b010, 0b010, 0b010, 0b000, 0b010}}, +}; + +const Glyph* Find(char c) +{ + // Строчные приводятся к заглавным: подпись не должна пропадать из-за того, + // что кто-то написал её в другом регистре. + if (c >= 'a' && c <= 'z') c = char(c - 'a' + 'A'); + for (const Glyph& g : GLYPHS) + if (g.c == c) return &g; + return nullptr; +} + +} // namespace + +int MicroTextWidth(const char* s) +{ + if (!s || !*s) return 0; + const int n = int(std::strlen(s)); + return n * MICRO_ADVANCE - 1; +} + +void MicroText(Framebuffer& fb, int x, int y, const char* s, uint32_t color) +{ + if (!s) return; + + for (int i = 0; s[i]; ++i) + { + const Glyph* g = Find(s[i]); + const int gx = x + i * MICRO_ADVANCE; + if (!g) continue; // пробел и всё незнакомое — просто пропуск + + for (int row = 0; row < MICRO_H; ++row) + { + const uint8_t bits = g->rows[row]; + for (int col = 0; col < MICRO_W; ++col) + if (bits & (1u << (MICRO_W - 1 - col))) fb.Put(gx + col, y + row, color); + } + } +} + +void MicroTextShadow(Framebuffer& fb, int x, int y, const char* s, uint32_t color, uint32_t shadow) +{ + // Подложка — четыре сдвига по осям. Диагонали не нужны: на 3x5 они + // съедают просвет внутри знака, и цифра превращается в кляксу. + MicroText(fb, x - 1, y, s, shadow); + MicroText(fb, x + 1, y, s, shadow); + MicroText(fb, x, y - 1, s, shadow); + MicroText(fb, x, y + 1, s, shadow); + MicroText(fb, x, y, s, color); +} diff --git a/engine/microfont.h b/engine/microfont.h new file mode 100644 index 0000000..5cace50 --- /dev/null +++ b/engine/microfont.h @@ -0,0 +1,39 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Растровый шрифт 3x5 для софтверного фреймбуфера. +// +// Зачем он появился. HUD прототипа был принципиально бессловесным: на 480x270 +// шрифт raylib либо нечитаем, либо занимает четверть экрана, и всё нужное +// показывалось формой и цветом (render/hud.h). Для игры про бесконечный спуск +// этого перестало хватать ровно на одном: НОМЕР ЭТАЖА — это число, и показать +// его формой нельзя. Двадцать семь зарубок на краю экрана никто не считает. +// +// Поэтому здесь минимальный шрифт, а не raylib-текст: правило «ни одного +// примитива raylib в игровой сцене» остаётся в силе, потому что этот шрифт +// пишет в тот же самый фреймбуфер, попадает под ту же сетку пикселей и под +// ту же световую рампу, если понадобится. +// +// 3x5 — предел читаемости на этом разрешении. Знаков ровно столько, сколько +// нужно: цифры, заглавная латиница и несколько разделителей. Строчных нет +// намеренно: на пяти пикселях высоты они неотличимы от заглавных. +// ----------------------------------------------------------------------------- +#include + +#include "engine/framebuffer.h" + +constexpr int MICRO_W = 3; // ширина знака +constexpr int MICRO_H = 5; +constexpr int MICRO_ADVANCE = MICRO_W + 1; // шаг между знаками + +// Ширина строки в пикселях (без хвостового пробела). +int MicroTextWidth(const char* s); + +// Написать строку. (x, y) — левый верхний угол первого знака. +// Неизвестный символ рисуется как пробел: подпись обязана уцелеть целиком, +// даже если в неё попало что-то неожиданное. +void MicroText(Framebuffer& fb, int x, int y, const char* s, uint32_t color); + +// То же, но с тёмной подложкой в один пиксель вокруг: HUD ложится поверх +// произвольного пола, и без подложки светлая надпись пропадает на светлом. +void MicroTextShadow(Framebuffer& fb, int x, int y, const char* s, uint32_t color, + uint32_t shadow); diff --git a/engine/pixel.cpp b/engine/pixel.cpp new file mode 100644 index 0000000..96cd6e9 --- /dev/null +++ b/engine/pixel.cpp @@ -0,0 +1,39 @@ +#include "engine/pixel.h" + +#include + +#include "engine/math.h" + +uint32_t ShadeColor(uint32_t c, float k) +{ + const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f); + const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f); + const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f); + return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c)); +} + +void DiamondRowN(int y, int dw, int dh, int& x0, int& x1) +{ + const int half = std::max(1, dh / 2); + const int k = (y < half) ? y : (dh - 1 - y); + const int width = std::max(1, (dw / half) * (k + 1)); + x0 = (dw - width) / 2; + x1 = x0 + width; +} + +void OutlineSilhouette(Sprite& s, uint32_t outline) +{ + Sprite copy = s; + for (int y = 0; y < s.h; ++y) + { + for (int x = 0; x < s.w; ++x) + { + if (AlphaOf(copy.Get(x, y)) == 0) continue; + const bool border = + x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 || + AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 || + AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0; + if (border) s.Set(x, y, outline); + } + } +} diff --git a/engine/pixel.h b/engine/pixel.h new file mode 100644 index 0000000..2528267 --- /dev/null +++ b/engine/pixel.h @@ -0,0 +1,29 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Мелочь, общая для всякого, кто рисует спрайты руками: движок и игра. +// +// Вынесено из render/sprites.cpp, потому что тайлы пола и стен переехали в +// движок (engine/tileset.h), а рисуются они тем же кодом, что и всё остальное. +// Держать две копии наклона ромба — верный способ получить пол и обстановку +// в разных изометриях. +// ----------------------------------------------------------------------------- +#include + +#include "engine/framebuffer.h" + +// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется). +uint32_t ShadeColor(uint32_t c, float k); + +// Занятая часть строки y для ромба dw x dh. Шаг ступеньки — dw/(dh/2), то есть +// ровно наклон 2:1: всё, что стоит на полу, обязано быть в одной изометрии +// с полом, иначе объект выглядит приклеенным к экрану. +void DiamondRowN(int y, int dw, int dh, int& x0, int& x1); + +// Ромба размером с плиту пола здесь БОЛЬШЕ НЕТ: форму плиты знает проекция +// (g_proj.TileRow), и при виде сверху плита не ромб, а квадрат. Помощник, +// рисующий ромб мимо проекции, — это ровно тот способ снова получить пол и +// обстановку в разных видах. + +// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный +// 4-сосед (или край спрайта), перекрашивается в outline. +void OutlineSilhouette(Sprite& s, uint32_t outline); diff --git a/engine/progression.cpp b/engine/progression.cpp new file mode 100644 index 0000000..dd8a4fe --- /dev/null +++ b/engine/progression.cpp @@ -0,0 +1,69 @@ +#include "engine/progression.h" + +#include +#include + +namespace +{ + +// --- отделка ----------------------------------------------------------------- +// Полосы этажей по темам. Порядок — это порядок погружения: обжитая сталь, +// заброшенная ржавчина, заражённая плоть, мёртвая наледь. Границы полос — +// данные проекта (FloorCurve::themeFrom), сам порядок — нет: тема с номером +// больше обязана быть «глубже», иначе спуск станет чересполосицей. +uint8_t ThemeAtDepth(int depth) +{ + uint8_t theme = 0; + for (int i = 0; i < THEME_COUNT; ++i) + if (depth >= g_curve.themeFrom[i]) theme = uint8_t(i); + return theme; +} + +} // namespace + +FloorSpec DescribeFloor(int depth) +{ + const FloorCurve& c = g_curve; + + FloorSpec s; + s.depth = std::max(1, depth); + const float d = float(s.depth); + const float over = d - 1.0f; // насколько глубже первого + + s.theme = ThemeAtDepth(s.depth); + + // Опасность: линейный рост плюс небольшой квадратичный хвост. Чистая + // линейка на тридцатом этаже читается как «то же самое, только больше»; + // чистая экспонента убивает забег ровно там, где игрок начал понимать + // правила. Хвост даёт ускорение, которое ЗАМЕТНО, но не обрывает спуск. + s.dangerBudget = c.dangerBase + c.dangerLinear * over + c.dangerQuad * over * over; + + // Плотность растёт до упора и там встаёт. Дальше расти нельзя: точки + // спавна в комнатах конечны, и множитель выше упора просто означал бы + // «все точки сработали всегда», то есть одинаковые этажи. + s.spawnChanceScale = std::min(c.densityCap, 1.0f + c.densityPerFloor * over); + + // Лут. Тир поднимается медленнее опасности, поэтому спуск всегда чуть + // опережает снаряжение — иначе отряд к десятому этажу одет так, что + // дальше можно не смотреть. + s.lootTier = (c.floorsPerTier > 0) ? (s.depth - 1) / c.floorsPerTier : 0; + s.lootDrops = c.lootBase + ((c.floorsPerDrop > 0) ? s.depth / c.floorsPerDrop : 0); + + // Обзор сжимается до упора и там встаёт. Ниже — это уже не «страшно», а + // «не видно, куда идти»: игрок перестаёт различать своих, и хоррор + // превращается в неудобство. + s.sightScale = std::max(c.sightFloorAt, 1.0f - c.sightPerFloor * over); + + // Опыт растёт медленнее опасности намеренно: спускаться должно быть + // выгодно, но не настолько, чтобы прыгать через этажи было единственной + // разумной игрой. + s.xpScale = 1.0f + c.xpPerFloor * over; + + return s; +} + +void FloorLabel(const FloorSpec& spec, char* buf, int cap) +{ + if (!buf || cap <= 0) return; + std::snprintf(buf, size_t(cap), "D%d %s", spec.depth, ThemeName(spec.theme)); +} diff --git a/engine/progression.h b/engine/progression.h new file mode 100644 index 0000000..a5f8340 --- /dev/null +++ b/engine/progression.h @@ -0,0 +1,106 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Что означает глубина N. +// +// Игра — это бесконечный спуск: чем глубже, тем страшнее окружение, злее твари, +// богаче находки. Весь этот наклон живёт ЗДЕСЬ, в одной функции, и больше +// нигде. Причина простая: кривая сложности — это то, что правят чаще всего и +// правят на ощупь. Размазанная по генератору, каталогу и рендеру, она через +// месяц перестаёт поддаваться правке вообще, потому что «сделать десятый этаж +// чуть злее» превращается в раскопки по трём файлам. +// +// Чего здесь НЕТ намеренно: +// * множителей урона и здоровья тварей. «Тот же бредущий, но со втрое +// большим HP» — это не страшнее, это дольше. Глубина меняет, КТО выходит +// и сколько их, а не сколько раз надо выстрелить в одного и того же; +// * потолка. Функция определена для любого N: спуск бесконечен, и этаж 200 +// обязан собраться, даже если каталог кончился на 12-м. +// +// Всё, что отсюда выходит, — данные. Ни рендер, ни симуляция про «глубину» не +// знают, они читают готовые числа. +// ----------------------------------------------------------------------------- +#include + +#include "engine/theme.h" + +struct FloorSpec +{ + int depth = 1; + + // Отделка этажа. Комнаты со своей темой её сохраняют — тема этажа + // достаётся коридорам и тем комнатам, которые от неё не отказались. + uint8_t theme = 0; + + // Сколько «опасности» разрешено выпустить на этаж (цены — в каталоге). + // Главный рычаг сложности: он растёт всегда, а состав того, на что его + // тратят, меняется вместе с ним. + float dangerBudget = 4.0f; + + // Множитель шанса срабатывания точек спавна. Бюджет отвечает за то, + // НАСКОЛЬКО злой этаж, этот множитель — за то, насколько он плотный. + float spawnChanceScale = 1.0f; + + // Лут: сколько предметов лежит на этаже и какого качества. + int lootDrops = 2; + int lootTier = 0; + + // Насколько сжат обзор. Render-only: свет и туман войны на симуляцию не + // влияют (docs/02-architecture.md), поэтому темнеть с глубиной безопасно. + // Именно это и делает окружение страшнее — не новые спрайты, а то, что + // видно всё меньше. + float sightScale = 1.0f; + + // Опыт за этаж — множитель к цене убитого. Растёт медленнее опасности: + // спускаться должно быть выгодно, но не настолько, чтобы пропуск этажей + // стал единственной разумной игрой. + float xpScale = 1.0f; +}; + +// ----------------------------------------------------------------------------- +// Коэффициенты кривой. Раньше они стояли числами прямо в DescribeFloor, то есть +// «чуть злее с десятого этажа» требовало пересборки движка. Теперь это данные +// проекта (project.txt), и ФОРМА кривой при этом остаётся в коде: проект гнёт +// её, но не может подменить, скажем, линейный рост экспонентой. +// +// Так и задумано. Проект, которому нужна другая форма, — это повод завести ещё +// одно поле здесь, а не язык выражений в текстовом файле: кривую правят на +// ощупь и часто, и она обязана оставаться читаемой целиком за один взгляд. +// ----------------------------------------------------------------------------- +struct FloorCurve +{ + // Опасность: base + linear*(d-1) + quad*(d-1)^2. + float dangerBase = 3.0f; + float dangerLinear = 2.2f; + float dangerQuad = 0.10f; + + // Плотность точек: 1 + perFloor*(d-1), но не выше cap. + float densityPerFloor = 0.05f; + float densityCap = 1.5f; + + // Лут: base + d/floorsPerDrop предметов, тир — ступень на floorsPerTier. + int lootBase = 2; + int floorsPerDrop = 4; + int floorsPerTier = 3; + + // Обзор: 1 - perFloor*(d-1), но не ниже floorAt. + float sightPerFloor = 0.035f; + float sightFloorAt = 0.60f; + + // Опыт: 1 + perFloor*(d-1). + float xpPerFloor = 0.15f; + + // С какого этажа начинается каждая тема отделки. Последняя полоса открытая: + // дальше тема не меняется, потому что менять её больше не на что. + int themeFrom[THEME_COUNT] = {1, 4, 8, 13}; +}; + +// Активная кривая. Ставится один раз при загрузке проекта, как и g_proj. +inline FloorCurve g_curve; + +// Кривая. Определена для любого depth >= 1. +FloorSpec DescribeFloor(int depth); + +// Подпись этажа для HUD: "D3 RUST". Здесь, а не в HUD, потому что имя темы +// этажа — часть кривой, и знать, какая тема на каком этаже, HUD не обязан. +// Пишет в buf не больше cap символов, включая ноль. +void FloorLabel(const FloorSpec& spec, char* buf, int cap); diff --git a/engine/project.cpp b/engine/project.cpp new file mode 100644 index 0000000..93a361d --- /dev/null +++ b/engine/project.cpp @@ -0,0 +1,225 @@ +#include "engine/project.h" + +#include +#include +#include +#include + +#include "engine/text_parse.h" + +namespace fs = std::filesystem; + +namespace +{ + +constexpr const char* MANIFEST = "project.txt"; +constexpr const char* PROJECTS_DIR = "projects"; + +// Откуда смотрим. Игра запускается и из корня репозитория, и из build/bin — +// оба способа рабочие, и требовать за это ключ было бы налогом на каждый запуск. +const char* const BASES[] = {".", "..", "../..", "../../.."}; + +bool HasManifest(const fs::path& dir) +{ + std::error_code ec; + return fs::exists(dir / MANIFEST, ec); +} + +// Число с плавающей точкой из лексемы по индексу; нет лексемы — прежнее значение +// остаётся. Так частично заполненная строка не обнуляет незаданные поля. +void Take(const std::vector& parts, size_t i, float& out) +{ + if (i < parts.size()) out = text::ToFloat(parts[i], out); +} + +void Take(const std::vector& parts, size_t i, int& out) +{ + if (i < parts.size()) out = text::ToInt(parts[i], out); +} + +} // namespace + +bool Project::Load(const std::string& projectDir, std::string* err) +{ + auto fail = [&](const std::string& why) { + if (err) *err = why; + return false; + }; + + const fs::path root(projectDir); + const fs::path manifest = root / MANIFEST; + + std::ifstream in(manifest); + if (!in) return fail("нет " + manifest.string()); + + *this = Project{}; + dir = projectDir; + + std::string line; + int lineNo = 0; + std::string rooms = "rooms"; + std::string catalog = "catalog/entities.txt"; + + while (std::getline(in, line)) + { + ++lineNo; + const std::vector parts = text::Split(text::Trim(text::StripComment(line))); + if (parts.empty()) continue; + + const std::string& key = parts[0]; + + if (key == "name") name = text::JoinFrom(parts, 1); + else if (key == "rooms") rooms = text::JoinFrom(parts, 1); + else if (key == "catalog") catalog = text::JoinFrom(parts, 1); + else if (key == "startdepth") Take(parts, 1, startDepth); + else if (key == "view") + { + // Неизвестный вид — это ошибка, а не повод молча остаться в + // изометрии: проект, написанный под вид сверху, в изометрии + // выглядит не «чуть иначе», а сломанным. + if (parts.size() < 2 || !ParseViewMode(parts[1].c_str(), view)) + return fail(manifest.string() + ": строка " + std::to_string(lineNo) + + ": неизвестный вид отображения"); + } + else if (key == "danger") + { + Take(parts, 1, curve.dangerBase); + Take(parts, 2, curve.dangerLinear); + Take(parts, 3, curve.dangerQuad); + } + else if (key == "density") + { + Take(parts, 1, curve.densityPerFloor); + Take(parts, 2, curve.densityCap); + } + else if (key == "loot") + { + Take(parts, 1, curve.lootBase); + Take(parts, 2, curve.floorsPerDrop); + Take(parts, 3, curve.floorsPerTier); + } + else if (key == "sight") + { + Take(parts, 1, curve.sightPerFloor); + Take(parts, 2, curve.sightFloorAt); + } + else if (key == "xp") Take(parts, 1, curve.xpPerFloor); + else if (key == "themes") + { + for (int i = 0; i < THEME_COUNT; ++i) Take(parts, size_t(i) + 1, curve.themeFrom[i]); + } + else + { + // Опечатка в ключе — самая дорогая ошибка в текстовом формате: + // строка просто не действует, и это выглядит как «движок не + // слушается». Поэтому она валит загрузку, а не пропускается. + return fail(manifest.string() + ": строка " + std::to_string(lineNo) + + ": неизвестный ключ " + key); + } + } + + roomsDir = (root / rooms).string(); + catalogPath = (root / catalog).string(); + + std::error_code ec; + if (!fs::is_directory(fs::path(roomsDir), ec)) + return fail("нет каталога комнат " + roomsDir); + if (!fs::exists(fs::path(catalogPath), ec)) + return fail("нет каталога сущностей " + catalogPath); + + // Полосы отделки обязаны идти по возрастанию: тема с большим номером — это + // «глубже». Перепутанные границы дали бы этаж, у которого тема зависит от + // порядка сравнений, а не от глубины. + for (int i = 1; i < THEME_COUNT; ++i) + if (curve.themeFrom[i] < curve.themeFrom[i - 1]) + return fail(manifest.string() + ": полосы отделки идут не по возрастанию"); + + if (startDepth < 1) startDepth = 1; + return true; +} + +void Project::Apply() const +{ + g_proj = Projection::For(view); + g_curve = curve; +} + +std::string FindProjectDir(const std::string& wantedArg) +{ + std::error_code ec; + + // Ключ важнее окружения, окружение важнее угадывания. Переменная нужна + // терминалу редактора и запущенным из него агентам: инструмент, вызванный + // изнутри открытого проекта, обязан говорить про ЭТОТ проект, а дописывать + // --project в чужие команды строковой подстановкой — способ однажды + // подставить его не туда. + std::string wanted = wantedArg; + if (wanted.empty()) + { +#ifdef _MSC_VER + // getenv_s, а не getenv: под /W4 второй — предупреждение, а собираемся + // мы без предупреждений, и глушить их ради одной строки нельзя. + char buf[512] = {}; + size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), "TILE2D_PROJECT") == 0 && len > 1) wanted = buf; +#else + const char* env = std::getenv("TILE2D_PROJECT"); + if (env && *env) wanted = env; +#endif + } + + // 1. Явный путь — как дали, так и берём. + if (!wanted.empty()) + { + if (HasManifest(fs::path(wanted))) return wanted; + + // 2. Или это имя проекта: <база>/projects/<имя>. + for (const char* b : BASES) + { + const fs::path p = fs::path(b) / PROJECTS_DIR / wanted; + if (HasManifest(p)) return p.string(); + } + return {}; + } + + // 3. Без имени: проект под ногами, потом единственный в projects/. + for (const char* b : BASES) + { + if (HasManifest(fs::path(b))) return fs::path(b).string(); + + const fs::path pdir = fs::path(b) / PROJECTS_DIR; + if (!fs::is_directory(pdir, ec)) continue; + + std::vector found; + for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec)) + if (e.is_directory(ec) && HasManifest(e.path())) found.push_back(e.path().string()); + + // Несколько проектов и ни одного названного — выбирать за игрока + // нельзя: молча взятый «первый попавшийся» читается как «движок открыл + // не то». Пусть скажут, какой. + if (found.size() == 1) return found[0]; + if (found.size() > 1) return {}; + } + return {}; +} + +std::vector AvailableProjects() +{ + std::error_code ec; + std::vector names; + + for (const char* b : BASES) + { + const fs::path pdir = fs::path(b) / PROJECTS_DIR; + if (!fs::is_directory(pdir, ec)) continue; + + for (const fs::directory_entry& e : fs::directory_iterator(pdir, ec)) + if (e.is_directory(ec) && HasManifest(e.path())) + names.push_back(e.path().filename().string()); + + if (!names.empty()) break; // ближайший каталог projects/ и есть нужный + } + + std::sort(names.begin(), names.end()); + return names; +} diff --git a/engine/project.h b/engine/project.h new file mode 100644 index 0000000..75687e1 --- /dev/null +++ b/engine/project.h @@ -0,0 +1,68 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Проект: всё, что отличает одну игру на Tile2D от другой. +// +// Движок не знает ни одного имени содержимого. Он знает, что у проекта есть +// каталог сущностей, набор комнат, вид отображения и кривая глубины, — а какие +// именно твари, комнаты и числа за этим стоят, читается из project.txt. +// +// Отсюда правило, которое легко нарушить и трудно заметить: НИ ОДИН путь к +// содержимому не может быть зашит в код движка. Раньше движок сам искал +// assets/rooms и assets/catalog, то есть в нём было зашито имя ровно одной +// игры; такой «движок» работает с одним проектом и притворяется общим. +// +// Формат — построчный `ключ значение`, как у комнат и каталога. Причина та же: +// файл лежит в git, правится руками и обязан читаться в diff. +// +// name DESCENT +// view iso # iso | topdown +// rooms rooms # каталог с *.room, относительно проекта +// catalog catalog/entities.txt +// startdepth 1 +// danger 3.0 2.2 0.10 # база, линейный рост, квадратичный хвост +// density 0.05 1.5 # прирост за этаж, упор +// loot 2 4 3 # база, этажей на +1 предмет, этажей на тир +// sight 0.035 0.60 # сжатие за этаж, нижний упор +// xp 0.15 # прирост множителя опыта за этаж +// themes 1 4 8 13 # с какого этажа начинается каждая тема +// ----------------------------------------------------------------------------- +#include +#include + +#include "engine/progression.h" +#include "engine/projection.h" + +struct Project +{ + std::string dir; // корень проекта + std::string name = "UNTITLED"; + ViewMode view = ViewMode::ISO_2_1; + + // Пути уже склеены с корнем проекта: вызывающему не приходится знать, + // относительно чего они были записаны. + std::string roomsDir; + std::string catalogPath; + + int startDepth = 1; + FloorCurve curve; + + // false — проект не читается; err объясняет, чего именно не хватает. + bool Load(const std::string& projectDir, std::string* err = nullptr); + + // Поставить проект на движок: вид отображения и кривую глубины. Отдельно от + // Load намеренно — прочитать манифест, чтобы показать его в списке, и + // ПЕРЕКЛЮЧИТЬ на него движок это разные действия. + void Apply() const; +}; + +// Где искать проект. wanted — либо путь к каталогу проекта, либо просто имя +// (тогда ищется projects/<имя>). Пусто — берётся переменная окружения +// TILE2D_PROJECT, а если и её нет — единственный найденный проект. +// +// Поиск идёт от текущего каталога вверх: запуск из корня репозитория и запуск +// из build/bin одинаково рабочие, и это не должно требовать ключей. +std::string FindProjectDir(const std::string& wanted = {}); + +// Имена проектов, которые видны отсюда, — для сообщения об ошибке и для списка +// в редакторе. «Проект не найден» без ответа на «а какие есть» бесполезно. +std::vector AvailableProjects(); diff --git a/engine/projection.cpp b/engine/projection.cpp new file mode 100644 index 0000000..467e631 --- /dev/null +++ b/engine/projection.cpp @@ -0,0 +1,100 @@ +#include "engine/projection.h" + +#include +#include + +namespace +{ + +struct ModeInfo +{ + const char* name; + int tileW, tileH, wallH; +}; + +// Размеры подобраны так, чтобы плита занимала на экране сопоставимую площадь: +// иначе смена вида меняла бы ещё и масштаб, и сравнить два вида было бы нельзя. +const ModeInfo MODES[VIEW_MODE_COUNT] = { + {"iso", TILE_W, TILE_H, WALL_H}, + {"topdown", TILE_MAX_W, TILE_MAX_H, TILE_MAX_H}, +}; + +} // namespace + +const char* ViewModeName(ViewMode m) +{ + const int i = int(m); + return (i >= 0 && i < VIEW_MODE_COUNT) ? MODES[i].name : "?"; +} + +bool ParseViewMode(const char* name, ViewMode& out) +{ + if (!name) return false; + for (int i = 0; i < VIEW_MODE_COUNT; ++i) + if (std::strcmp(name, MODES[i].name) == 0) + { + out = ViewMode(i); + return true; + } + return false; +} + +Projection Projection::For(ViewMode m) +{ + const ModeInfo& info = MODES[int(m) < VIEW_MODE_COUNT ? int(m) : 0]; + Projection p; + p.mode = m; + p.tileW = info.tileW; + p.tileH = info.tileH; + p.wallH = info.wallH; + return p; +} + +Vec2 Projection::WorldToScreen(Vec2 w) const +{ + if (mode == ViewMode::TOP_DOWN) return {w.x * float(tileW), w.y * float(tileH)}; + + // sx = (wx - wy) * tileW/2 + // sy = (wx + wy) * tileH/2 + return {(w.x - w.y) * float(tileW) * 0.5f, (w.x + w.y) * float(tileH) * 0.5f}; +} + +Vec2 Projection::ScreenToWorld(Vec2 s) const +{ + if (mode == ViewMode::TOP_DOWN) return {s.x / float(tileW), s.y / float(tileH)}; + + const float a = s.x / float(tileW); // = (wx - wy) / 2 + const float b = s.y / float(tileH); // = (wx + wy) / 2 + return {b + a, b - a}; +} + +float Projection::Depth(Vec2 w) const +{ + // В изометрии «дальше от камеры» — это меньшая сумма координат; при виде + // сверху глубины как таковой нет, и порядок задаёт только строка экрана. + return (mode == ViewMode::TOP_DOWN) ? w.y : (w.x + w.y); +} + +void Projection::TileRow(int y, int& x0, int& x1) const +{ + if (mode == ViewMode::TOP_DOWN) + { + x0 = 0; + x1 = tileW; + return; + } + + // Ступенька ромба — tileW/(tileH/2), то есть ровно наклон 2:1. + const int half = std::max(1, tileH / 2); + const int k = (y < half) ? y : (tileH - 1 - y); + const int width = std::max(1, (tileW / half) * (k + 1)); + x0 = (tileW - width) / 2; + x1 = x0 + width; +} + +int Projection::WallAnchorY() const +{ + // Изометрия: низ блока садится на ромб пола, поэтому крыша поднята над + // центром плиты на высоту тайла. Вид сверху: подниматься некуда. + return (mode == ViewMode::TOP_DOWN) ? tileH / 2 : tileH / 2 + tileH; +} diff --git a/engine/projection.h b/engine/projection.h new file mode 100644 index 0000000..6f55746 --- /dev/null +++ b/engine/projection.h @@ -0,0 +1,75 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Проекция: как мир кладётся на экран. +// +// Это ЕДИНСТВЕННОЕ место, где движок знает про изометрию. Мир везде декартов +// (тайл = 1.0 юнита), симуляция, ИИ и коллизии об этом файле не знают вообще — +// правило «src/ai/ не знает про изометрию» этим и держится. +// +// Проекция задаёт четыре вещи, и больше ничего: +// * куда мировая точка попадает на экран и обратно; +// * чем сортируется список отрисовки (кто «ближе к камере»); +// * какой формы плита пола — ромб или квадрат; +// * насколько крыша стены поднята над своей плитой. +// +// Всё остальное — спрайты, свет, кровь, эффекты — считается ЧЕРЕЗ эти четыре +// ответа и потому переживает смену вида. Абстракция с одной реализацией была бы +// самообманом, поэтому режима сразу два: изометрия 2:1 и вид сверху. +// +// Вид выбирается ОДИН раз при старте (проектом или ключом --view) и дальше не +// меняется: тайлсет рисуется под форму плиты, и смена вида на ходу означала бы +// перерисовку всех спрайтов посреди кадра. +// ----------------------------------------------------------------------------- +#include + +#include "engine/config.h" +#include "engine/math.h" + +enum class ViewMode : uint8_t +{ + ISO_2_1 = 0, // ромб 32x16, наклон 2:1 — классическая изометрия + TOP_DOWN, // квадрат 32x32, вид строго сверху +}; + +constexpr int VIEW_MODE_COUNT = 2; + +const char* ViewModeName(ViewMode m); +// Разбор имени из project.txt или из ключа --view. false — имя не опознано, +// out не тронут (звать с уже осмысленным значением по умолчанию). +bool ParseViewMode(const char* name, ViewMode& out); + +struct Projection +{ + ViewMode mode = ViewMode::ISO_2_1; + + int tileW = TILE_W; // размер плиты пола на экране + int tileH = TILE_H; + int wallH = WALL_H; // высота спрайта стены целиком + + static Projection For(ViewMode m); + + // Мир -> экран и обратно. Обратная строго обратна прямой: по ней считается + // направление WASD, и рассогласование увело бы отряд не туда, куда жмут. + Vec2 WorldToScreen(Vec2 w) const; + Vec2 ScreenToWorld(Vec2 s) const; + + // Ключ сортировки: чем больше, тем ближе к камере. + float Depth(Vec2 w) const; + + // Занятая часть строки y у плиты пола: ромб или вся строка. + void TileRow(int y, int& x0, int& x1) const; + + // Куда внутри спрайта стены попадает центр её плиты. В изометрии крыша + // поднята над полом на высоту тайла, при виде сверху подниматься некуда. + int WallAnchorY() const; + + // Есть ли у стены видимые вертикальные грани. При виде сверху их нет, и + // рисовать их значило бы показывать стену в двух проекциях сразу. + bool WallHasSides() const { return mode == ViewMode::ISO_2_1; } +}; + +// Активная проекция. Глобальная по той же причине, что и g_tune: её читают +// рендер, редактор и генерация тайлов, и протаскивать её параметром через +// каждый блит значило бы переписать половину рендера ради значения, которое +// за весь запуск не меняется ни разу. +inline Projection g_proj; diff --git a/engine/room.cpp b/engine/room.cpp new file mode 100644 index 0000000..3a79ff8 --- /dev/null +++ b/engine/room.cpp @@ -0,0 +1,564 @@ +#include "engine/room.h" + +#include +#include +#include +#include + +#include "engine/text_parse.h" + +namespace fs = std::filesystem; + +namespace +{ + +constexpr char CH_EMPTY = '.'; +constexpr char CH_WALL = '#'; +constexpr char CH_DOOR = '+'; + +char CharOf(RoomCell c) +{ + switch (c) + { + case RoomCell::WALL: return CH_WALL; + case RoomCell::DOOR: return CH_DOOR; + default: return CH_EMPTY; + } +} + +bool CellOf(char ch, RoomCell& out) +{ + switch (ch) + { + case CH_EMPTY: out = RoomCell::EMPTY; return true; + case CH_WALL: out = RoomCell::WALL; return true; + case CH_DOOR: out = RoomCell::DOOR; return true; + default: return false; + } +} + +// Обход рамки по стороне: сколько на ней клеток и какая клетка под номером i. +int SideLength(const Room& r, RoomSide side) +{ + return (side == RoomSide::NORTH || side == RoomSide::SOUTH) ? r.w : r.h; +} + +void SideCell(const Room& r, RoomSide side, int i, int& x, int& y) +{ + switch (side) + { + case RoomSide::NORTH: x = i; y = 0; break; + case RoomSide::SOUTH: x = i; y = r.h - 1; break; + case RoomSide::WEST: x = 0; y = i; break; + default: x = r.w - 1; y = i; break; + } +} + +} // namespace + +// ----------------------------------------------------------------------------- +// Точка спавна +// ----------------------------------------------------------------------------- +int SpawnPoint::TotalWeight() const +{ + int total = 0; + for (const SpawnOption& o : options) total += (o.weight > 0) ? o.weight : 0; + return total; +} + +int SpawnPoint::Pick(float roll01) const +{ + const int total = TotalWeight(); + if (total <= 0) return -1; + + // Бросок приводится к [0, total) и идёт по накопленной сумме. Никаких + // делений на веса: одинаковый бросок обязан давать одинаковый результат + // на любой платформе, а плавающая арифметика здесь только одна — умножение. + float acc = roll01 * float(total); + for (size_t i = 0; i < options.size(); ++i) + { + const int wgt = (options[i].weight > 0) ? options[i].weight : 0; + if (wgt <= 0) continue; + acc -= float(wgt); + if (acc < 0.0f) return int(i); + } + // Бросок ровно 1.0 или накопленная погрешность: отдаём последний непустой. + for (int i = int(options.size()) - 1; i >= 0; --i) + if (options[size_t(i)].weight > 0) return i; + return -1; +} + +int SpawnPoint::WeightOf(const std::string& id) const +{ + for (const SpawnOption& o : options) + if (o.id == id) return o.weight; + return 0; +} + +void SpawnPoint::SetWeight(const std::string& id, int weight) +{ + for (size_t i = 0; i < options.size(); ++i) + { + if (options[i].id != id) continue; + if (weight > 0) options[i].weight = weight; + else options.erase(options.begin() + long(i)); + return; + } + if (weight > 0) options.push_back(SpawnOption{id, weight}); +} + +// ----------------------------------------------------------------------------- +// Комната +// ----------------------------------------------------------------------------- +Room::Room() +{ + for (RoomCell& c : cells) c = RoomCell::WALL; + ClearInterior(); + FillBorder(); +} + +int Room::SpawnIndexAt(int x, int y) const +{ + for (size_t i = 0; i < spawns.size(); ++i) + if (spawns[i].x == x && spawns[i].y == y) return int(i); + return -1; +} + +SpawnPoint* Room::SpawnAt(int x, int y) +{ + const int i = SpawnIndexAt(x, y); + return (i >= 0) ? &spawns[size_t(i)] : nullptr; +} + +const SpawnPoint* Room::SpawnAt(int x, int y) const +{ + const int i = SpawnIndexAt(x, y); + return (i >= 0) ? &spawns[size_t(i)] : nullptr; +} + +SpawnPoint& Room::TouchSpawnAt(int x, int y) +{ + if (SpawnPoint* p = SpawnAt(x, y)) return *p; + SpawnPoint p; + p.x = x; + p.y = y; + spawns.push_back(std::move(p)); + return spawns.back(); +} + +void Room::RemoveSpawnAt(int x, int y) +{ + const int i = SpawnIndexAt(x, y); + if (i >= 0) spawns.erase(spawns.begin() + long(i)); +} + +void Room::Resize(int newW, int newH) +{ + newW = std::max(ROOM_MIN_W, std::min(ROOM_MAX_W, newW)); + newH = std::max(ROOM_MIN_H, std::min(ROOM_MAX_H, newH)); + if (newW == w && newH == h) return; + + // Содержимое переезжает как есть, левым верхним углом: якорь комнаты — + // её угол, и «расти вправо-вниз» единственное, что предсказуемо на глаз. + RoomCell old[ROOM_MAX_W * ROOM_MAX_H]; + std::copy(std::begin(cells), std::end(cells), std::begin(old)); + const int oldW = w, oldH = h; + + for (RoomCell& c : cells) c = RoomCell::WALL; + w = newW; + h = newH; + + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + { + const bool had = (x < oldW && y < oldH); + RoomCell c = had ? old[size_t(y) * ROOM_MAX_W + x] : RoomCell::EMPTY; + // Дверь, оказавшаяся ВНУТРИ новой комнаты, — это уже не дверь. + // Оставить её значило бы отдать генератору проход в никуда. + if (c == RoomCell::DOOR && !(x == 0 || y == 0 || x == w - 1 || y == h - 1)) + c = RoomCell::EMPTY; + Set(x, y, c); + } + + FillBorder(); + + // Точки, выехавшие за новую границу или попавшие в стену, снимаются здесь + // же: иначе они молча висели бы в файле и не срабатывали никогда. + spawns.erase(std::remove_if(spawns.begin(), spawns.end(), + [this](const SpawnPoint& p) { return !Walkable(p.x, p.y); }), + spawns.end()); +} + +void Room::FillBorder() +{ + for (int x = 0; x < w; ++x) + { + if (At(x, 0) == RoomCell::EMPTY) Set(x, 0, RoomCell::WALL); + if (At(x, h - 1) == RoomCell::EMPTY) Set(x, h - 1, RoomCell::WALL); + } + for (int y = 0; y < h; ++y) + { + if (At(0, y) == RoomCell::EMPTY) Set(0, y, RoomCell::WALL); + if (At(w - 1, y) == RoomCell::EMPTY) Set(w - 1, y, RoomCell::WALL); + } + // Углы дверью быть не могут: коридор пришлось бы тянуть сразу в две + // стороны, и генератор не смог бы выбрать, в какую именно. + Set(0, 0, RoomCell::WALL); + Set(w - 1, 0, RoomCell::WALL); + Set(0, h - 1, RoomCell::WALL); + Set(w - 1, h - 1, RoomCell::WALL); +} + +void Room::ClearInterior() +{ + for (int y = 1; y < h - 1; ++y) + for (int x = 1; x < w - 1; ++x) + Set(x, y, RoomCell::EMPTY); + spawns.clear(); +} + +int Room::CountDoors() const +{ + int n = 0; + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + if (At(x, y) == RoomCell::DOOR) ++n; + return n; +} + +int Room::CountDoorsOn(RoomSide side) const +{ + int n = 0; + const int len = SideLength(*this, side); + for (int i = 0; i < len; ++i) + { + int x = 0, y = 0; + SideCell(*this, side, i, x, y); + if (At(x, y) == RoomCell::DOOR) ++n; + } + return n; +} + +bool Room::FirstDoorOn(RoomSide side, int& outX, int& outY) const +{ + const int len = SideLength(*this, side); + for (int i = 0; i < len; ++i) + { + int x = 0, y = 0; + SideCell(*this, side, i, x, y); + if (At(x, y) != RoomCell::DOOR) continue; + outX = x; + outY = y; + return true; + } + return false; +} + +void Room::DefaultBreachOn(RoomSide side, int& outX, int& outY) const +{ + SideCell(*this, side, SideLength(*this, side) / 2, outX, outY); +} + +// Сообщения об ошибках — латиницей, и это не мелочь: их показывает редактор, +// а встроенный шрифт raylib кириллицы не знает и рисует её вопросами. +// Комментарии при этом остаются русскими: их читают в исходнике, а не на экране. +std::string RoomProblem(const Room& r) +{ + if (!Room::SizeOk(r.w, r.h)) return "size out of range"; + + if (r.theme >= THEME_COUNT) return "unknown theme"; + + for (int x = 0; x < r.w; ++x) + for (int y = 0; y < r.h; ++y) + if (r.At(x, y) == RoomCell::DOOR && !r.OnBorder(x, y)) + return "door is not on the border"; + + if (r.CountDoors() == 0) return "no doors at all"; + + int floorCells = 0; + for (int y = 1; y < r.h - 1; ++y) + for (int x = 1; x < r.w - 1; ++x) + if (r.At(x, y) == RoomCell::EMPTY) ++floorCells; + if (floorCells == 0) return "no floor inside"; + + for (const SpawnPoint& p : r.spawns) + { + if (!r.Walkable(p.x, p.y)) return "spawn point inside a wall"; + if (p.options.empty()) return "spawn point has an empty table"; + } + return {}; +} + +// ----------------------------------------------------------------------------- +// Чтение и запись +// ----------------------------------------------------------------------------- +namespace +{ + +// spawn ... +bool ParseSpawn(const std::vector& p, SpawnPoint& out) +{ + if (p.size() < 5) return false; + out.x = text::ToInt(p[1], -1); + out.y = text::ToInt(p[2], -1); + out.chance = text::ToFloat(p[3], 1.0f); + if (out.chance < 0.0f) out.chance = 0.0f; + if (out.chance > 1.0f) out.chance = 1.0f; + + for (size_t i = 4; i < p.size(); ++i) + { + std::string id, weight; + if (!text::SplitPair(p[i], id, weight)) return false; + const int wgt = text::ToInt(weight, 0); + if (id.empty() || wgt <= 0) return false; + out.options.push_back(SpawnOption{id, wgt}); + } + return !out.options.empty(); +} + +} // namespace + +bool LoadRoom(const std::string& path, Room& out, std::string* err) +{ + std::ifstream in(path); + if (!in) + { + if (err) *err = "cannot open file"; + return false; + } + + Room r; + // Планировка приходит целиком блоком `tiles`; пока он не прочитан, размер + // ещё не известен, и заводить сетку не по чему. + bool sized = false; + bool tiled = false; + int lineNo = 0; + std::string line; + + auto fail = [&](const char* what) { + if (err) *err = "line " + std::to_string(lineNo) + ": " + what; + return false; + }; + + while (std::getline(in, line)) + { + ++lineNo; + const std::vector p = text::Split(text::StripComment(line)); + if (p.empty()) continue; + + const std::string& key = p[0]; + + if (key == "name" && p.size() >= 2) { r.name = p[1]; continue; } + if (key == "weight" && p.size() >= 2) { r.weight = std::max(1, text::ToInt(p[1], 1)); continue; } + if (key == "theme" && p.size() >= 2) { r.theme = uint8_t(text::ToInt(p[1], 0)); continue; } + if (key == "start" && p.size() >= 2) { r.start = text::ToInt(p[1], 0) != 0; continue; } + + // depth [max]; max опущен или 0 — до бесконечности. + if (key == "depth" && p.size() >= 2) + { + r.minDepth = std::max(1, text::ToInt(p[1], 1)); + r.maxDepth = (p.size() >= 3) ? text::ToInt(p[2], 0) : 0; + if (r.maxDepth > 0 && r.maxDepth < r.minDepth) return fail("max глубины меньше min"); + continue; + } + + if (key == "size" && p.size() >= 3) + { + const int w = text::ToInt(p[1], 0); + const int h = text::ToInt(p[2], 0); + if (!Room::SizeOk(w, h)) return fail("bad room size"); + r.w = w; + r.h = h; + for (RoomCell& c : r.cells) c = RoomCell::WALL; + sized = true; + continue; + } + + if (key == "tiles") + { + if (!sized) return fail("tiles before size"); + if (tiled) return fail("duplicate tiles block"); + + // Строки сетки читаются СЫРЫМИ: '#' здесь означает стену, а не + // начало комментария, и через StripComment их пропускать нельзя. + for (int y = 0; y < r.h; ++y) + { + if (!std::getline(in, line)) return fail("tiles block truncated"); + ++lineNo; + // Хвостовой \r от файла с windows-переводами строк — не тайл. + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back(); + if (int(line.size()) != r.w) return fail("row length differs from room width"); + + for (int x = 0; x < r.w; ++x) + { + RoomCell c = RoomCell::WALL; + if (!CellOf(line[size_t(x)], c)) return fail("unknown tile char"); + r.Set(x, y, c); + } + } + tiled = true; + continue; + } + + if (key == "spawn") + { + if (!tiled) return fail("spawn before tiles block"); + SpawnPoint sp; + if (!ParseSpawn(p, sp)) return fail("malformed spawn record"); + if (!r.Walkable(sp.x, sp.y)) return fail("spawn point off the room floor"); + if (r.SpawnIndexAt(sp.x, sp.y) >= 0) return fail("two spawn points on one cell"); + r.spawns.push_back(std::move(sp)); + continue; + } + + return fail("unknown field"); + } + + if (!tiled) + { + if (err) *err = "no tiles block"; + return false; + } + + // Имя по умолчанию — имя файла: оно и так уникально в каталоге, а поле + // `name` нужно ровно для того, чтобы имя пережило переименование файла. + if (r.name.empty()) r.name = fs::path(path).stem().string(); + + out = std::move(r); + return true; +} + +bool SaveRoom(const std::string& path, const Room& r, std::string* err) +{ + std::error_code ec; + fs::create_directories(fs::path(path).parent_path(), ec); + + std::ofstream f(path, std::ios::binary); // \n без \r: файл лежит в git + if (!f) + { + if (err) *err = "cannot open file for writing"; + return false; + } + + f << "# комната: планировка и таблицы спавна. Формат — engine/room.h\n"; + f << "name " << (r.name.empty() ? "room" : r.name) << "\n"; + f << "size " << r.w << " " << r.h << "\n"; + f << "weight " << r.weight << "\n"; + f << "theme " << int(r.theme) << "\n"; + f << "start " << (r.start ? 1 : 0) << "\n"; + f << "depth " << r.minDepth << " " << r.maxDepth << "\n"; + f << "tiles\n"; + for (int y = 0; y < r.h; ++y) + { + for (int x = 0; x < r.w; ++x) f << CharOf(r.At(x, y)); + f << "\n"; + } + + // Точки пишутся в порядке обхода сетки, а не в порядке добавления: иначе + // перестановка кистей в редакторе давала бы diff на ровном месте. + std::vector sorted; + sorted.reserve(r.spawns.size()); + for (const SpawnPoint& p : r.spawns) sorted.push_back(&p); + std::sort(sorted.begin(), sorted.end(), [](const SpawnPoint* a, const SpawnPoint* b) { + return (a->y != b->y) ? (a->y < b->y) : (a->x < b->x); + }); + + for (const SpawnPoint* p : sorted) + { + if (p->options.empty()) continue; // пустая таблица в файле — мусор + char chance[16]; + std::snprintf(chance, sizeof(chance), "%.2f", double(p->chance)); + f << "spawn " << p->x << " " << p->y << " " << chance; + for (const SpawnOption& o : p->options) f << " " << o.id << ":" << o.weight; + f << "\n"; + } + + if (!f.good()) + { + if (err) *err = "write failed"; + return false; + } + return true; +} + +// ----------------------------------------------------------------------------- +// Набор комнат +// ----------------------------------------------------------------------------- +bool RoomLibrary::Load(const std::string& dirPath, std::string* err) +{ + rooms.clear(); + failures.clear(); + dir = dirPath; + + std::error_code ec; + if (!fs::is_directory(dirPath, ec)) + { + if (err) *err = "no rooms directory: " + dirPath; + return false; + } + + // Порядок обхода каталога файловой системой не определён, а генератор + // обязан быть детерминирован по сиду. Поэтому сначала собираем имена + // и сортируем, и только потом читаем. + std::vector files; + for (const fs::directory_entry& e : fs::directory_iterator(dirPath, ec)) + if (e.is_regular_file(ec) && e.path().extension() == ".room") files.push_back(e.path()); + std::sort(files.begin(), files.end()); + + for (const fs::path& p : files) + { + Room r; + std::string why; + if (LoadRoom(p.string(), r, &why)) + rooms.push_back(std::move(r)); + else + failures.push_back(p.filename().string() + ": " + why); + } + + if (rooms.empty()) + { + if (err) *err = "no readable rooms in " + dirPath; + return false; + } + return true; +} + +int RoomLibrary::TotalWeight() const +{ + int total = 0; + for (const Room& r : rooms) total += std::max(1, r.weight); + return total; +} + +int RoomLibrary::IndexOf(const std::string& name) const +{ + for (size_t i = 0; i < rooms.size(); ++i) + if (rooms[i].name == name) return int(i); + return -1; +} + +bool RoomLibrary::HasStartRoom() const +{ + for (const Room& r : rooms) + if (r.start) return true; + return false; +} + +int RoomLibrary::CountAt(int depth) const +{ + int n = 0; + for (const Room& r : rooms) + if (r.InDepth(depth)) ++n; + return n; +} + +bool RoomLibrary::HasStartRoomAt(int depth) const +{ + for (const Room& r : rooms) + if (r.start && r.InDepth(depth)) return true; + return false; +} + +// FindRoomsDir() здесь БОЛЬШЕ НЕТ. Каталог комнат называет проект +// (engine/project.h), и движок, который сам ищет чьё-то assets/rooms, — это +// движок с зашитым именем ровно одной игры. diff --git a/engine/room.h b/engine/room.h new file mode 100644 index 0000000..faf9698 --- /dev/null +++ b/engine/room.h @@ -0,0 +1,198 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Комната — ШАБЛОН, а не готовый кусок карты. +// +// В файле лежит планировка (пол, стены, двери) и точки спавна, но НЕ лежит +// ответ на вопрос «кто здесь стоит». Точка спавна несёт таблицу весов по id +// каталога и шанс срабатывания: одна и та же комната, поставленная дважды, +// населена по-разному. Именно поэтому уровень, собранный из комнат, читается +// как уровень, а не как одна и та же кишка, повторённая пять раз. +// +// Размер ограничен ROOM_MAX_*, потому что генератор ставит комнаты в слоты +// фиксированного размера (engine/level_gen.h). Комната меньше слота — вокруг +// неё остаётся камень, и в нём режутся коридоры; комната размером в слот +// оставила бы коридорам ноль места. +// +// Формат текстовый и построчный: комнаты лежат в git, и правка планировки +// обязана читаться в diff. Бинарный формат сэкономил бы килобайты и отнял бы +// единственный способ увидеть, что именно изменилось. +// ----------------------------------------------------------------------------- +#include +#include +#include + +#include "engine/theme.h" + +// Слот генератора — 16 тайлов (48/3). Комната не может занять его целиком: +// хотя бы по тайлу с каждой стороны остаётся под коридоры и рамку. +constexpr int ROOM_SLOT = 16; +constexpr int ROOM_MAX_W = 14; +constexpr int ROOM_MAX_H = 14; +constexpr int ROOM_MIN_W = 5; +constexpr int ROOM_MIN_H = 5; + +enum class RoomCell : uint8_t +{ + EMPTY = 0, // пол + WALL = 1, + DOOR = 2, // проход наружу: пол, к которому генератор тянет коридор +}; + +// Стороны в мировых осях: y растёт на юг, x — на восток. +enum class RoomSide : uint8_t +{ + NORTH = 0, + EAST, + SOUTH, + WEST, + COUNT, +}; + +inline int SideDX(RoomSide s) { return (s == RoomSide::EAST) ? 1 : (s == RoomSide::WEST ? -1 : 0); } +inline int SideDY(RoomSide s) { return (s == RoomSide::SOUTH) ? 1 : (s == RoomSide::NORTH ? -1 : 0); } +inline RoomSide OppositeSide(RoomSide s) { return RoomSide((int(s) + 2) % 4); } + +// Один вариант в таблице точки спавна. id — строка каталога, а не индекс: +// каталог правится руками, и перестановка строк в нём не должна молча менять +// население всех комнат разом. +struct SpawnOption +{ + std::string id; + int weight = 1; +}; + +// Точка спавна: место плюс таблица «кто здесь может оказаться». +struct SpawnPoint +{ + int x = 0; + int y = 0; + float chance = 1.0f; // вероятность, что точка вообще сработает + std::vector options; // пусто — точка не даёт никого + + int TotalWeight() const; + + // Выбрать вариант по броску [0..1). -1 — таблица пуста или все веса нулевые. + int Pick(float roll01) const; + + // Вес по id; 0 — такого варианта в таблице нет. + int WeightOf(const std::string& id) const; + + // Выставить вес. 0 убирает вариант из таблицы: нулевые веса в файле — + // это мусор, который переживает правку и потом путает. + void SetWeight(const std::string& id, int weight); +}; + +struct Room +{ + std::string name; // имя файла без расширения + int w = 12; + int h = 10; + int weight = 1; // вес комнаты при выборе генератором + uint8_t theme = 0; + bool start = false; // годится как стартовая комната отряда + + // --- глубина -------------------------------------------------------------- + // С какого этажа комната встречается и по какой (0 — до бесконечности). + // Планировка — тоже часть «чем глубже, тем хуже»: широкая арена первых + // этажей и теснота, где нельзя развернуть строй, — это разные игры, и + // отдавать их случайности на всех этажах поровну незачем. + int minDepth = 1; + int maxDepth = 0; + + bool InDepth(int depth) const + { + return depth >= minDepth && (maxDepth <= 0 || depth <= maxDepth); + } + + RoomCell cells[ROOM_MAX_W * ROOM_MAX_H] = {}; + std::vector spawns; + + Room(); // пустая комната с рамкой стен + + static bool SizeOk(int w, int h) + { + return w >= ROOM_MIN_W && w <= ROOM_MAX_W && h >= ROOM_MIN_H && h <= ROOM_MAX_H; + } + + bool InBounds(int x, int y) const + { + return unsigned(x) < unsigned(w) && unsigned(y) < unsigned(h); + } + + // Вне комнаты — стена. То же правило, что у Tilemap: оно убирает граничные + // проверки из всего, что ходит по сетке. + RoomCell At(int x, int y) const + { + return InBounds(x, y) ? cells[size_t(y) * ROOM_MAX_W + x] : RoomCell::WALL; + } + void Set(int x, int y, RoomCell c) + { + if (InBounds(x, y)) cells[size_t(y) * ROOM_MAX_W + x] = c; + } + + bool Walkable(int x, int y) const { return At(x, y) != RoomCell::WALL; } + bool OnBorder(int x, int y) const + { + return InBounds(x, y) && (x == 0 || y == 0 || x == w - 1 || y == h - 1); + } + + // --- точки спавна --------------------------------------------------------- + int SpawnIndexAt(int x, int y) const; + SpawnPoint* SpawnAt(int x, int y); + const SpawnPoint* SpawnAt(int x, int y) const; + // Точка на этом месте либо уже есть, либо заводится пустой. + SpawnPoint& TouchSpawnAt(int x, int y); + void RemoveSpawnAt(int x, int y); + + // --- правка планировки ---------------------------------------------------- + // Размер меняется с сохранением содержимого; всё, что вышло за новую + // границу, теряется вместе с точками спавна на нём. + void Resize(int newW, int newH); + void FillBorder(); // обвести рамкой стен, двери сохранить + void ClearInterior(); // выскоблить всё внутри рамки + + // --- двери ---------------------------------------------------------------- + // Двери обязаны стоять на рамке: генератор тянет коридор НАРУЖУ, и дверь + // посреди комнаты вела бы в собственную стену. + int CountDoors() const; + int CountDoorsOn(RoomSide side) const; + // Первая дверь на стороне. false — на этой стороне дверей нет. + bool FirstDoorOn(RoomSide side, int& outX, int& outY) const; + // Куда генератор пробьёт стену, если дверей на стороне нет: середина стороны. + void DefaultBreachOn(RoomSide side, int& outX, int& outY) const; +}; + +// Что в комнате не так. Пустая строка — всё в порядке. Не «загрузилась ли», +// а «сыграет ли»: комнату без дверей генератор поставить не сможет. +std::string RoomProblem(const Room& r); + +bool LoadRoom(const std::string& path, Room& out, std::string* err = nullptr); +bool SaveRoom(const std::string& path, const Room& r, std::string* err = nullptr); + +// ----------------------------------------------------------------------------- +// Набор комнат: всё, что лежит в assets/rooms/*.room. +// ----------------------------------------------------------------------------- +struct RoomLibrary +{ + std::string dir; + std::vector rooms; + // Комнаты, которые не загрузились: имя файла и причина. Редактор обязан + // показать их списком, а не молча недосчитаться комнаты в наборе. + std::vector failures; + + // Порядок — алфавитный по имени файла: генератор детерминирован по сиду, + // а порядок обхода каталога файловой системой таковым не является. + bool Load(const std::string& dirPath, std::string* err = nullptr); + + int Count() const { return int(rooms.size()); } + int TotalWeight() const; + int IndexOf(const std::string& name) const; + bool HasStartRoom() const; + + // Есть ли чем застроить этот этаж. Разделено на два вопроса, потому что + // это две РАЗНЫЕ поломки забега: «нет обычных комнат» и «нет стартовой». + int CountAt(int depth) const; + bool HasStartRoomAt(int depth) const; +}; + +// Где лежит набор — знает проект (engine/project.h). Движок сам его не ищет. diff --git a/engine/sfa_library.cpp b/engine/sfa_library.cpp new file mode 100644 index 0000000..3b43956 --- /dev/null +++ b/engine/sfa_library.cpp @@ -0,0 +1,418 @@ +#include "engine/sfa_library.h" + +#include +#include +#include +#include +#include +#include + +// sfa.h — чужой заголовок из репозитория SpriteForge, единственный экземпляр +// на всю игру лежит здесь. Он держит весь код в статических функциях, поэтому +// без глушилки предупреждений /W4 и -Wall тонут в шуме про неиспользованное. +#if defined(_MSC_VER) +# pragma warning(push, 0) +#elif defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wunused-function" +#endif +#include "sfa.h" +#if defined(_MSC_VER) +# pragma warning(pop) +# pragma warning(disable : 4505) // C4505 приходит в конце файла, а не в блоке выше +#elif defined(__GNUC__) +# pragma GCC diagnostic pop +#endif + +namespace fs = std::filesystem; + +namespace +{ + +// Полный оборот в сотых долях градуса: .sfa хранит направления именно так. +constexpr int FULL_TURN_CD = 36000; +constexpr double RAD_TO_CD = 18000.0 / 3.14159265358979323846; +constexpr float MS_TO_SEC = 0.001f; + +// Тот же хеш, которым `sf codegen` считает SF_ASSET_*: FNV-1a, 32 бита, по +// байтам UTF-8. Совпадение обязано быть точным, иначе ассет не найдётся. +uint32_t Fnv1a(const std::string& s) +{ + uint32_t h = 2166136261u; + for (const char c : s) h = (h ^ uint8_t(c)) * 16777619u; + return h; +} + +bool ReadWhole(const fs::path& path, std::vector& out) +{ + std::error_code ec; + const auto size = fs::file_size(path, ec); + if (ec) return false; + + std::ifstream in(path, std::ios::binary); + if (!in) return false; + + out.resize(size_t(size)); + if (out.empty()) return false; + in.read(reinterpret_cast(out.data()), std::streamsize(out.size())); + return bool(in); +} + +// Кадр .sfa -> Sprite на холсте по размеру кадра, привязка ровно в pivot. +// Дальше игра работает только с этим спрайтом: RLE распаковывается один раз +// за загрузку библиотеки. +bool DecodeFrame(const sfa_view& view, const sfa_frame& frame, const uint8_t* palette, Sprite& out) +{ + Sprite sprite; + sprite.Init(int(frame.width), int(frame.height), 0); // прозрачный фон + sprite.ax = int(frame.pivot_x); + sprite.ay = int(frame.pivot_y); + if (sprite.px.empty()) return false; + + // SFA_BLEND_OPAQUE ставит альфу 255 ровно на записанных пикселях, а порядок + // каналов в sfa_mix (R в младшем байте) совпадает с RGBA() фреймбуфера. + sfa_surface surface{sprite.px.data(), sprite.w, sprite.h, sprite.w}; + if (sfa_blit(&view, &frame, palette, nullptr, surface, sprite.ax, sprite.ay, + SFA_BLEND_OPAQUE) != SFA_OK) + return false; + + out = std::move(sprite); + return true; +} + +} // namespace + +bool SpriteLibrary::Open(const std::string& dir) +{ + // Копия до Close(): аргументом легко приезжает Dir() этой же библиотеки + // (`lib.Open(lib.Dir())`), а Close() эту строку обнуляет — дальше каталог + // читался бы по висячей ссылке. + const std::string path = dir; + Close(); + + const fs::path root(path); + + // Палитра общая на всю библиотеку: индексы в кадрах без неё бессмысленны, + // и читать её надо до ассетов — кадры распаковываются прямо здесь. + std::vector pack; + if (!ReadWhole(root / "palettes.sfp", pack)) return false; + + sfp_view pv{}; + const uint8_t* rgb = nullptr; + if (sfp_open(pack.data(), pack.size(), &pv) != SFA_OK) return false; + if (sfp_palette(&pv, 0, &rgb) != SFA_OK) return false; + std::memcpy(palette_.data(), rgb, palette_.size()); + + // Имя файла — это и есть строковый ID ассета. Больше строковых ID в игре + // нет нигде: дальше работают только числа из generated/. + std::error_code ec; + for (const fs::directory_entry& entry : fs::directory_iterator(root, ec)) + { + if (!entry.is_regular_file(ec)) continue; + if (entry.path().extension() != ".sfa") continue; + + std::vector bytes; + if (!ReadWhole(entry.path(), bytes)) continue; + + Asset asset; + asset.name = entry.path().stem().string(); + if (!DecodeAsset(bytes.data(), bytes.size(), asset)) continue; + + assets_.emplace(Fnv1a(asset.name), std::move(asset)); + } + if (ec) { Close(); return false; } + + dir_ = path; + stamp_ = ReloadStamp(); + open_ = !assets_.empty(); + if (!open_) Close(); + return open_; +} + +void SpriteLibrary::Close() +{ + assets_.clear(); + palette_.fill(0); + dir_.clear(); + stamp_ = Stamp{}; + open_ = false; +} + +bool SpriteLibrary::DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const +{ + sfa_view view{}; + if (sfa_open(bytes, size, &view) != SFA_OK) return false; + + out.frames.resize(size_t(view.frame_count)); + out.durationsMs.resize(size_t(view.frame_count)); + for (uint32_t i = 0; i < view.frame_count; ++i) + { + sfa_frame frame{}; + if (sfa_get_frame(&view, i, &frame) != SFA_OK) return false; + if (!DecodeFrame(view, frame, palette_.data(), out.frames[i])) return false; + out.durationsMs[i] = frame.duration_ms; + } + + out.clips.reserve(view.animation_count); + for (uint32_t i = 0; i < view.animation_count; ++i) + { + sfa_animation anim{}; + if (sfa_get_animation(&view, uint16_t(i), &anim) != SFA_OK) return false; + + // Диапазон клипа проверяется здесь и только здесь: если он сходится, + // выборка кадра дальше не может выйти за плоский список. + const uint64_t last = uint64_t(anim.first_frame) + + uint64_t(anim.frames_per_direction) * view.direction_count; + if (anim.frames_per_direction == 0 || last > view.frame_count) return false; + + out.clips.push_back(AnimClip{anim.name_hash, int(anim.first_frame), + int(anim.frames_per_direction)}); + } + + out.dirs.reserve(view.direction_count); + for (uint32_t i = 0; i < view.direction_count; ++i) + { + sfa_direction dir{}; + if (sfa_get_direction(&view, uint16_t(i), &dir) != SFA_OK) return false; + // Угол знаковый: 315 градусов приезжают и как 31500, и как -4500. + int cd = int(dir.centidegrees) % FULL_TURN_CD; + if (cd < 0) cd += FULL_TURN_CD; + out.dirs.push_back(cd); + } + + return !out.frames.empty() && !out.clips.empty() && !out.dirs.empty(); +} + +const SpriteLibrary::Asset* SpriteLibrary::FindAsset(uint32_t assetId) const +{ + const auto it = assets_.find(assetId); + return (it == assets_.end()) ? nullptr : &it->second; +} + +const AnimClip* SpriteLibrary::FindClip(const Asset& asset, uint32_t animNameHash) +{ + // Клипов у ассета единицы, хеш-таблица тут дороже перебора. + for (const AnimClip& clip : asset.clips) + if (clip.nameHash == animNameHash) return &clip; + return nullptr; +} + +float SpriteLibrary::ClipLength(const Asset& asset, const AnimClip& clip) +{ + // Тайминг берётся с направления 0: кадры у направлений одни и те же, а + // разъехавшиеся длительности превратили бы поворот на месте в рывок. + float total = 0.0f; + for (int i = 0; i < clip.framesPerDir; ++i) + total += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC; + return total; +} + +int SpriteLibrary::FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop) +{ + const float length = ClipLength(asset, clip); + if (length <= 0.0f) return 0; // все кадры нулевой длительности — статичная поза + + // Отрицательное время и NaN трактуем как начало клипа: сущность в первом + // кадре выглядит нормально, а пропуск отрисовки — нет. + float t = (timeSec > 0.0f) ? timeSec : 0.0f; + if (loop) + { + t = std::fmod(t, length); + if (t < 0.0f) t = 0.0f; + } + + float acc = 0.0f; + for (int i = 0; i < clip.framesPerDir; ++i) + { + acc += float(asset.durationsMs[size_t(clip.firstFrame) + size_t(i)]) * MS_TO_SEC; + if (t < acc) return i; + } + return clip.framesPerDir - 1; // незацикленный клип замирает на последнем кадре +} + +int SpriteLibrary::FrameCount(uint32_t assetId) const +{ + const Asset* asset = FindAsset(assetId); + return asset ? int(asset->frames.size()) : 0; +} + +int SpriteLibrary::AnimationCount(uint32_t assetId) const +{ + const Asset* asset = FindAsset(assetId); + return asset ? int(asset->clips.size()) : 0; +} + +bool SpriteLibrary::FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const +{ + const Asset* asset = FindAsset(assetId); + if (!asset) return false; + + const AnimClip* clip = FindClip(*asset, animNameHash); + if (!clip) return false; + + out = *clip; + return true; +} + +int SpriteLibrary::DirectionCount(uint32_t assetId) const +{ + const Asset* asset = FindAsset(assetId); + return asset ? int(asset->dirs.size()) : 0; +} + +int SpriteLibrary::DirectionOf(uint32_t assetId, float angleRad) const +{ + const Asset* asset = FindAsset(assetId); + if (!asset || asset->dirs.size() < 2) return 0; + + const double turn = double(angleRad) * RAD_TO_CD; + if (!std::isfinite(turn)) return 0; // угол из мусора — не повод считать fmod + int cd = int(std::fmod(turn, double(FULL_TURN_CD))); + if (cd < 0) cd += FULL_TURN_CD; + + // Набор направлений не обязан быть равномерным, поэтому не деление, а поиск + // ближайшего по кругу: 350 градусов должны попасть в направление 0. + int best = 0; + int bestDist = FULL_TURN_CD; + for (size_t i = 0; i < asset->dirs.size(); ++i) + { + int dist = std::abs(asset->dirs[i] - cd); + if (dist > FULL_TURN_CD / 2) dist = FULL_TURN_CD - dist; + if (dist < bestDist) + { + bestDist = dist; + best = int(i); + } + } + return best; +} + +const Sprite* SpriteLibrary::Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex, + float timeSec, bool loop) const +{ + const Asset* asset = FindAsset(assetId); + if (!asset) return nullptr; + + const AnimClip* clip = FindClip(*asset, animNameHash); + if (!clip) return nullptr; + + // Направление вне набора — не повод не нарисовать сущность. + const int dirs = int(asset->dirs.size()); + const int dir = (dirIndex < 0) ? 0 : ((dirIndex >= dirs) ? dirs - 1 : dirIndex); + + // Раскладка направленчески-мажорная, границы клипа проверены на загрузке. + const size_t index = size_t(clip->firstFrame) + size_t(dir) * size_t(clip->framesPerDir) + + size_t(FrameInClip(*asset, *clip, timeSec, loop)); + if (index >= asset->frames.size()) return nullptr; + return &asset->frames[index]; +} + +float SpriteLibrary::AnimationLength(uint32_t assetId, uint32_t animNameHash) const +{ + const Asset* asset = FindAsset(assetId); + if (!asset) return 0.0f; + + const AnimClip* clip = FindClip(*asset, animNameHash); + return clip ? ClipLength(*asset, *clip) : 0.0f; +} + +bool SpriteLibrary::Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const +{ + const Asset* asset = FindAsset(assetId); + if (!asset || frame < 0 || frame >= int(asset->frames.size())) return false; + + const Sprite& src = asset->frames[size_t(frame)]; + + // Холст по размеру кадра — копия как есть, привязка уже стоит в pivot. + const bool padded = place.canvasW > 0 && place.canvasH > 0; + if (!padded) + { + out = src; + if (place.anchorX >= 0) out.ax = place.anchorX; + if (place.anchorY >= 0) out.ay = place.anchorY; + return true; + } + + Sprite sprite; + sprite.Init(place.canvasW, place.canvasH, 0); + sprite.ax = (place.anchorX >= 0) ? place.anchorX : place.pivotX; + sprite.ay = (place.anchorY >= 0) ? place.anchorY : place.pivotY; + + // Тот же перенос, что делал бы sfa_blit с якорем (pivotX, pivotY): pivot + // кадра совмещается с заданной точкой холста, прозрачное не пишется. + const int x0 = place.pivotX - src.ax; + const int y0 = place.pivotY - src.ay; + for (int y = 0; y < src.h; ++y) + for (int x = 0; x < src.w; ++x) + { + const uint32_t c = src.px[size_t(y) * size_t(src.w) + size_t(x)]; + if (AlphaOf(c) != 0) sprite.Set(x0 + x, y0 + y, c); + } + + out = std::move(sprite); + return true; +} + +const char* SpriteLibrary::NameOf(uint32_t assetId) const +{ + const Asset* asset = FindAsset(assetId); + return asset ? asset->name.c_str() : ""; +} + +std::vector SpriteLibrary::Ids() const +{ + std::vector ids; + ids.reserve(assets_.size()); + for (const auto& pair : assets_) ids.push_back(pair.first); + std::sort(ids.begin(), ids.end(), [this](uint32_t a, uint32_t b) { + return assets_.at(a).name < assets_.at(b).name; + }); + return ids; +} + +SpriteLibrary::Stamp SpriteLibrary::ReloadStamp() const +{ + Stamp stamp; + if (dir_.empty()) return stamp; + + std::error_code ec; + const fs::path path = fs::path(dir_) / "reload.json"; + const auto time = fs::last_write_time(path, ec); + if (ec) return stamp; + stamp.time = time.time_since_epoch().count(); + stamp.size = uint64_t(fs::file_size(path, ec)); + if (ec) return Stamp{}; + return stamp; +} + +bool SpriteLibrary::PollReload() +{ + if (!open_) return false; + + const Stamp fresh = ReloadStamp(); + if (fresh.time == 0 || !(fresh != stamp_)) return false; + + // `sf watch` обновляет reload.json последним, когда выходные файлы уже на + // месте. Но если пересборка всё же долетела не целиком, старая библиотека + // обязана остаться рабочей: собираем новую рядом и подменяем только целую. + // Заодно это единственный честный способ пережить смену набора анимаций. + SpriteLibrary next; + if (!next.Open(dir_)) + { + stamp_ = fresh; // иначе неудачную попытку будет видно каждый кадр + return false; + } + *this = std::move(next); + return true; +} + +std::string FindSpriteLibraryDir() +{ + // Запуск из корня репозитория и запуск из build/bin — оба рабочие. + static const char* const candidates[] = {"build/sprites", "sprites", "../sprites", + "../../build/sprites"}; + std::error_code ec; + for (const char* candidate : candidates) + if (fs::exists(fs::path(candidate) / "palettes.sfp", ec)) return candidate; + return {}; +} diff --git a/engine/sfa_library.h b/engine/sfa_library.h new file mode 100644 index 0000000..af9476a --- /dev/null +++ b/engine/sfa_library.h @@ -0,0 +1,160 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Чтение библиотеки спрайтов, собранной SpriteForge (`sf build`). +// +// Игра не знает про YAML, генераторы и PNG. Она видит только каталог с файлами +// `.sfa` и общей палитрой `palettes.sfp`, а обращается к ассетам по +// числовым идентификаторам из generated/spriteforge_assets.h. Строковых ID +// ассетов в коде игры быть не должно. +// +// Слой ровно один: .sfa (индексы палитры + RLE по строкам) -> игровой Sprite +// (RGBA8 с альфа-тестом). Дальше по коду разницы между процедурным спрайтом +// и собранным нет. +// +// Распаковка идёт целиком на Open() и только там. Ленивое декодирование стоило +// бы RLE-прохода и аллокации холста на КАЖДУЮ анимированную сущность в КАЖДОМ +// кадре игры; готовые Sprite лежат в памяти, наружу отдаются ссылки на них. +// ----------------------------------------------------------------------------- +#include +#include +#include +#include +#include + +#include "engine/framebuffer.h" +#include "spriteforge_assets.h" // sf_asset_id, SF_ASSET_*, SF_ANIM_* + +// Куда положить кадр .sfa на холст игрового спрайта. +// +// SpriteForge обрезает кадр по силуэту, поэтому размер кадра плывёт вместе с +// содержимым. Тайлам это не подходит: ShadeQuad считается в координатах +// спрайта и предполагает канонический холст с ромбом в известном месте +// (Lighting::QuadFor). Placement кладёт кадр на такой холст: pivot из .sfa +// совмещается с (pivotX, pivotY). +struct FramePlacement +{ + int canvasW = 0; // 0 — холст по размеру кадра, без дополнения + int canvasH = 0; + int pivotX = 0; // куда на холсте попадает pivot кадра + int pivotY = 0; + int anchorX = -1; // Sprite::ax; -1 — привязка в точке pivot + int anchorY = -1; // Sprite::ay +}; + +// Один клип внутри ассета. Имя приходит только хешем (SF_ANIM_*): строковых +// имён анимаций в игре, как и строковых ID ассетов, быть не должно. +struct AnimClip +{ + uint32_t nameHash = 0; + int firstFrame = 0; // индекс первого кадра клипа в плоском списке кадров + int framesPerDir = 0; // кадров на одно направление +}; + +class SpriteLibrary +{ +public: + // Открыть каталог сборки (обычно build/sprites). Файлы читаются целиком + // и распаковываются сразу: битый ассет просто не попадает в набор. + bool Open(const std::string& dir); + void Close(); + + bool IsOpen() const { return open_; } + const std::string& Dir() const { return dir_; } + int AssetCount() const { return int(assets_.size()); } + + // Все кадры ассета скопом, по всем анимациям и направлениям. + int FrameCount(uint32_t assetId) const; + + // Кадр как игровой Sprite. false — нет такого ассета или кадра; out в этом + // случае не трогается. Копия: вызывающий волен её пережить и подшаманить + // (так грузятся тайлы, им нужен канонический холст). + bool Read(uint32_t assetId, int frame, const FramePlacement& place, Sprite& out) const; + bool Read(uint32_t assetId, int frame, Sprite& out) const + { + return Read(assetId, frame, FramePlacement{}, out); + } + + // --- анимации --- + // + // Раскладка кадров в .sfa НАПРАВЛЕНЧЕСКИ-МАЖОРНАЯ, клипы идут подряд: + // index = clip.firstFrame + dirIndex * clip.framesPerDir + frameInClip + // Поэтому firstFrame анимации — это не ноль, а конец предыдущей. + + int AnimationCount(uint32_t assetId) const; + + // Клип по хешу имени (SF_ANIM_* из generated/spriteforge_assets.h). + bool FindAnimation(uint32_t assetId, uint32_t animNameHash, AnimClip& out) const; + + // Сколько направлений отрисовано у ассета; 1 — вид один на все стороны. + int DirectionCount(uint32_t assetId) const; + + // Индекс направления, ближайшего к мировому углу (радианы, как Agent::facing). + // Нет ассета — 0: рисовать всё равно нечего, а ветвиться у вызывающего лишнее. + int DirectionOf(uint32_t assetId, float angleRad) const; + + // Кадр анимации: timeSec — время от её начала, loop — зациклить, иначе клип + // замирает на последнем кадре (смерть так и остаётся лежать). + // nullptr — нет такого ассета или анимации. + // + // ВНИМАНИЕ: указатель живёт до ближайшего PollReload(), вернувшего true. + // После пересборки библиотеки все выданные раньше кадры недействительны, + // держать их между кадрами игры нельзя. + const Sprite* Frame(uint32_t assetId, uint32_t animNameHash, int dirIndex, + float timeSec, bool loop) const; + + // Длительность клипа целиком, сек. 0 — нет такой анимации либо все кадры + // нулевой длительности (статичная поза). + float AnimationLength(uint32_t assetId, uint32_t animNameHash) const; + + // Watch-режим `sf watch` атомарно переписывает reload.json после успешной + // пересборки. Вызов раз в кадр; true — библиотека перечитана и спрайты, + // взятые из неё раньше, устарели. Смена набора анимаций переваривается + // сама собой: пересобирается вся библиотека целиком, а не отдельные кадры. + bool PollReload(); + + // Имя ассета (оно же имя файла) — только для отладочного отчёта. + const char* NameOf(uint32_t assetId) const; + + // Все идентификаторы, отсортированные по имени. Тоже только для отчёта. + std::vector Ids() const; + +private: + struct Asset + { + std::string name; + std::vector frames; // уже RGBA8; индекс — как в .sfa + std::vector durationsMs; // длительность кадра, параллельно frames + std::vector clips; + std::vector dirs; // сотые доли градуса, приведённые к [0, 36000) + }; + + // Отпечаток reload.json: время правки и размер. 0 — файла нет. + struct Stamp + { + int64_t time = 0; + uint64_t size = 0; + bool operator!=(const Stamp& o) const { return time != o.time || size != o.size; } + }; + + // Разобрать .sfa целиком: все кадры в Sprite, клипы, направления. + // false — файл битый или его раскладка не сходится сама с собой. + bool DecodeAsset(const uint8_t* bytes, size_t size, Asset& out) const; + + const Asset* FindAsset(uint32_t assetId) const; + static const AnimClip* FindClip(const Asset& asset, uint32_t animNameHash); + static float ClipLength(const Asset& asset, const AnimClip& clip); + static int FrameInClip(const Asset& asset, const AnimClip& clip, float timeSec, bool loop); + + Stamp ReloadStamp() const; + + std::unordered_map assets_; + std::array palette_{}; // 256 RGB-троек, индекс 0 прозрачен + std::string dir_; + Stamp stamp_; + bool open_ = false; +}; + +// Первый каталог сборки, в котором лежит palettes.sfp: рядом с бинарником +// (build/bin -> ../sprites) или относительно корня репозитория. Пустая строка — +// библиотека не собрана. +std::string FindSpriteLibraryDir(); diff --git a/engine/text_parse.h b/engine/text_parse.h new file mode 100644 index 0000000..671de82 --- /dev/null +++ b/engine/text_parse.h @@ -0,0 +1,107 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Разбор строчных текстовых форматов движка: каталог сущностей и файлы комнат. +// +// Свой микро-парсер, а не YAML/JSON: оба формата — это «слово и несколько +// значений в строке», и внешняя библиотека ради них тянула бы в движок +// зависимость тяжелее всего остального движка вместе взятого. Оба файла +// правятся руками и лежат в git, поэтому формат обязан быть построчным и +// diff-читаемым — это требование к формату, а не следствие лени. +// ----------------------------------------------------------------------------- +#include +#include +#include + +namespace text +{ + +// Комментарий — от '#' до конца строки. Внутри значений '#' не встречается: +// в тайловой сетке комнаты решётка означает стену, но сетка читается целыми +// строками и через этот разбор не проходит. +inline std::string StripComment(const std::string& line) +{ + const size_t hash = line.find('#'); + return (hash == std::string::npos) ? line : line.substr(0, hash); +} + +inline std::string Trim(const std::string& s) +{ + size_t a = 0; + size_t b = s.size(); + while (a < b && (s[a] == ' ' || s[a] == '\t' || s[a] == '\r')) ++a; + while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b; + return s.substr(a, b - a); +} + +// Разбить по пробелам. Пустых лексем не возвращает. +inline std::vector Split(const std::string& s) +{ + std::vector out; + size_t i = 0; + while (i < s.size()) + { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r')) ++i; + const size_t start = i; + while (i < s.size() && s[i] != ' ' && s[i] != '\t' && s[i] != '\r') ++i; + if (i > start) out.push_back(s.substr(start, i - start)); + } + return out; +} + +// Разбить надвое по разделителю. false — разделителя нет. +// +// Разделителей два, и они РАЗНЫЕ намеренно: `ключ=значение` — это свойство +// записи (каталог сущностей), `имя:вес` — элемент таблицы (веса точки спавна). +// Один символ на оба случая читался бы одинаково в двух разных ролях, и +// таблица весов внутри строки свойств стала бы неразличима. +inline bool SplitAt(const std::string& s, char sep, std::string& key, std::string& value) +{ + const size_t at = s.find(sep); + if (at == std::string::npos) return false; + key = s.substr(0, at); + value = s.substr(at + 1); + return true; +} + +// `имя:вес` — элемент таблицы. +inline bool SplitPair(const std::string& s, std::string& key, std::string& value) +{ + return SplitAt(s, ':', key, value); +} + +// `ключ=значение` — свойство записи. +inline bool SplitKeyValue(const std::string& s, std::string& key, std::string& value) +{ + return SplitAt(s, '=', key, value); +} + +inline int ToInt(const std::string& s, int fallback = 0) +{ + if (s.empty()) return fallback; + char* end = nullptr; + const long v = std::strtol(s.c_str(), &end, 10); + return (end && *end == '\0') ? int(v) : fallback; +} + +inline float ToFloat(const std::string& s, float fallback = 0.0f) +{ + if (s.empty()) return fallback; + char* end = nullptr; + const double v = std::strtod(s.c_str(), &end); + return (end && *end == '\0') ? float(v) : fallback; +} + +// Склеить хвост лексем обратно в строку — для полей, где значение с пробелами +// (подпись в палитре, теги комнаты). +inline std::string JoinFrom(const std::vector& parts, size_t first) +{ + std::string out; + for (size_t i = first; i < parts.size(); ++i) + { + if (!out.empty()) out += ' '; + out += parts[i]; + } + return out; +} + +} // namespace text diff --git a/engine/theme.h b/engine/theme.h new file mode 100644 index 0000000..a462b32 --- /dev/null +++ b/engine/theme.h @@ -0,0 +1,21 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Тема отделки — НАБОР тайлов пола и стен, которым отрисован тайл карты. +// +// Отдельный крошечный заголовок, потому что тему знают двое, и по разным +// причинам: формат комнаты (engine/room.h) хранит её как данные, тайлсет +// (engine/tileset.h) по ней выбирает спрайты. Свести их в один заголовок +// значило бы затащить рендер в формат файла. +// +// Пока темы получаются перекраской базового набора: это честная заглушка, а не +// временное решение, — она даёт различимые на глаз материалы, не требуя ни +// одного собранного ассета. Когда для темы соберут свой тайлсет, поменяется +// engine/tileset.cpp, а формат комнат и уже нарисованные комнаты — нет. +// ----------------------------------------------------------------------------- +#include + +constexpr int THEME_COUNT = 4; + +// Имя темы для палитры редактора. Неизвестный номер — "?": редактор обязан +// показать чужую тему как чужую, а не как первую попавшуюся. +const char* ThemeName(uint8_t theme); diff --git a/src/world/tilemap.cpp b/engine/tilemap.cpp similarity index 95% rename from src/world/tilemap.cpp rename to engine/tilemap.cpp index d7c7f65..0486538 100644 --- a/src/world/tilemap.cpp +++ b/engine/tilemap.cpp @@ -1,4 +1,4 @@ -#include "world/tilemap.h" +#include "engine/tilemap.h" #include @@ -19,6 +19,9 @@ constexpr float INF_F = std::numeric_limits::infinity(); void Tilemap::Generate() { for (int i = 0; i < MAP_W * MAP_H; ++i) tiles[i] = Tile::EMPTY; + // Захардкоженная планировка — одна тема на всю карту. Разные темы приходят + // только с уровнем, собранным из комнат (engine/level_gen.h). + for (int i = 0; i < MAP_W * MAP_H; ++i) theme[i] = 0; auto fill = [&](int x0, int y0, int x1, int y1, Tile t) { for (int y = y0; y <= y1; ++y) diff --git a/src/world/tilemap.h b/engine/tilemap.h similarity index 68% rename from src/world/tilemap.h rename to engine/tilemap.h index a7fe0c9..f385eaa 100644 --- a/src/world/tilemap.h +++ b/engine/tilemap.h @@ -6,8 +6,8 @@ // ----------------------------------------------------------------------------- #include -#include "core/math.h" -#include "tuning.h" +#include "engine/math.h" +#include "engine/config.h" enum class Tile : uint8_t { @@ -19,6 +19,13 @@ struct Tilemap { Tile tiles[MAP_W * MAP_H] = {}; + // Тема отделки тайла: какой НАБОР спрайтов пола и стен на нём лежит. + // Слой отдельный от tiles намеренно — проходимость и внешний вид это два + // разных вопроса, и смешав их в одном значении, мы получили бы отдельный + // тип тайла на каждое сочетание. Симуляция этот слой не читает вообще: + // рейкаст, коллизии и правила видят только Tile. + uint8_t theme[MAP_W * MAP_H] = {}; + void Generate(); // единственный захардкоженный уровень static bool InBounds(int x, int y) { return unsigned(x) < unsigned(MAP_W) && unsigned(y) < unsigned(MAP_H); } @@ -26,6 +33,9 @@ struct Tilemap Tile At(int x, int y) const { return InBounds(x, y) ? tiles[y * MAP_W + x] : Tile::WALL; } void Set(int x, int y, Tile t) { if (InBounds(x, y)) tiles[y * MAP_W + x] = t; } + uint8_t ThemeAt(int x, int y) const { return InBounds(x, y) ? theme[y * MAP_W + x] : 0; } + void SetTheme(int x, int y, uint8_t t) { if (InBounds(x, y)) theme[y * MAP_W + x] = t; } + // Вне карты считаем стеной — так рейкасты и коллизии не убегают наружу. bool IsWall(int x, int y) const { return At(x, y) == Tile::WALL; } bool IsWallAt(Vec2 p) const { return IsWall(int(std::floor(p.x)), int(std::floor(p.y))); } diff --git a/engine/tilemap_draw.cpp b/engine/tilemap_draw.cpp new file mode 100644 index 0000000..247f988 --- /dev/null +++ b/engine/tilemap_draw.cpp @@ -0,0 +1,69 @@ +#include "engine/tilemap_draw.h" + +#include +#include + +#include "engine/view2d.h" + +namespace +{ + +// Запас за краями кадра: спрайт, торчащий внутрь краем, обязан дорисоваться. +// Считается по самому большому тайлу, а не по текущей проекции, — это верхняя +// оценка, и лишние блиты по кромке дешевле мигающих стен. +constexpr int MARGIN_X = TILE_MAX_W; +constexpr int MARGIN_Y = WALL_H + TILE_MAX_H; + +bool OffScreen(int sx, int sy) +{ + return sx < -MARGIN_X || sx > INTERNAL_W + MARGIN_X || sy < -MARGIN_Y || + sy > INTERNAL_H + MARGIN_Y; +} + +} // namespace + +void DrawTilemap(Framebuffer& fb, const Tilemap& map, const TileSet& tiles, int ox, int oy) +{ + // Пол — сплошным проходом: он лежит ПОД всем, и сортировать его не с чем. + for (int ty = 0; ty < MAP_H; ++ty) + for (int tx = 0; tx < MAP_W; ++tx) + { + if (map.IsWall(tx, ty)) continue; // под стеной пола не видно + + const Vec2 c = v2d::WorldToScreen({float(tx) + 0.5f, float(ty) + 0.5f}); + const int sx = int(std::lround(c.x)) + ox; + const int sy = int(std::lround(c.y)) + oy; + if (OffScreen(sx, sy)) continue; + + fb.Blit(tiles.Floor(tx, ty, map.ThemeAt(tx, ty)), sx, sy); + } + + // Стены — по возрастанию глубины, и глубину считает ПРОЕКЦИЯ: в изометрии + // это x+y, при виде сверху — строка экрана. Обход «по диагоналям x+y» + // выглядел бы правильным и молча ломался при смене вида, поэтому порядок + // не зашит в обход, а взят у v2d::Depth через общий список отрисовки. + static std::vector items; + items.clear(); + + for (int ty = 0; ty < MAP_H; ++ty) + for (int tx = 0; tx < MAP_W; ++tx) + { + if (!map.IsWall(tx, ty)) continue; + + const Vec2 world{float(tx) + 0.5f, float(ty) + 0.5f}; + const Vec2 c = v2d::WorldToScreen(world); + const int sx = int(std::lround(c.x)) + ox; + const int sy = int(std::lround(c.y)) + oy; + if (OffScreen(sx, sy)) continue; + + v2d::DrawItem it; + it.depth = v2d::Depth(world); + it.spr = &tiles.Wall(tx, ty, map.ThemeAt(tx, ty)); + it.x = sx; + it.y = sy; + items.push_back(it); + } + + v2d::SortByDepth(items); + v2d::FlushDrawList(fb, items); +} diff --git a/engine/tilemap_draw.h b/engine/tilemap_draw.h new file mode 100644 index 0000000..7ebddce --- /dev/null +++ b/engine/tilemap_draw.h @@ -0,0 +1,21 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Нарисовать карту тайлсетом через активную проекцию. Без света, без крови, +// без сущностей — только пол и стены. +// +// Это ДЕФОЛТ движка: проект, у которого нет своего слоя света, показывает карту +// этим и больше ничего не пишет. Игра Descent им не пользуется намеренно — её +// проход по полу вплавляет свет в каждый блит (src/render/scene.cpp), и +// вынести это сюда значило бы затащить в движок туман войны. +// +// Две реализации прохода по тайлам — не дублирование, а разные задачи: +// «показать карту» и «нарисовать сцену со светом». Общее у них — порядок +// сортировки, и он один на всех, потому что живёт в проекции. +// ----------------------------------------------------------------------------- +#include "engine/framebuffer.h" +#include "engine/tilemap.h" +#include "engine/tileset.h" + +// (ox, oy) — смещение сцены в пикселях кадра; берётся у камеры +// (v2d::Camera::ScreenOffset). +void DrawTilemap(Framebuffer& fb, const Tilemap& map, const TileSet& tiles, int ox, int oy); diff --git a/engine/tileset.cpp b/engine/tileset.cpp new file mode 100644 index 0000000..8673816 --- /dev/null +++ b/engine/tileset.cpp @@ -0,0 +1,292 @@ +#include "engine/tileset.h" + +#include + +#include "engine/math.h" +#include "engine/pixel.h" +#include "engine/projection.h" +#include "engine/sfa_library.h" + +namespace +{ + +Sprite MakeFloor(uint32_t base) +{ + const int tw = g_proj.tileW; + const int th = g_proj.tileH; + + Sprite s; + s.Init(tw, th, 0); + s.ax = tw / 2; + s.ay = th / 2; + + const uint32_t edge = ShadeColor(base, 0.78f); + const uint32_t fleck = ShadeColor(base, 1.18f); + + for (int y = 0; y < th; ++y) + { + int x0 = 0, x1 = 0; + g_proj.TileRow(y, x0, x1); + for (int x = x0; x < x1; ++x) + { + uint32_t c = base; + if (x < x0 + 2 || x >= x1 - 2) c = edge; // рант тайла + else if (((x * 7 + y * 13) % 23) == 0) c = fleck; // «металлическая» рябь + s.Set(x, y, c); + } + } + return s; +} + +// Рисунок ПОВЕРХ готовой плиты. Отдельно от MakeFloor намеренно: тайлы могут +// приехать из SpriteForge, и варианты обязаны получаться из них тем же кодом — +// иначе половина пола была бы нарисованной, а половина нет. +// +// Пишет только по непрозрачным пикселям, поэтому силуэт ромба не меняется: +// сетка пола остаётся ровно там же, где была. +void AddFloorDetail(Sprite& s, int variant, uint32_t base) +{ + if (variant <= 0) return; // ровная плита + + const uint32_t crack = ShadeColor(base, 0.42f); + const uint32_t deep = ShadeColor(base, 0.30f); + const uint32_t lip = ShadeColor(base, 1.28f); + + auto paint = [&](int x, int y, uint32_t c) { + if (AlphaOf(s.Get(x, y)) != 0) s.Set(x, y, c); + }; + + // Размеры берутся у самой плиты, а не у констант тайла: рисунок обязан + // ложиться на ту плиту, которую выдала проекция, — ромб или квадрат. + const int tw = s.w; + const int th = s.h; + + switch (variant) + { + case 1: + { + // Трещина: ломаная от левого края плиты к правому. Ход задаётся + // хешем от x, а не генератором, — плита обязана быть одинаковой + // в каждом прогоне. + int y = th / 2; + for (int x = 3; x < tw - 3; ++x) + { + const float r = Hash01(Hash2(0xC7A1u, uint32_t(x))); + y += (r < 0.33f) ? -1 : (r > 0.66f ? 1 : 0); + y = std::max(1, std::min(th - 2, y)); + paint(x, y, crack); + if ((x & 3) == 0) paint(x, y + 1, deep); + } + break; + } + + case 2: + { + // Решётка стока: в изометрии слаты идут вдоль оси сетки, поэтому + // шаг по x вдвое больше шага по y — иначе решётка «встаёт» из пола. + // При виде сверху наклонять нечего, слаты горизонтальные. + // + // Контраст НАМЕРЕННО низкий. Пол — фон: на нём читаются кровь, + // трассеры и твари, и решётка, нарисованная в полную силу, спорит + // со всеми тремя. Она должна угадываться, а не бросаться в глаза. + const uint32_t slat = ShadeColor(base, 0.74f); + const uint32_t gap = ShadeColor(base, 0.90f); + const bool slanted = (g_proj.mode == ViewMode::ISO_2_1); + + // Доли, а не пиксели: на плите любого размера решётка занимает ту + // же часть плиты. Для тайла 32x16 числа совпадают с прежними — + // рисунок пола не должен «поехать» от одного лишь переезда кода. + const int step = std::max(2, th / 8); + const int margin = std::max(1, tw * 9 / 32); + for (int i = -2; i <= 2; ++i) + { + const int y0 = th / 2 + i * step; + for (int x = margin; x < tw - margin; ++x) + { + const int y = y0 + (slanted ? (x - tw / 2) / 4 : 0); + paint(x, y, (i & 1) ? gap : slat); + } + } + break; + } + + default: + { + // Труха и щебень: тёмные кляксы плюс редкие светлые крупинки. + for (int y = 0; y < th; ++y) + for (int x = 0; x < tw; ++x) + { + const float r = Hash01(Hash2(uint32_t(x) * 73u + 0x5B3Du, uint32_t(y))); + if (r < 0.10f) paint(x, y, deep); + else if (r < 0.14f) paint(x, y, crack); + else if (r > 0.965f) paint(x, y, lip); + } + break; + } + } +} + +Sprite MakeWall(uint32_t top) +{ + const int tw = g_proj.tileW; + const int th = g_proj.tileH; + + Sprite s; + s.Init(tw, g_proj.wallH, 0); + s.ax = tw / 2; + s.ay = g_proj.WallAnchorY(); // в изометрии низ блока садится на ромб пола + + const uint32_t left = ShadeColor(top, 0.62f); + const uint32_t right = ShadeColor(top, 0.80f); + + // верхняя грань — по форме плиты + for (int y = 0; y < th; ++y) + { + int x0 = 0, x1 = 0; + g_proj.TileRow(y, x0, x1); + for (int x = x0; x < x1; ++x) s.Set(x, y, top); + } + + // Вертикальные грани: от нижней кромки крыши вниз на высоту тайла. При + // виде сверху их нет вообще — блок, показанный сверху И сбоку сразу, это + // две проекции в одном спрайте. + if (g_proj.WallHasSides()) + { + for (int x = 0; x < tw; ++x) + { + int bottom = -1; + for (int y = 0; y < th; ++y) + if (AlphaOf(s.Get(x, y)) != 0) bottom = y; + if (bottom < 0) continue; + + const uint32_t side = (x < tw / 2) ? left : right; + for (int y = bottom + 1; y <= bottom + th && y < g_proj.wallH; ++y) + s.Set(x, y, side); + } + } + + OutlineSilhouette(s, ShadeColor(top, 0.35f)); + return s; +} + +// ----------------------------------------------------------------------------- +// Темы. Множители по каналам, а не отдельные наборы спрайтов: пока тайлсет +// один, тема обязана менять МАТЕРИАЛ, оставляя рисунок — трещины, решётку, +// ранты — на месте. Игрок читает планировку по рисунку, и перерисовывать его +// на каждую тему значило бы учить его читать пол заново в каждой комнате. +// +// Базовая тема (0) — множители 1.0: она и есть то, что нарисовал Generate() +// или привезла SpriteForge, и трогать её перекраской нельзя. +// ----------------------------------------------------------------------------- +struct ThemeTint +{ + const char* name; + float r, g, b; +}; + +const ThemeTint THEMES[THEME_COUNT] = { + {"STEEL", 1.00f, 1.00f, 1.00f}, // сталь: базовый набор как есть + {"RUST", 1.28f, 0.94f, 0.66f}, // ржавчина: тёплая, выцветшая + {"FLESH", 1.18f, 0.70f, 0.74f}, // плоть: розово-багровая + {"FROST", 0.78f, 0.96f, 1.32f}, // наледь: холодная и светлая +}; + +Sprite TintSprite(const Sprite& src, const ThemeTint& t) +{ + Sprite out = src; + for (uint32_t& px : out.px) + { + if (AlphaOf(px) == 0) continue; // силуэт не трогаем + const float r = Clampf(float(px & 0xFFu) * t.r, 0.0f, 255.0f); + const float g = Clampf(float((px >> 8) & 0xFFu) * t.g, 0.0f, 255.0f); + const float b = Clampf(float((px >> 16) & 0xFFu) * t.b, 0.0f, 255.0f); + px = RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(px)); + } + return out; +} + +} // namespace + +const char* ThemeName(uint8_t theme) +{ + return (theme < THEME_COUNT) ? THEMES[theme].name : "?"; +} + +void TileSet::Generate() +{ + // Шахматка x рисунок: два тона плиты, на каждом — все варианты детали. + const uint32_t floorTint[2] = {RGBA(42, 46, 53), RGBA(50, 54, 62)}; + for (int p = 0; p < 2; ++p) + for (int v = 0; v < FLOOR_VARIANTS; ++v) + { + floor[0][p][v] = MakeFloor(floorTint[p]); + AddFloorDetail(floor[0][p][v], v, floorTint[p]); + } + + wall[0][0] = MakeWall(RGBA(78, 84, 96)); + wall[0][1] = wall[0][0]; + + BuildThemes(); +} + +void TileSet::BuildThemes() +{ + for (int t = 1; t < THEME_COUNT; ++t) + { + for (int p = 0; p < 2; ++p) + for (int v = 0; v < FLOOR_VARIANTS; ++v) + floor[t][p][v] = TintSprite(floor[0][p][v], THEMES[t]); + for (int i = 0; i < 2; ++i) wall[t][i] = TintSprite(wall[0][i], THEMES[t]); + } +} + +bool TileSet::LoadFromForge(const SpriteLibrary& lib) +{ + if (!lib.IsOpen()) return false; + + // Библиотека нарисована в изометрии: ромб 32x16 и блок с гранями. Положить + // её в вид сверху нельзя — это не «неточно», это другой ракурс. Отказ здесь + // оставляет процедурный набор целиком, ровно как при любой другой осечке. + if (g_proj.mode != ViewMode::ISO_2_1) return false; + + // Холст тайла канонический — TILE_W x TILE_H с ромбом на своём месте. + // SpriteForge обрезает кадр по силуэту (32x16 приходит как 30x16), а + // ShadeQuad из Lighting::QuadFor считается в координатах спрайта: съехавший + // на пиксель холст увёл бы свет пола относительно света стен. + const FramePlacement floorPlace{TILE_W, TILE_H, TILE_HALF_W, TILE_HALF_H, -1, -1}; + + // У стены pivot кадра — нижняя строка блока, она садится на дно холста. + // Привязка же спрайта остаётся в центре крыши: низ блока ложится на ромб + // пола ровно так же, как у MakeWall. + const FramePlacement wallPlace{TILE_W, WALL_H, TILE_HALF_W, WALL_H - 1, + TILE_HALF_W, TILE_HALF_H + TILE_H}; + + // Явное приведение: sf_asset_id — обычный enum, и MSVC считает его знаковым, + // хотя хеши ассетов доходят до 32 бит (C4245 на SF_ASSET_WALL_BLOCK). + const uint32_t floorIds[FLOOR_VARIANTS] = { + uint32_t(SF_ASSET_FLOOR_CLEAN), uint32_t(SF_ASSET_FLOOR_CRACKED), + uint32_t(SF_ASSET_FLOOR_GRATE), uint32_t(SF_ASSET_FLOOR_BIOMASS), + }; + + Sprite newFloor[2][FLOOR_VARIANTS]; + for (int v = 0; v < FLOOR_VARIANTS; ++v) + { + Sprite tile; + if (!lib.Read(floorIds[v], 0, floorPlace, tile)) return false; + newFloor[0][v] = tile; + newFloor[1][v] = std::move(tile); + } + + Sprite newWall[2]; + if (!lib.Read(uint32_t(SF_ASSET_WALL_INDUSTRIAL), 0, wallPlace, newWall[0])) return false; + if (!lib.Read(uint32_t(SF_ASSET_WALL_INFESTED), 0, wallPlace, newWall[1])) return false; + + // Всё прочитано — только теперь подменяем набор и пересобираем темы. + for (int p = 0; p < 2; ++p) + for (int v = 0; v < FLOOR_VARIANTS; ++v) floor[0][p][v] = std::move(newFloor[p][v]); + wall[0][0] = std::move(newWall[0]); + wall[0][1] = std::move(newWall[1]); + + BuildThemes(); + return true; +} diff --git a/engine/tileset.h b/engine/tileset.h new file mode 100644 index 0000000..6bcd24b --- /dev/null +++ b/engine/tileset.h @@ -0,0 +1,69 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Тайлы пола и стен — по набору на каждую тему отделки. +// +// Живёт в движке, а не в render/sprites.h, ровно по одной причине: этим +// набором рисует и игра, и редактор комнат. Редактор линкуется только с +// движком и про бойцов, тварей и предметы не знает, поэтому тащить ради двух +// спрайтов весь SpriteSet он не может и не должен. +// +// Выбор варианта плиты — чистая функция от координат тайла, без состояния и без +// генератора: пол обязан быть одинаковым в любом прогоне, иначе запечённая +// кровь легла бы после перезапуска на другую плиту (render/blood.h). +// ----------------------------------------------------------------------------- +#include + +#include "engine/hash.h" +#include "engine/theme.h" +#include "engine/framebuffer.h" +#include "engine/config.h" + +class SpriteLibrary; + +// Сколько разных «рисунков» может лежать на плите пола: ровная, треснувшая, +// решётка, засыпанная трухой. Шахматка при этом остаётся — вариант ложится +// ПОВЕРХ неё, а не вместо: без чередования двух тонов изометрическая сетка +// перестаёт читаться, и пол превращается в кашу. +constexpr int FLOOR_VARIANTS = 4; + +struct TileSet +{ + // [тема][чётность шахматки][вариант рисунка]. + Sprite floor[THEME_COUNT][2][FLOOR_VARIANTS]; + Sprite wall[THEME_COUNT][2]; // industrial / biomass-infested, 32x32 + + // Процедурный набор. Не зависит ни от одного файла на диске: игра и + // редактор обязаны запускаться на голом репозитории. + void Generate(); + + // Заменить базовую тему на тайлы из библиотеки SpriteForge и пересобрать + // по ней остальные. Всё или ничего: при любой осечке набор остаётся тем, + // что дал Generate(), и возвращается false. Вызывать только после Generate(). + bool LoadFromForge(const SpriteLibrary& lib); + + const Sprite& Floor(int tx, int ty, uint8_t theme) const + { + // Раскладка намеренно неравномерная: ровная плита занимает больше + // половины карты. Равные доли превращают пол в пёстрый ковёр, на + // котором не видно ни крови, ни тварей. + static const int kPick[8] = {0, 0, 0, 0, 0, 1, 2, 3}; + const uint32_t h = Hash2(uint32_t(tx) * 2654435761u, uint32_t(ty)); + return floor[Clamp(theme)][(tx + ty) & 1][kPick[h & 7u]]; + } + + const Sprite& Wall(int tx, int ty, uint8_t theme) const + { + // Редкие заражённые блоки, детерминированно по координате. + const uint32_t h = Hash2(uint32_t(tx) * 2246822519u, uint32_t(ty)); + return wall[Clamp(theme)][(h & 7u) == 0u ? 1 : 0]; + } + +private: + // Комната может сослаться на тему, которой в этой сборке движка ещё нет. + // Падать из-за этого нельзя: неизвестная тема рисуется базовой. + static int Clamp(uint8_t theme) { return (theme < THEME_COUNT) ? int(theme) : 0; } + + // Темы 1..N-1 — перекраска темы 0. Вызывается и после Generate(), и после + // LoadFromForge(): иначе привезённые тайлы остались бы только в базовой. + void BuildThemes(); +}; diff --git a/src/render/iso.h b/engine/view2d.h similarity index 70% rename from src/render/iso.h rename to engine/view2d.h index 4591360..e78a42c 100644 --- a/src/render/iso.h +++ b/engine/view2d.h @@ -1,35 +1,30 @@ #pragma once // ----------------------------------------------------------------------------- -// Изометрия существует ТОЛЬКО здесь и в рендере. Симуляция, ИИ и коллизии -// работают в декартовом мире (top-down) и об этом файле не знают. +// Слой отрисовки 2D: камера, список отрисовки, сортировка по глубине. // -// sx = (wx - wy) * 16 -// sy = (wx + wy) * 8 +// Раньше файл назывался render/iso.h и считал изометрию прямо здесь. Теперь её +// считает engine/projection.h, а тут остаётся то, что от вида не зависит: +// собрать список, отсортировать, вылить в фреймбуфер. +// +// Мир везде декартов (тайл = 1.0 юнита). Симуляция, ИИ и коллизии об этом файле +// не знают. // ----------------------------------------------------------------------------- #include #include -#include "core/math.h" -#include "render/framebuffer.h" -#include "tuning.h" +#include "engine/config.h" +#include "engine/framebuffer.h" +#include "engine/math.h" +#include "engine/projection.h" -namespace iso +namespace v2d { -inline Vec2 WorldToScreen(Vec2 w) -{ - return {(w.x - w.y) * float(TILE_HALF_W), (w.x + w.y) * float(TILE_HALF_H)}; -} - -inline Vec2 ScreenToWorld(Vec2 s) -{ - const float a = s.x / float(TILE_W); // = (wx - wy) / 2 - const float b = s.y / float(TILE_H); // = (wx + wy) / 2 - return {b + a, b - a}; -} +inline Vec2 WorldToScreen(Vec2 w) { return g_proj.WorldToScreen(w); } +inline Vec2 ScreenToWorld(Vec2 s) { return g_proj.ScreenToWorld(s); } // Ключ сортировки по глубине: чем больше, тем «ближе к камере». -inline float Depth(Vec2 w) { return w.x + w.y; } +inline float Depth(Vec2 w) { return g_proj.Depth(w); } // Направление в ЭКРАННЫХ осях -> направление в мире (WASD, спек 5). inline Vec2 ScreenDirToWorld(Vec2 screenDir) @@ -90,4 +85,4 @@ inline void FlushDrawList(Framebuffer& fb, const std::vector& items) } } -} // namespace iso +} // namespace v2d diff --git a/main.code-workspace b/main.code-workspace index 63784b0..204422b 100644 --- a/main.code-workspace +++ b/main.code-workspace @@ -53,6 +53,10 @@ "typeinfo": "cpp" }, "git.autoRepositoryDetection": false, - "git.enabled": false + "git.enabled": false, + "makefile.configureOnOpen": true, + "workbench.editorAssociations": { + "*.sprite": "d2rmodding.spriteViewer" + } } } \ No newline at end of file diff --git a/samples/walk/main.cpp b/samples/walk/main.cpp new file mode 100644 index 0000000..91bccd1 --- /dev/null +++ b/samples/walk/main.cpp @@ -0,0 +1,157 @@ +// ----------------------------------------------------------------------------- +// Tile2D walk — самое маленькое приложение, которое имеет смысл. +// +// Ни игры, ни редактора: открыть проект, собрать этаж, походить по нему +// камерой. Существует ради одного вопроса — сколько кода нужно, чтобы что-то +// показать на этом движке. Ответ виден целиком на одном экране, и если он +// однажды перестанет туда влезать, это будет значить, что движок оброс тем, +// что должно было остаться в игре. +// +// Проверяет заодно то, что нельзя проверить ни игрой, ни редактором: движок +// собирается и работает БЕЗ них. Игра могла бы незаметно унести к себе что-то +// общее — тайлсет, проекцию, цикл, — и никто бы не заметил, пока не появился +// второй потребитель. Вот он. +// +// tile2d_walk [--project <имя>] [--depth N] [--seed N] [--windowed] [--shot N] +// WASD / стрелки — идти, R — пересобрать этаж, Esc — выход +// ----------------------------------------------------------------------------- +#include + +#include +#include + +#include "engine/app.h" +#include "engine/catalog.h" +#include "engine/level_gen.h" +#include "engine/project.h" +#include "engine/room.h" +#include "engine/tilemap.h" +#include "engine/tilemap_draw.h" +#include "engine/tileset.h" +#include "engine/view2d.h" + +int main(int argc, char** argv) +{ + const char* projectName = nullptr; + int depth = 1; + uint32_t seed = 1; + bool windowed = false; + int shotFrame = -1; // --shot N: снять кадр и выйти (картинки в документацию) + + for (int i = 1; i < argc; ++i) + { + if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) projectName = argv[++i]; + else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc) depth = std::atoi(argv[++i]); + else if (std::strcmp(argv[i], "--seed") == 0 && i + 1 < argc) + seed = uint32_t(std::atoi(argv[++i])); + else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true; + else if (std::strcmp(argv[i], "--shot") == 0 && i + 1 < argc) + shotFrame = std::atoi(argv[++i]); + } + + // --- проект: пути, вид отображения, кривая глубины ------------------------- + Project project; + std::string why; + const std::string dir = FindProjectDir(projectName ? projectName : ""); + if (dir.empty() || !project.Load(dir, &why)) + { + std::printf("проект не открыт: %s\n", dir.empty() ? "не выбран" : why.c_str()); + for (const std::string& n : AvailableProjects()) std::printf(" доступен: %s\n", n.c_str()); + return 1; + } + project.Apply(); // ставит проекцию и кривую на движок + + EntityCatalog catalog; + RoomLibrary rooms; + if (!catalog.Load(project.catalogPath, &why) || !rooms.Load(project.roomsDir, &why)) + { + std::printf("содержимое не читается: %s\n", why.c_str()); + return 1; + } + + // --- этаж ----------------------------------------------------------------- + static Tilemap map; + static GeneratedLevel level; + auto build = [&]() { + if (GenerateLevel(rooms, catalog, DescribeFloor(depth), seed, map, level, &why)) return true; + std::printf("этаж не собрался: %s\n", why.c_str()); + return false; + }; + if (!build()) return 1; + + std::printf("%s: этаж %d (%s), сид %u, комнат %d, тварей %d\n", project.name.c_str(), + level.floor.depth, ThemeName(level.floor.theme), unsigned(seed), level.roomsPlaced, + level.monstersPlaced); + + // --- окно и цикл ---------------------------------------------------------- + App app; + AppConfig cfg; + cfg.title = "Tile2D walk"; + cfg.borderless = !windowed; + if (!app.Init(cfg)) return 1; + + TileSet tiles; + tiles.Generate(); + + v2d::Camera cam; + cam.SnapTo(level.squadStart); + Vec2 pos = level.squadStart; + + while (app.NextFrame()) + { + // Ввод в ЭКРАННЫХ осях переводится в мировые проекцией — тем же + // способом, что и в игре. Без этого WASD в изометрии ведёт вбок. + Vec2 move{}; + if (IsKeyDown(KEY_A) || IsKeyDown(KEY_LEFT)) move.x -= 1.0f; + if (IsKeyDown(KEY_D) || IsKeyDown(KEY_RIGHT)) move.x += 1.0f; + if (IsKeyDown(KEY_W) || IsKeyDown(KEY_UP)) move.y -= 1.0f; + if (IsKeyDown(KEY_S) || IsKeyDown(KEY_DOWN)) move.y += 1.0f; + + const int steps = app.Advance(false); + for (int i = 0; i < steps; ++i) + { + if (LengthSq(move) > 0.0f) + { + const Vec2 step = v2d::ScreenDirToWorld(move); + pos = pos + step * (6.0f * FIXED_DT); + map.ResolveCircle(pos, 0.3f); // сквозь стены не ходим + } + } + + if (IsKeyPressed(KEY_R)) + { + ++seed; + if (build()) { pos = level.squadStart; cam.SnapTo(pos); } + } + if (IsKeyPressed(KEY_ESCAPE)) app.quit = true; + + cam.Follow(pos, app.frameDt, 9.0f); + + int ox = 0, oy = 0; + cam.ScreenOffset(ox, oy); + + app.fb.Clear(RGBA(8, 8, 10)); + DrawTilemap(app.fb, map, tiles, ox, oy); + + // Где стоим, где спуск. Кружком, а не спрайтом: спрайты — дело игры, + // движок про то, как выглядит отряд, не знает ничего. + auto dot = [&](Vec2 w, uint32_t c) { + const Vec2 s = v2d::WorldToScreen(w); + app.fb.CircleFilled(int(s.x) + ox, int(s.y) + oy, 3, c); + }; + dot(level.exitPos, RGBA(60, 200, 90)); + dot(pos, RGBA(240, 230, 200)); + + app.Present(); + app.Finish(); + + if (shotFrame >= 0 && --shotFrame <= 0) + { + app.Screenshot("walk.png"); + break; + } + } + + app.Shutdown(); + return 0; +} diff --git a/src/ai/firing_solver.cpp b/src/ai/firing_solver.cpp index 7dcc410..c1a5143 100644 --- a/src/ai/firing_solver.cpp +++ b/src/ai/firing_solver.cpp @@ -37,6 +37,7 @@ bool ConeClearOfAllies(const Agent& shooter, Vec2 muzzle, int shooterIndex, Vec2 { if (i == shooterIndex) continue; const Agent& ally = ctx.agents[i]; + if (!ally.alive) continue; // упавший больше не перекрывает линию float t = 0.0f; const float d = DistancePointToSegment(ally.pos, muzzle, targetPos, &t); if (d < ally.bodyRadius + half + k * t) return false; @@ -71,6 +72,20 @@ int PredictAllyPositions(const Agent& ally, float t, Vec2 out[2]) } // namespace +// ----------------------------------------------------------------------------- +// Приоритет цели: обстановка идёт последней +// ----------------------------------------------------------------------------- +bool EnemyNearby(Vec2 from, const SolverContext& ctx) +{ + const float r = g_tune.propYieldRadius; + for (const Target& t : *ctx.targets) + { + if (!t.alive || t.IsProp()) continue; + if (Distance(from, t.pos) <= r) return true; + } + return false; +} + // ----------------------------------------------------------------------------- // 6.3 LaneClear // ----------------------------------------------------------------------------- @@ -113,6 +128,7 @@ bool AllyInterceptsShot(Vec2 muzzle, Vec2 targetPos, int shooterIndex, const Sol { if (i == shooterIndex) continue; const Agent& ally = ctx.agents[i]; + if (!ally.alive) continue; const float r = ally.bodyRadius + shooter.gun.def.bulletRadius + g_tune.safetyMargin + g_tune.shotPredictSlack * MaxAgentSpeed() * t + spreadK * speed * t; @@ -135,6 +151,7 @@ int NearestAliveTarget(Vec2 from, const SolverContext& ctx, float searchRadius) for (int i = 0; i < int(targets.size()); ++i) { if (!targets[i].alive) continue; + if (targets[i].IsProp()) continue; // хлам ближайшей целью не бывает const float d = Distance(from, targets[i].pos); if (d < bestD) { @@ -182,23 +199,36 @@ void EvaluateAgentLane(Agent& a, int index, const SolverContext& ctx) } // --- авто: ближайшая цель, до которой линия чиста --- - int best = -1; - float bestD = BIG_F; - for (int i = 0; i < int(targets.size()); ++i) - { - if (!targets[i].alive) continue; - const float d = Distance(a.pos, targets[i].pos); - if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue; - if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue; - best = i; - bestD = d; - } + // Два прохода, и порядок между ними — это и есть приоритет. Сначала твари; + // обстановка рассматривается только когда противника в поле зрения нет + // вообще, иначе боец бросал бы врага ради ящика за его спиной. + auto nearestClear = [&](bool wantProps) { + int best = -1; + float bestD = BIG_F; + for (int i = 0; i < int(targets.size()); ++i) + { + if (!targets[i].alive) continue; + if (targets[i].IsProp() != wantProps) continue; + const float d = Distance(a.pos, targets[i].pos); + if (d >= bestD || d > a.gun.def.range + a.gun.def.muzzleOffset) continue; + if (!LaneClearFrom(a.pos, index, targets[i].pos, ctx)) continue; + best = i; + bestD = d; + } + return best; + }; + + int best = nearestClear(false); + const bool onProp = (best < 0 && !EnemyNearby(a.pos, ctx)); + if (onProp) best = nearestClear(true); if (best >= 0) { a.currentTargetId = best; a.hasLane = true; - a.priorityLane = (ctx.assignedTargetId < 0); + // Линия к хламу приоритетной не бывает: приоритет запрещает решателю + // искать позицию получше, а ради ящика боец переступать обязан. + a.priorityLane = (ctx.assignedTargetId < 0) && !onProp; a.laneEnd = targets[best].pos; return; } @@ -238,6 +268,9 @@ float ScoreCandidate(Vec2 cand, const Agent& a, int index, const SolverContext& for (int i = 0; i < int(targets.size()); ++i) { if (!targets[i].alive) continue; + // Покрытие считается по ПРОТИВНИКАМ. Позиция, с которой видно три + // ящика, огневой не является, а «слишком близко» к бочке — не штраф. + if (targets[i].IsProp()) continue; ++total; nearestDist = std::min(nearestDist, Distance(cand, targets[i].pos)); if (LaneClearFrom(cand, index, targets[i].pos, ctx)) ++shootable; @@ -302,6 +335,7 @@ bool SolveFiringPosition(Agent& a, int index, const SolverContext& ctx, SolveRes for (int k = 0; k < ctx.agentCount; ++k) { if (k == index) continue; + if (!ctx.agents[k].alive) continue; if (Distance(p, ctx.agents[k].pos) < g_tune.candMinAlly) { out.valid[i] = false; diff --git a/src/ai/firing_solver.h b/src/ai/firing_solver.h index f6d92fd..f40390f 100644 --- a/src/ai/firing_solver.h +++ b/src/ai/firing_solver.h @@ -11,7 +11,7 @@ #include "ai/lane_registry.h" #include "sim/agent.h" #include "sim/target.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" struct SolverContext { @@ -25,6 +25,20 @@ struct SolverContext int assignedTargetId = -1; // ручной режим (6.8); -1 = авто }; +// --- приоритет цели ---------------------------------------------------------- +// Есть ли живая ТВАРЬ (не обстановка) в радиусе propYieldRadius от точки. +// +// Правило приоритета разрушаемой обстановки, одно на оба класса оружия: пока +// такая тварь есть, ящик, бочка и шкафчик целью не становятся ни у стрелка +// (firing_solver.cpp), ни у клинка (melee.cpp). Один общий предикат тут не +// украшение — разъехавшись, стрелок и клинок начали бы спорить, что важнее. +// +// Радиус СВОЙ, а не дальность ствола: с дальностью правило читалось бы как +// «не достаю — стреляю по ящику», и подошедшую тварь боец встречал бы на +// перезарядке. Стены не спасают намеренно: тварь за углом в пяти шагах — всё +// ещё повод не заниматься мебелью. +bool EnemyNearby(Vec2 from, const SolverContext& ctx); + // --- 6.3 --------------------------------------------------------------------- // Линия от дула стрелка (в точке from, ствол смотрит на цель) до targetPos. // Порядок проверок как в спеке: свои -> геометрия -> дистанция. diff --git a/src/ai/lane_registry.h b/src/ai/lane_registry.h index a5bf90b..b424231 100644 --- a/src/ai/lane_registry.h +++ b/src/ai/lane_registry.h @@ -8,7 +8,7 @@ // // Модуль ai/ не знает ни про рендер, ни про изометрию. // ----------------------------------------------------------------------------- -#include "core/math.h" +#include "engine/math.h" #include "tuning.h" struct FireLane diff --git a/src/ai/melee.cpp b/src/ai/melee.cpp index a8ee748..e4a6b46 100644 --- a/src/ai/melee.cpp +++ b/src/ai/melee.cpp @@ -64,9 +64,14 @@ void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx) } else { + // Обстановка — только когда вокруг чисто. Предикат общий со стрелком + // (EnemyNearby): ящик за спиной не уводит с боя ни клинок, ни ствол. + const bool wantProps = !EnemyNearby(a.pos, ctx); + float bestCost = BIG_F; for (int i = 0; i < int(targets.size()); ++i) { + if (targets[i].IsProp() != wantProps) continue; if (!MeleeCandidate(a, ctx, i)) continue; // Цена = дистанция + штраф за каждого своего, уже вцепившегося @@ -76,6 +81,7 @@ void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx) for (int k = 0; k < ctx.agentCount; ++k) { if (k == index) continue; + if (!ctx.agents[k].alive) continue; if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i) cost += g_tune.meleeClaimPenalty; } @@ -124,6 +130,7 @@ bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverConte { if (i == index) continue; const Agent& ally = ctx.agents[i]; + if (!ally.alive) continue; // упавший под лезвие не попадает const Vec2 probe[2] = {ally.pos, ally.pos + ally.vel * horizon}; for (const Vec2& q : probe) diff --git a/src/core/input.h b/src/core/input.h index 681483a..510eafd 100644 --- a/src/core/input.h +++ b/src/core/input.h @@ -4,7 +4,7 @@ // в headless-прогонах заполняется скриптом. Отладочные клавиши (F1..F4, тюнинг) // сюда не попадают — они не влияют на симуляцию. // ----------------------------------------------------------------------------- -#include "core/math.h" +#include "engine/math.h" struct InputState { diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 5f85b47..547e533 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -1,6 +1,7 @@ #include "debug/harness.h" #include +#include #include #include @@ -8,7 +9,7 @@ // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). -#include "render/iso.h" +#include "engine/view2d.h" namespace { @@ -58,7 +59,7 @@ constexpr float kWaypointTimeout = 20.0f; // Мировое направление -> экранные оси: ровно то, что делает игрок клавишами. Vec2 MoveInputToward(const Game& g, Vec2 goal) { - return Normalized(iso::WorldToScreen(Normalized(goal - g.squad.anchor))); + return Normalized(v2d::WorldToScreen(Normalized(goal - g.squad.anchor))); } // ----------------------------------------------------------------------------- @@ -601,3 +602,368 @@ int RunAccept() std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL"); return allPass ? 0 : 1; } + +// ----------------------------------------------------------------------------- +// --descend N: забег на N этажей без окна. +// +// Проверяет ровно то, что нельзя проверить ни скриншотом, ни `--check`: +// цепочку «спуск -> пересборка мира -> перенос отряда». Ошибка здесь не +// падает и не рисуется — она выглядит как отряд, приехавший вниз с пустыми +// руками, и заметить её на глаз почти невозможно. +// +// Симуляция не шагает: здесь важна сборка, а не бой. +// ----------------------------------------------------------------------------- +int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors) +{ + // Свой профиль в памяти, не с диска: прогон не имеет права трогать чужой + // схрон, а без профиля не проверить ни эвакуацию, ни чекпоинты. + Profile profile; + profile.ResetLoadouts(); + + Game game; + game.combatMode = true; + game.rooms = &rooms; + game.catalog = &catalog; + game.profile = &profile; + game.levelSeed = seed; + game.Init(); + + std::printf("%-6s %-7s %7s %6s %5s %6s %s\n", "DEPTH", "THEME", "MONSTER", "DANGER", "LOOT", + "EXIT", "SQUAD"); + + int bad = 0; + for (int i = 0; i < floors; ++i) + { + const GeneratedLevel& l = game.level; + + if (!game.levelGenerated) + { + std::printf("[FAIL] этаж %d не собрался\n", game.depth); + return 1; + } + + // Сумки считаются каждый этаж: именно они переезжают вниз, и именно + // их пропажа была бы тихой. Вместе с ними — НАГРУЗКА: без неё не видно, + // связывает вес хоть что-то или он остался числом в каталоге. + const int bags = game.squad.bag.Count(); + float maxLoad = 0.0f; + int overloaded = 0; + for (int a = 0; a < NUM_AGENTS; ++a) + { + const float load = game.squad.agents[a].load; + if (load > maxLoad) maxLoad = load; + if (load > CARRY_LIMIT) ++overloaded; + } + + // Патроны — главный ресурс вылазки, и в прогоне он обязан стоять + // столбцом: «дошли до восьмого» само по себе не говорит, было ли чем + // стрелять на восьмом. + int ammoLeft = 0; + for (int a = 0; a < NUM_AGENTS; ++a) + ammoLeft += std::max(0, game.squad.agents[a].ammoReserve); + + std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f" + " (перегруз %d), патронов %d\n", + l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced, + double(l.dangerSpent), int(l.loot.size()), l.exitDistance, + game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft); + + if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; } + if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0) + { + std::printf(" [FAIL] срезано %d, недостижимо %d\n", l.spawnsDropped, + l.spawnsUnreachable); + ++bad; + } + + // Кладём каждому в сумку по предмету перед спуском: если перенос + // сломан, на следующем этаже сумки окажутся пустыми, и это видно + // в столбце прямо здесь. + // Кладём в ОБЩУЮ сумку по предмету на бойца перед спуском: если + // перенос сломан, на следующем этаже сумка окажется пустой. + for (int a = 0; a < NUM_AGENTS; ++a) + game.squad.bag.Take(ItemId::BANDAGE, game.squad.Carriers(), 0.0f); + + if (i + 1 < floors) game.Descend(); + } + + + // Сколько довезли до низа — считается ДО опытов ниже: они меняют содержимое + // сумки, и посчитав после, проверка «доехало ли нажитое» сравнивала бы + // сумку с собственноручно разложенной сумкой, то есть проходила бы всегда. + const int bagsBefore = game.squad.bag.Count(); + + // ------------------------------------------------------------------------- + // Расходники. Проверяется правило, а не побочные цифры прогона: спуск ни с + // кем не воюет, поэтому патроны сами по себе не кончатся, и «столбец не + // изменился» не значит ничего. + // + // Условия ставятся руками: у бойца пусто в стволе и в запасе, в сумке пачка + // патронов и бинт, здоровье срезано, живых тварей рядом нет. Дальше — шаг + // симуляции, и оба расходника обязаны сработать сами. + // ------------------------------------------------------------------------- + { + Agent& a = game.squad.agents[0]; + Inventory* inv = game.world.Get(a.self); + SquadBag& bag = game.squad.bag; + + // Опыт разбирает сумку под себя, а после него проверяется эвакуация — + // то есть ровно то, что он разобрал. Инструмент обязан вернуть мир + // как было, иначе следующая проверка меряет его следы. + const SquadBag savedBag = bag; + const float savedHp = a.hp; + if (!inv) + { + std::printf("\n [FAIL] у бойца нет инвентаря — расходники не проверить\n"); + ++bad; + } + else + { + // Лечиться можно только вне схватки, поэтому поле зачищается. + for (Target& t : game.targets) t.alive = false; + + bag.Clear(); + bag.slots[0] = ItemId::AMMO; + bag.slots[1] = ItemId::BANDAGE; + // Ствол в сумке обязателен: патроны нужны тому, у кого есть из чего + // стрелять, а в руках у этого бойца клинок. Без него проверка + // мерила бы не правило, а свою же расчищенную сумку. + bag.slots[2] = ItemId::RIFLE; + + a.alive = true; + a.hp = a.maxHp * 0.3f; + a.ammoReserve = 0; + a.gun.ammo = 0; + a.gun.reloadTimer = 0.0f; + + const float hpBefore = a.hp; + for (int k = 0; k < 8; ++k) game.Step(FIXED_DT); + + const bool loaded = a.ammoReserve >= AMMO_BOX_ROUNDS; + const bool healed = a.hp > hpBefore; + const bool spent = bag.slots[0] == ItemId::NONE && bag.slots[1] == ItemId::NONE; + + std::printf("\nрасходники: патроны %s, лечение %s (%.0f -> %.0f), из сумки ушли %s\n", + loaded ? "набиты" : "НЕ набиты", healed ? "сработало" : "НЕ сработало", + double(hpBefore), double(a.hp), spent ? "обе" : "не все"); + + if (!loaded) + { + std::printf(" [FAIL] пачка патронов не пошла в запас — стрелять нечем\n"); + ++bad; + } + if (!healed) + { + std::printf(" [FAIL] бинт не применился: расходники снова мёртвый груз\n"); + ++bad; + } + if (!spent) + { + std::printf(" [FAIL] потраченное осталось в сумке\n"); + ++bad; + } + + // И главное правило: перезарядка тянет ИЗ ЗАПАСА. Прогон спуска ни + // с кем не воюет, поэтому столбец «патронов» в таблице выше стоит + // на месте — и без этой проверки бесконечный боезапас выглядел бы + // ровно так же. + EquipWeapon(game.world, a, ItemId::RIFLE); + game.Step(FIXED_DT); // SyncLoadout переносит ствол на бойца + + const int reserveBefore = a.ammoReserve; + a.gun.ammo = 0; + for (int k = 0; k < 200 && a.ammoReserve == reserveBefore; ++k) game.Step(FIXED_DT); + + const int spentRounds = reserveBefore - a.ammoReserve; + std::printf("перезарядка: запас %d -> %d (магазин %d)\n", reserveBefore, + a.ammoReserve, a.gun.def.magazine); + if (spentRounds <= 0) + { + std::printf(" [FAIL] магазин набился ниоткуда — боезапас бесконечен\n"); + ++bad; + } + + // Мир возвращается как был: дальше проверяется эвакуация, то есть + // ровно то содержимое сумки, которое опыт только что разобрал. + bag = savedBag; + a.hp = savedHp; + } + } + + // ------------------------------------------------------------------------- + // Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз» + // выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами, + // и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой + // цифре — верный способ выставить пороги, которые ничего не ограничивают. + // ------------------------------------------------------------------------- + { + // Один боец несёт ствол и общую сумку целиком: доля на одного — это + // худший случай, тот самый, в котором отряд остался вдвоём или один. + const float gun = ItemDefOf(ItemId::RIFLE).weight; + SquadBag probe; + + // Три находки среднего веса — отряд, который взял нужное и ушёл. + probe.Take(ItemId::SABER, 1, gun); + probe.Take(ItemId::LANTERN, 1, gun); + probe.Take(ItemId::VEST, 1, gun); + const float light = gun + probe.Load(); + + // Добираем до отказа: ячейки кончатся или кончится переноска. + int added = 0; + while (probe.Take(ItemId::PLATE, 1, gun)) ++added; + const float heavy = gun + probe.Load(); + + const bool freeAtLight = CarryMul(light) > 0.999f; + const bool slowAtHeavy = CarryMul(heavy) < 0.95f; + const bool refused = probe.FreeSlot() >= 0; // отказ по ВЕСУ, а не по ячейкам + + std::printf("\nпереноска: налегке %.1f (скорость %.0f%%), с грузом %.1f " + "(скорость %.0f%%), плит влезло %d\n", + double(light), double(CarryMul(light) * 100.0f), double(heavy), + double(CarryMul(heavy) * 100.0f), added); + + if (!freeAtLight) + { + std::printf(" [FAIL] обычное снаряжение уже тормозит отряд\n"); + ++bad; + } + if (!slowAtHeavy) + { + std::printf(" [FAIL] полная сумка не стоит ничего — вес не связывает\n"); + ++bad; + } + if (!refused) + { + std::printf(" [FAIL] предел переноски не сработал: остановили ячейки, не вес\n"); + ++bad; + } + } + + // Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не + // значит: вся суть экстракшена в том, что происходит на выходе. + game.Extract(); + const RunReport& r = game.report; + + std::printf("\nэвакуация: вынесено %d, потеряно %d, выживших %d, чекпоинт D%d\n", + r.itemsBrought, r.itemsLost, r.survivors, r.checkpoint); + + if (r.outcome != RunOutcome::EXTRACTED) + { + std::printf(" [FAIL] исход не эвакуация\n"); + ++bad; + } + // Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому + // проверяем неравенством: меньше содержимого сумок быть не может. + if (r.itemsBrought < bagsBefore) + { + std::printf(" [FAIL] в сумках было %d, до профиля доехало %d\n", bagsBefore, + r.itemsBrought); + ++bad; + } + if (profile.maxDepth < game.depth) + { + std::printf(" [FAIL] глубина %d не записалась в профиль (%d)\n", game.depth, + profile.maxDepth); + ++bad; + } + + // Круговой прогон профиля через ЗАПИСЬ. Здесь он не для полноты: запись + // профиля — единственный путь, которым игра может стереть всё нажитое, и + // ошибка в нём выглядит как «схрон куда-то делся», а не как падение. + { + const std::string tmp = + (std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string(); + + std::string why; + Profile back; + if (!profile.Save(tmp, &why)) + { + std::printf(" [FAIL] профиль не пишется: %s\n", why.c_str()); + ++bad; + } + else if (!back.Load(tmp, &why)) + { + std::printf(" [FAIL] профиль не читается обратно: %s\n", why.c_str()); + ++bad; + } + else + { + int diff = 0; + if (back.maxDepth != profile.maxDepth) ++diff; + if (back.StashCount() != profile.StashCount()) ++diff; + for (int a = 0; a < NUM_AGENTS; ++a) + { + const Inventory& x = profile.loadout[a]; + const Inventory& y = back.loadout[a]; + if (x.weapon != y.weapon || x.armor != y.armor || x.trinket != y.trinket) ++diff; + } + if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; } + else std::printf("профиль: запись и чтение сходятся\n"); + } + + std::error_code ec; + std::filesystem::remove(tmp, ec); + } + + // ------------------------------------------------------------------------- + // Итог забега снимается ДО проверки улучшений: та начинает новую вылазку и + // обнуляет счётчик этажей. Печатать его после значило бы отчитаться + // цифрой, которую испортил измеряющий. + const int floorsDone = game.floorsCleared + 1; + const float xpEarned = game.xp; + + // Опыт тратится. Проверяется цепочка целиком: купили — подействовало на + // + // Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает + // глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в + // одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра + // сломана, а потому что её сломал измеряющий. + // отряд — пережило запись профиля. Число в профиле, которое ни на что не + // влияет, выглядит точно так же, как работающее улучшение. + // ------------------------------------------------------------------------- + { + Profile& pr = *game.profile; + const float hpBefore = game.squad.agents[0].maxHp; + const int ammoBefore = int(g_tune.startAmmo); + + pr.bankedXp = 10000.0f; + const bool boughtAmmo = pr.Buy(Upgrade::AMMO); + const bool boughtVigor = pr.Buy(Upgrade::VIGOR); + const float leftXp = pr.bankedXp; + + // Улучшения раздаются вместе со снаряжением, то есть на новой вылазке. + game.NewRun(1); + game.Step(FIXED_DT); + + const float hpAfter = game.squad.agents[0].maxHp; + const int ammoAfter = game.squad.agents[0].ammoReserve; + + std::printf("\nулучшения: здоровье %.0f -> %.0f, патроны %d -> %d, " + "опыта осталось %.0f\n", + double(hpBefore), double(hpAfter), ammoBefore, ammoAfter, double(leftXp)); + + if (!boughtAmmo || !boughtVigor) + { + std::printf(" [FAIL] покупка не прошла при полном кошельке\n"); + ++bad; + } + if (hpAfter <= hpBefore) + { + std::printf(" [FAIL] живучесть куплена, но здоровье не выросло\n"); + ++bad; + } + if (ammoAfter <= ammoBefore) + { + std::printf(" [FAIL] запас куплен, но патронов не прибавилось\n"); + ++bad; + } + if (leftXp >= 10000.0f) + { + std::printf(" [FAIL] опыт не списался: покупка бесплатная\n"); + ++bad; + } + } + + std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad); + return bad == 0 ? 0 : 1; +} diff --git a/src/debug/harness.h b/src/debug/harness.h index b49a06c..e5ba42f 100644 --- a/src/debug/harness.h +++ b/src/debug/harness.h @@ -8,6 +8,14 @@ // ----------------------------------------------------------------------------- #include "tuning.h" +// --descend N: пройти N этажей подряд без окна и напечатать, во что +// превращается забег. Проверяет ровно то, что нельзя проверить скриншотом: +// цепочку Descend -> пересборка мира -> перенос отряда. Симуляция при этом +// не шагает — здесь важна сборка, а не бой. +#include "engine/level_gen.h" + +int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors); + struct HarnessOptions { float seconds = 60.0f; diff --git a/src/debug/metrics.h b/src/debug/metrics.h index 34109a7..f047494 100644 --- a/src/debug/metrics.h +++ b/src/debug/metrics.h @@ -45,7 +45,10 @@ struct Metrics // достаёт за спину, здесь вырастет десятки градусов. Ровно этим и выглядел // баг «бьёт на 360». float meleeMaxHitExcessDeg = 0.0f; - int reloads = 0; // сколько раз стрелки уходили в перезарядку + int reloads = 0; // сколько раз стрелки уходили в перезарядку + // Сколько расходников потрачено за прогон. Ноль при раненом отряде и полных + // сумках означает, что расходники снова стали мёртвым грузом. + int itemsUsed = 0; FriendlyHitEvent hitLog[HIT_LOG_MAX]; int hitLogCount = 0; diff --git a/src/debug/overlay.cpp b/src/debug/overlay.cpp index abfb341..8d9ad45 100644 --- a/src/debug/overlay.cpp +++ b/src/debug/overlay.cpp @@ -8,21 +8,80 @@ namespace { -// Мир -> координаты окна (кадр уже растянут в WINDOW_SCALE раз). -inline Vector2 ToWindow(const View& v, Vec2 world) +// ----------------------------------------------------------------------------- +// Сетка оверлея. +// +// Кадр 480x270 растянут целым множителем и центрирован в окне, поэтому все +// размеры ниже заданы в пикселях КАДРА и переводятся в окно через Layout. +// Считать напрямую в пикселях окна нельзя: разметка сразу разъезжается +// с картинкой, потому что множитель зависит от монитора. +// ----------------------------------------------------------------------------- +struct Layout { - const Vec2 s = iso::WorldToScreen(world); - return Vector2{(s.x + float(v.scene.ox)) * float(WINDOW_SCALE), - (s.y + float(v.scene.oy)) * float(WINDOW_SCALE)}; + float x = 0.0f, y = 0.0f; // левый верхний угол кадра в окне + float u = 1.0f; // размер пикселя кадра в пикселях окна + int fw = 0, fh = 0; // размер кадра в его собственных пикселях + + Vector2 At(float fx, float fy) const { return Vector2{x + fx * u, y + fy * u}; } + float Px(float frame) const { return frame * u; } +}; + +Layout Of(const ScreenRect& fit) +{ + const int s = (fit.scale > 0) ? fit.scale : 1; + Layout l; + l.x = float(fit.x); + l.y = float(fit.y); + l.u = float(s); + l.fw = fit.w / s; + l.fh = fit.h / s; + return l; } -void Ring(const View& g, Vec2 center, float radiusWorld, Color c, int segments = 48) +// Радиусы и толщины — в пикселях кадра. На апскейле x3 они дают ту же картинку, +// что была у прежних «магических» значений в пикселях окна. +constexpr float R_ANCHOR = 1.5f; // точка якоря +constexpr float R_SLOT = 2.0f; // слот строя +constexpr float R_POST = 1.5f; // выбранная огневая позиция +constexpr float R_CAND = 1.0f; // кандидат решателя +constexpr float R_CAND_ALL = 0.7f; // отбракованный кандидат +constexpr float R_BEST = 3.0f; // выбранный кандидат +constexpr float R_TARGET = 4.0f; // назначенная цель +constexpr float LINE_THIN = 0.35f; +constexpr float LINE_BOLD = 0.7f; + +constexpr int LABEL_SIZE = 4; // подпись над бойцом +constexpr float LABEL_DX = -6.0f; +constexpr float LABEL_DY = -21.0f; + +constexpr float PANEL_X = 3.0f; // текстовая панель в левом верхнем углу +constexpr float PANEL_Y = 3.0f; +constexpr float PANEL_STEP = 7.0f; +constexpr int PANEL_SIZE = 6; + +constexpr int HINT_SIZE = 5; // строка подсказки +constexpr float HINT_BOTTOM = 8.0f; // отступ от нижней кромки КАДРА + +void Text(const Layout& l, const char* s, float fx, float fy, int frameSize, Color c) +{ + DrawText(s, int(l.x + fx * l.u), int(l.y + fy * l.u), int(float(frameSize) * l.u), c); +} + +// Мир -> окно: изометрия даёт пиксели кадра, Layout доводит их до окна. +inline Vector2 ToWindow(const Layout& l, const View& v, Vec2 world) +{ + const Vec2 s = v2d::WorldToScreen(world); + return l.At(s.x + float(v.scene.ox), s.y + float(v.scene.oy)); +} + +void Ring(const Layout& l, const View& g, Vec2 center, float radiusWorld, Color c, + int segments = 48) { Vector2 prev{}; for (int i = 0; i <= segments; ++i) { const float a = float(i) * (TWO_PI_F / float(segments)); - const Vector2 p = ToWindow(g, center + FromAngle(a) * radiusWorld); + const Vector2 p = ToWindow(l, g, center + FromAngle(a) * radiusWorld); if (i > 0) DrawLineV(prev, p, c); prev = p; } @@ -70,30 +129,31 @@ void Overlay::HandleKeys() value = Clampf(value + p.step, p.minV, p.maxV); } -void Overlay::Draw(const Game& game, const View& view) const +void Overlay::Draw(const Game& game, const View& view, const ScreenRect& fit) const { if (!show) return; + const Layout l = Of(fit); const Squad& squad = game.squad; // --- якорь, круги когезии, слоты строя --- - Ring(view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160}); + Ring(l, view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160}); // Свой, больший круг бойцов ближнего боя — их отпускают дальше. if (squad.meleeCount > 0) - Ring(view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110}); + Ring(l, view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110}); // Поводок курсора, обе границы упругой модели: сплошной круг — мягкая // граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел. // По зазору между ними видно натяжение, то есть скоро ли отряд побежит. - Ring(view, squad.Centroid(), g_tune.anchorLeash, + Ring(l, view, squad.Centroid(), g_tune.anchorLeash, squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110}); - Ring(view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax), + Ring(l, view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax), Color{90, 70, 110, 70}); - DrawCircleV(ToWindow(view, squad.anchor), 4.0f, + DrawCircleV(ToWindow(l, view, squad.anchor), l.Px(R_ANCHOR), squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220}); for (int i = 0; i < NUM_AGENTS; ++i) { - const Vector2 p = ToWindow(view, squad.SlotWorld(i)); - DrawCircleLinesV(p, 5.0f, Color{80, 120, 160, 140}); + const Vector2 p = ToWindow(l, view, squad.SlotWorld(i)); + DrawCircleLinesV(p, l.Px(R_SLOT), Color{80, 120, 160, 140}); } // --- сектор ближнего боя --- @@ -116,19 +176,19 @@ void Overlay::Draw(const Game& game, const View& view) const break; } - Ring(view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24); + Ring(l, view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24); const float axis = m.Committed() ? m.dir : a.facing; const float half = m.HalfArc(); for (int s = -1; s <= 1; s += 2) { const Vec2 e = a.pos + FromAngle(axis + half * float(s)) * m.reach; - DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), c); + DrawLineV(ToWindow(l, view, a.pos), ToWindow(l, view, e), c); } if (m.state == MeleeState::STRIKE) { const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach; - DrawLineEx(ToWindow(view, a.pos), ToWindow(view, blade), 2.0f, c); + DrawLineEx(ToWindow(l, view, a.pos), ToWindow(l, view, blade), l.Px(LINE_BOLD), c); } } @@ -146,7 +206,8 @@ void Overlay::Draw(const Game& game, const View& view) const else c = Color{240, 210, 60, 200}; // перекрыта своим const Vec2 end = a.laneEnd; - DrawLineEx(ToWindow(view, a.MuzzleTowards(end)), ToWindow(view, end), 2.0f, c); + DrawLineEx(ToWindow(l, view, a.MuzzleTowards(end)), ToWindow(l, view, end), + l.Px(LINE_BOLD), c); } } @@ -169,18 +230,18 @@ void Overlay::Draw(const Game& game, const View& view) const for (int k = 0; k < r.count; ++k) { - const Vector2 p = ToWindow(view, r.candidates[k]); + const Vector2 p = ToWindow(l, view, r.candidates[k]); if (!r.valid[k]) { - DrawCircleV(p, 2.0f, Color{80, 40, 40, 140}); // отбракован + DrawCircleV(p, l.Px(R_CAND_ALL), Color{80, 40, 40, 140}); // отбракован continue; } const float t = (r.scores[k] - lo) / span; // синий -> белый const unsigned char v = (unsigned char)(60 + 195 * t); - DrawCircleV(p, 3.0f, Color{v, v, 255, 200}); + DrawCircleV(p, l.Px(R_CAND), Color{v, v, 255, 200}); } if (r.bestIndex > 0) - DrawCircleLinesV(ToWindow(view, r.candidates[r.bestIndex]), 8.0f, + DrawCircleLinesV(ToWindow(l, view, r.candidates[r.bestIndex]), l.Px(R_BEST), Color{255, 255, 255, 230}); } } @@ -191,15 +252,16 @@ void Overlay::Draw(const Game& game, const View& view) const for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; - Ring(view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70}); - Ring(view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110}); + Ring(l, view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70}); + Ring(l, view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110}); // Границы конуса взгляда. const float half = g_tune.sightConeDeg * 0.5f * DEG2RAD_F; for (int sgn = -1; sgn <= 1; sgn += 2) { const Vec2 e = a.pos + FromAngle(a.facing + half * float(sgn)) * g_tune.sightRadius; - DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), Color{90, 200, 140, 110}); + DrawLineV(ToWindow(l, view, a.pos), ToWindow(l, view, e), + Color{90, 200, 140, 110}); } } } @@ -208,11 +270,14 @@ void Overlay::Draw(const Game& game, const View& view) const for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; - const Vector2 p = ToWindow(view, a.pos); + const Vec2 s = v2d::WorldToScreen(a.pos); + const float fx = s.x + float(view.scene.ox) + LABEL_DX; + const float fy = s.y + float(view.scene.oy) + LABEL_DY; + if (a.IsMelee()) - DrawText(TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H', - a.currentTargetId, MeleePhase(a.melee.state)), - int(p.x) - 18, int(p.y) - 62, 12, Color{255, 210, 170, 210}); + Text(l, TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H', + a.currentTargetId, MeleePhase(a.melee.state)), + fx, fy, LABEL_SIZE, Color{255, 210, 170, 210}); else { // Патроны и перезарядка — то, чего не видно по силуэту, но от чего @@ -221,20 +286,21 @@ void Overlay::Draw(const Game& game, const View& view) const const Color col = a.gun.Reloading() ? Color{255, 170, 90, 230} : Color{220, 220, 220, 210}; if (a.gun.Reloading()) - DrawText(TextFormat("%d%s %c t%d RELOAD %.1f", i, tag, - a.state == AgentState::ADVANCING ? 'A' : 'H', - a.currentTargetId, double(a.gun.reloadTimer)), - int(p.x) - 18, int(p.y) - 62, 12, col); + Text(l, TextFormat("%d%s %c t%d RELOAD %.1f", i, tag, + a.state == AgentState::ADVANCING ? 'A' : 'H', + a.currentTargetId, double(a.gun.reloadTimer)), + fx, fy, LABEL_SIZE, col); else - DrawText(TextFormat("%d%s %c t%d %d/%d", i, tag, - a.state == AgentState::ADVANCING ? 'A' : 'H', - a.currentTargetId, a.gun.ammo, a.gun.def.magazine), - int(p.x) - 18, int(p.y) - 62, 12, col); + Text(l, TextFormat("%d%s %c t%d %d/%d", i, tag, + a.state == AgentState::ADVANCING ? 'A' : 'H', + a.currentTargetId, a.gun.ammo, a.gun.def.magazine), + fx, fy, LABEL_SIZE, col); } // выбранная огневая позиция if (a.state == AgentState::HOLDING || a.IsMelee()) - DrawCircleLinesV(ToWindow(view, a.postPos), 4.0f, Color{160, 160, 255, 140}); + DrawCircleLinesV(ToWindow(l, view, a.postPos), l.Px(R_POST), + Color{160, 160, 255, 140}); } // --- назначенная цель / вектор прицела (6.8) --- @@ -242,24 +308,26 @@ void Overlay::Draw(const Game& game, const View& view) const { const Vec2 from = squad.anchor; const Vec2 to = from + FromAngle(game.aim.dir) * g_tune.weaponRange; - DrawLineEx(ToWindow(view, from), ToWindow(view, to), 1.0f, Color{255, 255, 255, 90}); + DrawLineEx(ToWindow(l, view, from), ToWindow(l, view, to), l.Px(LINE_THIN), + Color{255, 255, 255, 90}); const float cone = g_tune.aimConeDeg * DEG2RAD_F; for (int s = -1; s <= 1; s += 2) { const Vec2 e = from + FromAngle(game.aim.dir + cone * float(s)) * g_tune.weaponRange; - DrawLineEx(ToWindow(view, from), ToWindow(view, e), 1.0f, Color{255, 255, 255, 50}); + DrawLineEx(ToWindow(l, view, from), ToWindow(l, view, e), l.Px(LINE_THIN), + Color{255, 255, 255, 50}); } if (game.aim.assignedTargetId >= 0) - DrawCircleLinesV(ToWindow(view, game.targets[game.aim.assignedTargetId].pos), 12.0f, - Color{255, 120, 120, 230}); + DrawCircleLinesV(ToWindow(l, view, game.targets[game.aim.assignedTargetId].pos), + l.Px(R_TARGET), Color{255, 120, 120, 230}); } // --- текстовая панель --- - int y = 8; + float ty = PANEL_Y; auto line = [&](const char* text, Color c = RAYWHITE) { - DrawText(text, 8, y, 18, c); - y += 20; + Text(l, text, PANEL_X, ty, PANEL_SIZE, c); + ty += PANEL_STEP; }; line(TextFormat("%s %s aim:%s fps %d", StateName(squad.state), @@ -302,6 +370,6 @@ void Overlay::Draw(const Game& game, const View& view) const double(g_tune.*(p.member))), Color{255, 210, 120, 255}); - DrawText("F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", 8, - WINDOW_H - 24, 16, Color{160, 160, 160, 200}); + Text(l, "F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", + PANEL_X, float(l.fh) - HINT_BOTTOM, HINT_SIZE, Color{160, 160, 160, 200}); } diff --git a/src/debug/overlay.h b/src/debug/overlay.h index 33319f7..de10a18 100644 --- a/src/debug/overlay.h +++ b/src/debug/overlay.h @@ -3,7 +3,14 @@ // Debug-оверлей (спек 7). Рисуется примитивами raylib ПОВЕРХ уже // отмасштабированного кадра — это единственное исключение из правила // «вся сцена софтверная», и оно dev-only (F1 выключает). +// +// Кадр лежит в окне со сдвигом и целым масштабом (ScreenFitRect), поэтому +// оверлей обязан получать ScreenRect и считать ВСЕ координаты через него. +// Рисовать по координатам окна нельзя: на любом мониторе, где кадр не занимает +// окно целиком, разметка уезжает относительно картинки. // ----------------------------------------------------------------------------- +#include "engine/framebuffer.h" + struct Game; struct View; @@ -16,5 +23,5 @@ struct Overlay int tuneIndex = 0; // [ / ] void HandleKeys(); - void Draw(const Game& game, const View& view) const; + void Draw(const Game& game, const View& view, const ScreenRect& fit) const; }; diff --git a/src/game.cpp b/src/game.cpp index ec263ef..8caf3a8 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -2,7 +2,9 @@ // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). -#include "render/iso.h" +#include "engine/view2d.h" +#include "sim/enemy.h" +#include "sim/spawn_catalog.h" namespace { @@ -19,7 +21,8 @@ void ClearBullets(ecs::World& world) Game::Game() : targets(world.Store().data), - bullets(world.Store().data) + bullets(world.Store().data), + floorItems(world.Store().data) { } @@ -31,10 +34,295 @@ void Game::Init() void Game::Reset() { - map.Generate(); + BuildFloor(nullptr); + xp = 0.0f; + floorsCleared = 0; + lootTaken = 0; +} + +void Game::NewRun(int startDepth) +{ + // Новый забег — новый сид. Сид крутится тем же линейным конгруэнтным шагом, + // что и кнопка SEED в редакторе: «следующий» обязано быть одним и тем же + // понятием в обоих приложениях, иначе сид, подобранный в редакторе, в игре + // не повторить. + levelSeed = levelSeed * 1664525u + 1013904223u; + + // Начать глубже открытого чекпоинта нельзя: иначе достигнутая глубина + // перестаёт что-либо значить, а вместе с ней и весь риск. + depth = std::max(1, startDepth); + if (profile) depth = std::min(depth, profile->DeepestCheckpoint()); + + report = RunReport{}; + if (profile) ++profile->runs; + + Reset(); + ApplyProfileLoadout(); +} + +void Game::Extract() +{ + // Унести можно только то, что на ВЫЖИВШИХ. Павший забирает своё с собой — + // в этом вся ставка: не «доживи», а «доживи, имея что вынести». + HarvestToProfile(true); + FinishRun(RunOutcome::EXTRACTED); +} + +void Game::WipeRun() +{ + // Ничего не уносится. Снаряжение сбрасывается к стартовому, но схрон и + // чекпоинты остаются: потерять вылазку — не то же самое, что потерять + // всё нажитое, и второе прекращало бы игру, а не наказывало. + if (profile) profile->ResetLoadouts(); + FinishRun(RunOutcome::WIPED); +} + +void Game::FinishRun(RunOutcome outcome) +{ + report.outcome = outcome; + report.depth = depth; + report.deepest = std::max(report.deepest, depth); + report.survivors = AliveAgents(); + report.xp = xp; + + if (!profile) { report.checkpoint = 1; return; } + + if (outcome == RunOutcome::EXTRACTED) + { + ++profile->extractions; + profile->bankedXp += xp; + } + else + { + ++profile->wipes; + } + + // Рекорд глубины засчитывается ЛЮБЫМ исходом. Дошёл — значит дошёл; + // требовать за чекпоинт ещё и удачную эвакуацию значило бы наказывать + // дважды за одну смерть. + report.newRecord = report.deepest > profile->maxDepth; + profile->maxDepth = std::max(profile->maxDepth, report.deepest); + report.checkpoint = profile->DeepestCheckpoint(); +} + +void Game::HarvestToProfile(bool onlySurvivors) +{ + if (!profile) return; + + report.itemsBrought = 0; + report.itemsLost = 0; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Agent& a = squad.agents[i]; + const Inventory* inv = world.Get(a.self); + if (!inv) continue; + + auto countCarried = [&](const Inventory& v) { + int n = 0; + if (v.weapon != ItemId::NONE) ++n; + if (v.armor != ItemId::NONE) ++n; + if (v.trinket != ItemId::NONE) ++n; + return n; + }; + + if (onlySurvivors && !a.alive) + { + report.itemsLost += countCarried(*inv); + // Слот павшего получает стартовый набор: следующая вылазка идёт + // впятером, и пустые руки одного из них — это не сложность, + // а неиграбельность. + profile->loadout[i] = Inventory{}; + continue; + } + + profile->loadout[i] = *inv; + report.itemsBrought += countCarried(*inv); + } + + // Общая сумка уходит в схрон целиком, если из забега вообще кто-то вышел. + // + // Правило «унести можно только то, что на выживших» этим не ослаблено, оно + // стало точнее: НАДЕТОЕ павшего теряется по-прежнему, а общая ноша на нём + // не висела — её тащил отряд. Вайп по-прежнему не выносит ничего: тогда + // выживших нет вовсе, и сумку некому донести. + const bool anySurvivor = AliveAgents() > 0; + for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) + { + const ItemId id = squad.bag.slots[s]; + if (id == ItemId::NONE) continue; + + if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought; + else ++report.itemsLost; + } + squad.bag.Clear(); + + // Пустой слот павшего добивается стартовым комплектом. Делается через + // ResetLoadouts только для тех, у кого ничего нет: иначе выжившие + // потеряли бы найденное. + Profile fresh; + fresh.ResetLoadouts(); + for (int i = 0; i < NUM_AGENTS; ++i) + if (profile->loadout[i].weapon == ItemId::NONE) profile->loadout[i] = fresh.loadout[i]; +} + +void Game::ApplyProfileLoadout() +{ + if (!profile) return; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + Agent& a = squad.agents[i]; + Inventory* inv = world.Get(a.self); + if (!inv) continue; + + *inv = profile->loadout[i]; + // Ствол в руках живёт ещё и отдельной сущностью-оружием: без этого + // боец выходит с карабином по умолчанию, что бы ни лежало в профиле. + EquipWeapon(world, a, inv->weapon); + + // Запас здоровья зависит от надетого (броня, оберег), и посчитан он + // будет только на первом шаге. Без явной синхронизации боец выходит + // на этаж с прежними ста единицами при полутора сотнях максимума — + // то есть уже раненым, ничего не сделав. + SyncLoadout(world, a); + a.hp = a.maxHp; + } +} + +void Game::Descend() +{ + // Отряд переезжает КАК ЕСТЬ: здоровье, оружие и сумки. В этом весь забег — + // спуск обязан ощущаться как продолжение, а не как новый заход. + SquadCarry carry; + SnapshotSquad(carry); + + ++depth; + ++floorsCleared; + report.deepest = std::max(report.deepest, depth); + BuildFloor(&carry); +} + +// ----------------------------------------------------------------------------- +// Что переезжает между этажами. Отдельная структура, а не «не трогать мир»: +// Reset пересоздаёт весь ecs::World, и по-другому сохранить бойцов нельзя без +// того, чтобы половина систем начала различать «новый мир» и «тот же мир». +// ----------------------------------------------------------------------------- +void Game::SnapshotSquad(SquadCarry& out) const +{ + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Agent& a = squad.agents[i]; + out.alive[i] = a.alive; + out.hp[i] = a.hp; + out.ammo[i] = a.ammoReserve; + if (const Inventory* inv = world.Get(a.self)) out.inv[i] = *inv; + } + out.valid = true; +} + +void Game::RestoreSquad(const SquadCarry& carry) +{ + if (!carry.valid) return; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + Agent& a = squad.agents[i]; + + if (Inventory* inv = world.Get(a.self)) + { + *inv = carry.inv[i]; + // Ствол в руках — часть инвентаря, но живёт ещё и отдельной + // сущностью-оружием. Без этого боец приезжает на новый этаж с + // карабином по умолчанию, что бы он ни нашёл этажом выше. + EquipWeapon(world, a, inv->weapon); + } + + // Павший остаётся павшим: воскрешение на лестнице обесценило бы + // единственную настоящую потерю в забеге. + a.alive = carry.alive[i]; + a.hp = a.alive ? std::max(1.0f, carry.hp[i]) : 0.0f; + a.ammoReserve = carry.ammo[i]; + } +} + +void Game::BuildFloor(const SquadCarry* carry) +{ + // Этаж из комнат — только если набор подключён снаружи. Не собрался + // (пустой набор, битые комнаты) — откатываемся на захардкоженную карту: + // прототип обязан запускаться на голом репозитории. + levelGenerated = false; + levelUnknownSpawns = 0; + atExit = false; + Vec2 start{SQUAD_START_X, SQUAD_START_Y}; + + const FloorSpec spec = DescribeFloor(depth); + + if (rooms && catalog && rooms->Count() > 0 && + GenerateLevel(*rooms, *catalog, spec, levelSeed, map, level)) + { + levelGenerated = true; + start = level.squadStart; + } + else + { + map.Generate(); + level = GeneratedLevel{}; + level.floor = spec; + } + world.Clear(); // бойцы, оружие, мишени, пули — всё разом - squad.Init(world, {SQUAD_START_X, SQUAD_START_Y}); - SpawnTargets(world); + squad.Init(world, start); + if (carry) RestoreSquad(*carry); + + // Собранный этаж населяет СЕБЯ САМ: захардкоженная расстановка мишеней + // и тварей относится к другой карте, и мешать их — верный способ получить + // тварь в стене на пустом месте. + if (levelGenerated) + { + int unknownLoot = 0; + SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns); + SpawnLootFromLevel(world, level.loot, &unknownLoot); + levelUnknownSpawns += unknownLoot; + } + else + { + SpawnTargets(world, combatMode, &map); + } + + // Снаряжение раздаётся только тем, кто пришёл ни с чем: переезжающий отряд + // уже одет тем, что нашёл, и выдавать ему стартовый комплект заново + // означало бы стирать весь смысл находок. + // + // Есть профиль — одеваемся из него: это и есть «в чём вернулись с прошлой + // вылазки». Нет профиля (приёмка, dev-прогоны) — прежний фиксированный + // комплект, чтобы замеры не поехали. + if (!carry) + { + if (profile) ApplyProfileLoadout(); + else if (combatMode) EquipKits(); + + // Улучшения профиля — сюда же: они часть того, с чем отряд выходит на + // вылазку. Нет профиля (приёмка, dev-прогоны) — нет и прибавок. + squad.carryBonus = 0.0f; + squad.sightBonus = 0.0f; + squad.hpBonus = 0.0f; + float ammoBonus = 0.0f; + if (profile) + { + squad.carryBonus = UPGRADE_CARRY_STEP * float(profile->UpgradeLevel(Upgrade::CARRY)); + squad.sightBonus = UPGRADE_SIGHT_STEP * float(profile->UpgradeLevel(Upgrade::SIGHT)); + squad.hpBonus = UPGRADE_VIGOR_STEP * float(profile->UpgradeLevel(Upgrade::VIGOR)); + ammoBonus = UPGRADE_AMMO_STEP * float(profile->UpgradeLevel(Upgrade::AMMO)); + } + + // Запас патронов выдаётся вместе со снаряжением и только в БОЮ. + // В песочнице приёмки его нет вовсе (-1 = бесконечно): критерии меряют + // правила стрельбы, а не то, на сколько минут хватило патронов. + for (int i = 0; i < NUM_AGENTS; ++i) + squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1; + } + lanes.Clear(); metrics.Reset(); events.Clear(); @@ -88,6 +376,15 @@ void Game::PlaceSquad(Vec2 at, bool clumped) a.gun.cooldown = 0.0f; a.gun.recoilDeg = 0.0f; a.gun.shotSeq = 0; + + // Расстановка — это НАЧАЛО сценария. Павший боец, переехавший в него + // трупом, испортил бы и замеры, и игру. + a.alive = true; + a.hp = a.maxHp; + a.hurtTimer = 0.0f; + // Захват — тоже состояние сценария: боец, переехавший в новый сценарий + // в лапах хвата, просто не сдвинулся бы с места. + a.grabbedTimer = 0.0f; } ClearBullets(world); @@ -110,6 +407,68 @@ SolverContext Game::MakeContext() const return ctx; } +// ----------------------------------------------------------------------------- +// Расходники. Одно место на все: бинт, стим и пачка патронов — это «предмет, +// который тратится, когда стало нужно», и разводить их по разным системам +// значило бы трижды написать одно правило. +// +// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон, +// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно: +// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой. +// ----------------------------------------------------------------------------- +void Game::UseConsumables(float dt) +{ + (void)dt; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + Agent& a = squad.agents[i]; + if (!a.alive) continue; + + const Inventory* inv = world.Get(a.self); + if (!inv) continue; + + // Насколько близко ближайшая живая тварь: лечиться при ней нельзя. + float nearest = 1e9f; + for (const Target& t : targets) + { + if (!t.alive || t.IsProp()) continue; + nearest = std::min(nearest, Distance(t.pos, a.pos)); + } + + const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac; + const bool safe = nearest > g_tune.healSafeDist; + + // Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто + // держит его прямо сейчас. Боец с клинком в руках и запасным карабином + // в сумке иначе таскал бы найденную пачку до конца забега мёртвым + // грузом, а это ровно то, от чего расходники и лечились. + bool ownsGun = !a.IsMelee(); + for (int s = 0; s < SQUAD_BAG_SLOTS && !ownsGun; ++s) + if (ItemDefOf(squad.bag.slots[s]).kind == ItemKind::FIREARM) ownsGun = true; + + const bool dry = (a.ammoReserve == 0) && ownsGun; + + for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) + { + const ItemDef d = ItemDefOf(squad.bag.slots[s]); + if (d.kind != ItemKind::CONSUMABLE) continue; + + const bool healsNow = (d.heal > 0.0f) && hurt && safe; + const bool loadsNow = (d.rounds > 0) && dry; + if (!healsNow && !loadsNow) continue; + + if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal); + if (loadsNow) a.ammoReserve += d.rounds; + + squad.bag.slots[s] = ItemId::NONE; + events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{}); + ++metrics.itemsUsed; + break; // не больше одного расходника за шаг: тратить пачку разом незачем + } + } +} + void Game::Step(float dt) { if (in.setFormation >= 0 && in.setFormation < int(Formation::COUNT)) @@ -118,13 +477,32 @@ void Game::Step(float dt) // --- 0. статы снаряжения: с сущностей-оружия на бойцов --------------------- // Одна система в начале шага. Всё остальное дальше читает уже готовые числа // и не лазает в ECS: ai/ про мир сущностей не знает вообще. - for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i]); + // Общая ноша делится поровну на живых. Потерять бойца — значит взвалить + // его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл + // сюда, и это не побочный эффект, а то, ради чего сумка общая. + const float bagShare = squad.bag.Load() / float(squad.Carriers()); + SquadBuffs buffs; + buffs.sight = squad.sightBonus; + buffs.hp = squad.hpBonus; + buffs.carry = squad.carryBonus; + for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs); + for (int i = 0; i < NUM_AGENTS; ++i) + squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt); + + // --- 0.5 расходники --------------------------------------------------------- + // Ручных действий в игре нет (спек 5), поэтому расходники боец применяет + // САМ. Иначе бинт остаётся числом в каталоге и мёртвым грузом в сумке — + // ровно тем, чем он и был до этой вехи. + UseConsumables(dt); UpdateTargets(targets, dt); + // Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует + // на то, что видит сейчас, а не на прошлый кадр. + UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events); // --- 1. якорь и состояние отряда ----------------------------------------- // Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде. - const Vec2 moveWorld = iso::ScreenDirToWorld(in.moveScreen); + const Vec2 moveWorld = v2d::ScreenDirToWorld(in.moveScreen); squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt); // --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) ----- @@ -138,6 +516,7 @@ void Game::Step(float dt) for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; + if (!a.alive) continue; if (a.IsMelee()) EvaluateMeleeTarget(a, i, ctx); else EvaluateAgentLane(a, i, ctx); } @@ -147,6 +526,7 @@ void Game::Step(float dt) for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; + if (!a.alive) continue; // мёртвый ничего не резервирует if (a.IsMelee()) { @@ -179,6 +559,7 @@ void Game::Step(float dt) for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; + if (!a.alive) { lastSolveValid[i] = false; continue; } a.commitTimer = std::max(0.0f, a.commitTimer - dt); if (a.IsMelee()) @@ -211,6 +592,7 @@ void Game::Step(float dt) for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; + if (!a.alive) continue; // На замахе и ударе ось зафиксирована: доворачивать её задним числом — // это стрельба с самонаведением, только клинком. if (a.IsMelee() && a.melee.Committed()) @@ -229,12 +611,71 @@ void Game::Step(float dt) // --- 9. пули --------------------------------------------------------------- UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events); - // --- 10. метрики ----------------------------------------------------------- + // --- 10. находки ----------------------------------------------------------- + // После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге, + // обязан быть подбираемым сразу, а не через кадр. + lootTaken += UpdateLootPickup(world, squad.agents.data(), NUM_AGENTS, squad.bag, + squad.Carriers(), squad.carryBonus, &events, dt); + + // --- 11. опыт за убитых ---------------------------------------------------- + AwardKillXp(); + + // --- 12. спуск ------------------------------------------------------------- + // Симуляция только ОТМЕЧАЕТ, что отряд дошёл. Сам этаж она не меняет: смена + // мира посреди шага, между движением и стрельбой, оставила бы половину + // систем со ссылками на снесённые сущности. + UpdateExit(); + + // --- 13. метрики ----------------------------------------------------------- metrics.Sample(squad, dt); simTime += dt; } +// ----------------------------------------------------------------------------- +// Опыт. Цена убитого — его danger из каталога движка, помноженная на множитель +// этажа: и то и другое живёт в данных, а не здесь. +// +// Обстановка опыта не даёт по определению: ящик — это обстановка, и платить за +// него значило бы сделать разбивание хлама самым выгодным занятием в игре. +// ----------------------------------------------------------------------------- +void Game::AwardKillXp() +{ + const float scale = g_tune.xpPerDanger * level.floor.xpScale; + + for (Target& t : targets) + { + if (t.alive) { t.countedDead = false; continue; } + if (t.countedDead) continue; + t.countedDead = true; + + if (t.IsProp() || t.IsDummy()) continue; + + float danger = 1.0f; + if (catalog) + if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger; + + xp += danger * scale; + } +} + +void Game::UpdateExit() +{ + if (!levelGenerated) { atExit = false; atExtract = false; return; } + + const float r = g_tune.descendRadius; + const bool nearExit = Distance(squad.anchor, level.exitPos) <= r; + const bool nearOut = Distance(squad.anchor, level.extractPos) <= r; + + // События пишутся на ФРОНТЕ: иначе журнал забивался бы одним и тем же + // сообщением каждый кадр, пока отряд стоит на месте. + if (nearExit && !atExit) events.Push(CombatEventKind::DESCEND, -1, level.exitPos, Vec2{}); + if (nearOut && !atExtract) events.Push(CombatEventKind::EXTRACT, -1, level.extractPos, Vec2{}); + + atExit = nearExit; + atExtract = nearOut; +} + void Game::UpdateAim(float dt) { if (in.toggleAimMode) @@ -264,9 +705,9 @@ void Game::UpdateAim(float dt) const float cone = g_tune.aimConeDeg * DEG2RAD_F; // Собираем кандидатов в конусе, отсортированных по дальности от якоря. - int inCone[NUM_TARGETS]; + int inCone[MAX_TARGETS]; int coneCount = 0; - for (int i = 0; i < int(targets.size()); ++i) + for (int i = 0; i < int(targets.size()) && coneCount < MAX_TARGETS; ++i) { if (!targets[i].alive) continue; const Vec2 d = targets[i].pos - squad.anchor; @@ -319,7 +760,7 @@ void Game::FireWeapons(const SolverContext& ctx, float dt) { Agent& a = squad.agents[i]; a.fired = false; - if (a.IsMelee()) continue; + if (!a.alive || a.IsMelee()) continue; Firearm& g = a.gun; g.cooldown = std::max(0.0f, g.cooldown - dt); @@ -333,13 +774,29 @@ void Game::FireWeapons(const SolverContext& ctx, float dt) if (g.reloadTimer <= 0.0f) { g.reloadTimer = 0.0f; - g.ammo = g.def.magazine; + + // Магазин набивается ИЗ ЗАПАСА. Запас отрицательный — песочница + // приёмки, там патроны бесконечны (sim/agent.h). + if (a.ammoReserve < 0) + { + g.ammo = g.def.magazine; + } + else + { + const int take = std::min(g.def.magazine, a.ammoReserve); + g.ammo = take; + a.ammoReserve -= take; + } events.Push(CombatEventKind::RELOAD_DONE, i, a.pos, FromAngle(a.facing)); } continue; } if (g.Empty()) { + // Нечем набивать — перезарядка даже не начинается. Боец с пустым + // стволом не изображает работу: он молчит, и это видно. + if (a.ammoReserve == 0) continue; + g.reloadTimer = g.def.reloadTime; events.Push(CombatEventKind::RELOAD_START, i, a.pos, FromAngle(a.facing)); ++metrics.reloads; @@ -368,11 +825,22 @@ void Game::FireWeapons(const SolverContext& ctx, float dt) if (AllyInterceptsShot(muzzle, tp, i, ctx)) continue; // Отклонение внутри уже проверенного конуса. + // + // Картечь ничего не ослабляет: ВСЕ дробины уходят в тот же конус + // SpreadRad, который только что проверили на своих. Поэтому у дробовика + // конус широкий, и выстрел отсекается чаще, — это его цена, а не + // послабление правила 6.3. const float spread = a.SpreadRad(); const float axis = AngleOf(Normalized(tp - muzzle, FromAngle(a.facing))); - const Vec2 dir = FromAngle(axis + g.ShotOffset(i, spread)); + const float travel = Distance(muzzle, tp); + const int pellets = std::max(1, g.def.pellets); - SpawnBullet(world, g.def, muzzle, dir, Distance(muzzle, tp), i); + Vec2 dir{}; + for (int p = 0; p < pellets; ++p) + { + dir = FromAngle(axis + g.ShotOffset(i, spread, p)); + SpawnBullet(world, g.def, muzzle, dir, travel, i); + } events.Push(CombatEventKind::SHOT, i, muzzle, dir); g.cooldown = g.def.fireInterval; @@ -404,7 +872,7 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; - if (!a.IsMelee()) continue; + if (!a.alive || !a.IsMelee()) continue; MeleeWeapon& m = a.melee; m.cooldown = std::max(0.0f, m.cooldown - dt); @@ -492,25 +960,39 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) } // 2. мишени, по одной за взмах - for (int t = 0; t < int(targets.size()); ++t) + // MAX_TARGETS — это ширина hitMask, а не «столько влезет»: + // за пределами маски удар прошёл бы по одной цели дважды. + for (int t = 0; t < int(targets.size()) && t < MAX_TARGETS; ++t) { Target& tg = targets[t]; if (!tg.alive) continue; - if (m.hitMask & (1u << t)) continue; + if (m.hitMask & (uint64_t(1) << t)) continue; if (!BladeSweepHits(a.pos, ang0, ang1, bladeHalf, m.reach, tg.pos, tg.radius)) continue; if (map.RaycastBlocked(a.pos, tg.pos)) continue; // сквозь стену не рубим - m.hitMask |= (1u << t); + m.hitMask |= (uint64_t(1) << t); a.fired = true; const Vec2 dir = Normalized(tg.pos - a.pos, FromAngle(m.dir)); const Vec2 contact = tg.pos - dir * tg.radius; + const bool prop = tg.IsProp(); tg.hp -= m.curDamage; + tg.hurtTimer = g_tune.targetHurtTime; KnockTarget(tg, dir, m.curKnock); - events.Push(CombatEventKind::MELEE_HIT, i, contact, dir); - ++metrics.meleeHits; + // Щепки вместо крови и мимо счётчика ударов по врагу: + // разрубленный ящик не должен красить пол и накручивать + // MELEE HITS в метриках. + if (prop) + { + events.Push(CombatEventKind::HIT_PROP, i, contact, dir); + } + else + { + events.Push(CombatEventKind::MELEE_HIT, i, contact, dir); + ++metrics.meleeHits; + } // Инвариант сектора: попадание обязано лежать внутри дуги // ЭТОГО взмаха. Замеряется превышение — оно должно быть ~0. @@ -523,9 +1005,11 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) if (tg.hp <= 0.0f) { tg.alive = false; - tg.respawnTimer = g_tune.targetRespawn; - ++metrics.targetsKilled; - events.Push(CombatEventKind::HIT_KILL, i, tg.pos, dir); + tg.respawnTimer = tg.RespawnDelay(); + if (!prop) ++metrics.targetsKilled; + events.Push(prop ? CombatEventKind::PROP_BREAK + : CombatEventKind::HIT_KILL, + i, tg.pos, dir); } } @@ -555,3 +1039,66 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) } } } + +// ----------------------------------------------------------------------------- +// Комплекты снаряжения. +// +// Раздаются ТОЛЬКО в боевом режиме и только здесь. Причина жёсткая: броня несёт +// множитель скорости, а на скорости движения меряются критерии 11.4 и 11.5. +// Отряд приёмки обязан оставаться голым — тогда цифры сравнимы с прежними. +// ----------------------------------------------------------------------------- +void Game::EquipKits() +{ + struct Kit + { + ItemId blade; // если слот держит клинок + ItemId gun; // если слот держит ствол + ItemId armor; + ItemId trinket; + ItemId bag[3]; // запасное оружие и расходники + }; + + // Комплект на СЛОТ СТРОЯ, а не на «персонажа»: остриё клина берёт плиту и + // тесак, хвост — лёгкую броню и дальний ствол. Состав читается по строю. + static const Kit KITS[NUM_AGENTS] = { + {ItemId::CLEAVER, ItemId::SHOTGUN, ItemId::PLATE, ItemId::CHARM, + {ItemId::BANDAGE, ItemId::NONE, ItemId::NONE}}, + {ItemId::KNIFE, ItemId::SMG, ItemId::SHROUD, ItemId::SPURS, + {ItemId::PISTOL, ItemId::BANDAGE, ItemId::NONE}}, + {ItemId::SABER, ItemId::RIFLE, ItemId::VEST, ItemId::LANTERN, + {ItemId::SHOTGUN, ItemId::NONE, ItemId::NONE}}, + {ItemId::SABER, ItemId::SHOTGUN, ItemId::VEST, ItemId::CHARM, + {ItemId::RIFLE, ItemId::STIMPACK, ItemId::NONE}}, + {ItemId::KNIFE, ItemId::MAGNUM, ItemId::JACKET, ItemId::SPURS, + {ItemId::SMG, ItemId::BANDAGE, ItemId::NONE}}, + }; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + Agent& a = squad.agents[i]; + Inventory* inv = world.Get(a.self); + if (!inv) continue; + + const Kit& kit = KITS[i]; + const bool melee = (i < squad.meleeCount); + + *inv = Inventory{}; + inv->weapon = melee ? kit.blade : kit.gun; + inv->armor = kit.armor; + inv->trinket = kit.trinket; + // Запасное уходит в ОБЩУЮ сумку: она одна на отряд. + for (int s = 0; s < 3; ++s) + if (kit.bag[s] != ItemId::NONE) squad.bag.Take(kit.bag[s], NUM_AGENTS, 0.0f); + + EquipWeapon(world, a, inv->weapon); + a.hp = a.maxHp; + } +} + +int Game::AliveAgents() const +{ + int n = 0; + for (int i = 0; i < NUM_AGENTS; ++i) + if (squad.agents[i].alive) ++n; + return n; +} diff --git a/src/game.h b/src/game.h index 0c39d68..c558e4e 100644 --- a/src/game.h +++ b/src/game.h @@ -7,7 +7,7 @@ // Мишени, пули, бойцы и оружие — сущности одного мира ecs::World. targets и // bullets ниже — это ССЫЛКИ на плотные массивы соответствующих пулов, а не // отдельные контейнеры: правила ходят по ним как по обычным векторам и про ECS -// ничего не знают (docs/12-ecs.md). +// ничего не знают (см. заголовок src/ecs/ecs.h). // ----------------------------------------------------------------------------- #include @@ -15,15 +15,26 @@ #include "ai/lane_registry.h" #include "ai/melee.h" #include "core/input.h" -#include "core/math.h" +#include "engine/math.h" #include "debug/metrics.h" #include "ecs/ecs.h" +#include "engine/level_gen.h" +#include "engine/room.h" #include "render/sprites.h" #include "sim/events.h" #include "sim/bullet.h" +#include "sim/loot.h" +#include "sim/profile.h" #include "sim/squad.h" #include "sim/target.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" + +// Движок и игра считают населённость этажа своими числами и не обязаны знать +// про чужое: у движка это MAX_LEVEL_SPAWNS, у игры — ширина маски удара +// клинком MAX_TARGETS. Здесь видны оба — значит, здесь и проверяется, что этаж +// целиком влезает в мир. Иначе он молча приезжал бы обкусанным. +static_assert(MAX_LEVEL_SPAWNS <= MAX_TARGETS, + "движок кладёт на этаж больше сущностей, чем игра способна держать"); // Ручное прицеливание (спек 6.8). struct AimState @@ -33,6 +44,45 @@ struct AimState int assignedTargetId = -1; }; +// Что отряд увозит с собой на следующий этаж. Отдельная структура, а не +// «не трогать мир»: сборка этажа пересоздаёт ecs::World целиком, и по-другому +// сохранить бойцов нельзя без того, чтобы половина систем начала различать +// «новый мир» и «тот же мир». +struct SquadCarry +{ + bool valid = false; + bool alive[NUM_AGENTS] = {}; + float hp[NUM_AGENTS] = {}; + Inventory inv[NUM_AGENTS]; + // Патроны едут вниз вместе со всем остальным. Иначе спуск чинил бы главный + // ресурс вылазки сам собой, и «идти ли ещё глубже» перестало бы быть + // вопросом. + int ammo[NUM_AGENTS] = {}; +}; + +// Чем кончился забег. Отдельная структура, потому что читать её будут ПОСЛЕ +// того, как мир уже пересобран: экран итога показывается на новом главном +// экране, а не поверх старого этажа. +enum class RunOutcome : uint8_t +{ + NONE = 0, // забег идёт + EXTRACTED, // отряд вышел наверх и унёс добычу + WIPED, // отряд полёг, унесено ничего +}; + +struct RunReport +{ + RunOutcome outcome = RunOutcome::NONE; + int depth = 1; // на каком этаже кончилось + int deepest = 1; // до какого дошли за забег + int survivors = 0; + float xp = 0.0f; + int itemsBrought = 0; // сколько предметов легло в схрон + int itemsLost = 0; // сколько осталось на павших и внизу + bool newRecord = false; // побит рекорд глубины + int checkpoint = 1; // самый глубокий открытый чекпоинт ПОСЛЕ забега +}; + struct Game { Tilemap map; @@ -42,8 +92,9 @@ struct Game Squad squad; LaneRegistry lanes; - std::vector& targets; // пул ECS - std::vector& bullets; // пул ECS + std::vector& targets; // пул ECS + std::vector& bullets; // пул ECS + std::vector& floorItems; Metrics metrics; AimState aim; // Единственный канал в сторону эффектов. Правила его не читают. @@ -57,13 +108,80 @@ struct Game bool enableSolver = true; bool enableLaneAvoid = true; + // Игровой режим: вместо мишеней — твари, на бойцах снаряжение. + // Прогоны приёмки и --headless его НЕ включают: там прежние мишени и голый + // отряд, поэтому все критерии меряются на том же, на чём мерялись. + bool combatMode = false; + + // --- забег: этаж, собранный из комнат (engine/level_gen.h) ---------------- + // nullptr — прежняя захардкоженная планировка Tilemap::Generate(). Приёмка + // и --headless набор комнат не подключают НИКОГДА: критерии меряются на той + // же карте, на которой мерялись, иначе цифры не с чем сравнивать. + const RoomLibrary* rooms = nullptr; + const EntityCatalog* catalog = nullptr; + uint32_t levelSeed = 1; + + // Глубина. Первичное понятие всей игры: от неё зависят твари, лут, отделка + // и свет. Что именно она означает — engine/progression.h, здесь только + // номер текущего этажа. + int depth = 1; + + // Что вышло из последней сборки: комнаты, коридоры, население, спуск. + // Читают оверлей, HUD и dev-вывод; правила боя про это не знают. + GeneratedLevel level; + bool levelGenerated = false; + int levelUnknownSpawns = 0; // сколько id этажа игра не опознала + + // --- счёт забега ---------------------------------------------------------- + // Опыт копится за убитых с учётом их опасности и множителя этажа. Тратить + // его пока не на что, и это честная заглушка, а не забытый кусок: тратить + // опыт — значит менять характеристики бойцов, а на них меряются критерии + // приёмки, и такой шаг обязан идти отдельной вехой со своими проверками. + float xp = 0.0f; + int floorsCleared = 0; + int lootTaken = 0; + // Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение + // принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает, + // иначе смена мира случалась бы посреди шага, между движением и стрельбой. + bool atExit = false; + bool atExtract = false; + + // --- экстракшен ----------------------------------------------------------- + // Профиль — состояние ИГРОКА между забегами: снаряжение, схрон, чекпоинты. + // nullptr — забеги не сохраняются (dev-прогоны, приёмка). + Profile* profile = nullptr; + + // Чем кончился последний забег. Читает экран итога; правила боя — нет. + RunReport report; + InputState in{}; float simTime = 0.0f; Game(); void Init(); // одноразовая инициализация (спрайты + Reset) - void Reset(); // R: перегенерировать уровень и расставить отряд + // R: пересобрать ТОТ ЖЕ уровень и расставить отряд. Сид не меняется — + // «переиграть с начала» обязано давать ту же карту, иначе на ней нечему + // научиться, а замеры не с чем сравнить. + void Reset(); + // Новая вылазка с указанного чекпоинта (1 — сверху). Счёт с нуля, другой + // сид, снаряжение берётся из профиля. + void NewRun(int startDepth = 1); + // Спуск на следующий этаж. Отряд переезжает КАК ЕСТЬ — со здоровьем, + // оружием и сумками: в этом весь забег. Павшие не воскресают. + void Descend(); + + // --- конец забега --------------------------------------------------------- + // Эвакуация: ВЫЖИВШИЕ уносят своё, павшие не уносят ничего. Это и есть + // ставка экстракшена — не «доживи», а «доживи, имея что вынести». + void Extract(); + // Отряд полёг. Снаряжение сбрасывается к стартовому, схрон и чекпоинты + // остаются: потерять вылазку — не то же самое, что потерять всё нажитое. + void WipeRun(); + + // Забег кончился сам собой? Проверяется снаружи (main), а не в Step: + // симуляция не должна решать, показывать ли экран. + bool RunOver() const { return report.outcome != RunOutcome::NONE; } // Поставить отряд в заданную точку. clumped = все пятеро в одной куче — // это исходная позиция сценария из критерия приёмки 11.1. @@ -73,7 +191,32 @@ struct Game SolverContext MakeContext() const; + // Раздать комплекты снаряжения по слотам строя. Только для combatMode: + // броня меняет скорость, а на скорости меряются критерии приёмки. + void EquipKits(); + + int AliveAgents() const; + bool SquadWiped() const { return AliveAgents() <= 0; } + private: + // Собрать текущий этаж. carry != nullptr — отряд приехал сверху и его + // надо восстановить после пересоздания мира. + void BuildFloor(const SquadCarry* carry); + void SnapshotSquad(SquadCarry& out) const; + void RestoreSquad(const SquadCarry& carry); + + void AwardKillXp(); // опыт за впервые упавших целей + void UpdateExit(); // дошёл ли отряд до спуска или до эвакуации + + // Собрать снаряжение бойцов в профиль. onlySurvivors — то, что унесли; + // иначе всё разом (нужно между этажами, там ничего не теряется). + void HarvestToProfile(bool onlySurvivors); + // Раздать бойцам снаряжение из профиля. Пустой профиль — стартовый набор. + void ApplyProfileLoadout(); + // Расходники применяются сами: ручных действий в игре нет. + void UseConsumables(float dt); + void FinishRun(RunOutcome outcome); + void UpdateAim(float dt); // Перезарядка, увод ствола, спуск. Разброс выстрела считается здесь же и // ровно тот, который проверил LaneClear. diff --git a/src/main.cpp b/src/main.cpp index 8391a07..69bb26a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,16 @@ // --melee N сколько слотов держат клинок (0 = все стрелки) // --pistol N сколько ПОСЛЕДНИХ слотов носят пистолет // --set имя=значение подкрутить параметр из TUNABLES +// --sf-tiles тайлы из библиотеки SpriteForge + hot reload +// --sprites отчёт по библиотеке SpriteForge и выход +// --windowed не разворачивать окно на весь экран +// --sandbox прежняя песочница: мишени вместо тварей +// --rooms [каталог] бесконечный спуск: этажи из комнат (assets/rooms) +// --seed N сид сборки этажа; без него 1 +// --depth N начать спуск не с первого этажа (для проверок) +// --descend N прогон забега на N этажей без окна +// --profile <файл> где лежит профиль (схрон, чекпоинты) +// --no-profile не читать и не писать профиль // ----------------------------------------------------------------------------- #include @@ -19,17 +29,27 @@ #include #include #include +#include +#include "engine/app.h" #include "debug/harness.h" #include "debug/overlay.h" +#include "engine/project.h" +#include "engine/room.h" #include "game.h" -#include "render/framebuffer.h" +#include "engine/framebuffer.h" +#include "render/hud.h" +#include "engine/sfa_library.h" #include "render/view.h" #include "tuning.h" +#include "ui/menu.h" namespace { +// Свой проект. Игра знает, как её содержимое называется; движок — нет. +constexpr const char* DEFAULT_PROJECT = "descent"; + InputState SampleInput() { InputState in; @@ -68,6 +88,31 @@ void ApplyTuningOverride(const char* arg) std::printf("неизвестный параметр: %s\n", arg); } +// --sprites: что загрузчик реально видит в build/sprites. Проверка стороны C++; +// содержимое самих ассетов смотрят через `sf list` / `sf ascii`, а не отсюда. +int RunSpriteReport() +{ + const std::string dir = FindSpriteLibraryDir(); + SpriteLibrary lib; + if (dir.empty() || !lib.Open(dir)) + { + std::printf("библиотека SpriteForge не найдена; собрать: sf build " + "assets/manifests/environment.yaml --output build/sprites\n"); + return 1; + } + + std::printf("%s: %d ассет(ов)\n", lib.Dir().c_str(), lib.AssetCount()); + std::printf("%-18s %8s %6s %10s %8s\n", "ID", "HASH", "FRAMES", "SIZE", "PIVOT"); + for (const uint32_t id : lib.Ids()) + { + Sprite s; + if (!lib.Read(id, 0, s)) continue; + std::printf("%-18s %08X %6d %5dx%-4d %4d,%-4d\n", lib.NameOf(id), id, lib.FrameCount(id), + s.w, s.h, s.ax, s.ay); + } + return 0; +} + } // namespace int main(int argc, char** argv) @@ -76,6 +121,29 @@ int main(int argc, char** argv) const char* shotFile = "shot.png"; bool accept = false; bool clean = false; // --clean: стартовать без оверлея (для скриншотов) + bool sfTiles = false; // --sf-tiles: пол и стены из библиотеки SpriteForge + bool sfEnemies = false; // --sf-enemies: анимированные твари оттуда же + bool sfAgents = false; // --sf-agents: modular agent paper-dolls + // --sf включает оба разом + bool sfReport = false; // --sprites: отчёт по библиотеке и выход + bool windowed = false; // --windowed: окно вместо безрамочного на весь экран + bool sandbox = false; // --sandbox: мишени вместо тварей, как было раньше + // --ui-shot <экран>: снять кадр не с боя, а с меню/отряда/поражения. + // Нужен ровно для картинок в документации: экраны вне боя иначе не снять, + // потому что --shot никого не нажимает. + const char* uiShot = nullptr; + // --rooms: планировка собирается из комнат вместо захардкоженной карты. + // Приёмки и headless это не касается — они до сюда не доходят. + bool useRooms = false; + // Какой проект открывать. Своё имя игра знает, движок — нет: для него это + // просто строка, которую ему передали. + const char* projectArg = nullptr; + const char* viewOverride = nullptr; + uint32_t levelSeed = 1; + int startDepth = 1; // --depth N: начать спуск не с первого этажа + int descendFloors = 0; // --descend N: прогнать N этажей без окна и выйти + const char* profilePath = nullptr; + bool useProfile = true; // --no-profile: не трогать диск (dev-прогоны) HarnessOptions harness; harness.seconds = 0.0f; @@ -95,87 +163,321 @@ int main(int argc, char** argv) else if (std::strcmp(argv[i], "--pistol") == 0 && i + 1 < argc) harness.pistolCount = TextToInteger(argv[++i]); else if (std::strcmp(argv[i], "--clean") == 0) clean = true; + else if (std::strcmp(argv[i], "--sf-tiles") == 0) sfTiles = true; + else if (std::strcmp(argv[i], "--sf-enemies") == 0) sfEnemies = true; + else if (std::strcmp(argv[i], "--sf-agents") == 0) sfAgents = true; + else if (std::strcmp(argv[i], "--sf") == 0) sfTiles = sfEnemies = sfAgents = true; + else if (std::strcmp(argv[i], "--sprites") == 0) sfReport = true; + else if (std::strcmp(argv[i], "--windowed") == 0) windowed = true; + else if (std::strcmp(argv[i], "--sandbox") == 0) sandbox = true; + else if (std::strcmp(argv[i], "--ui-shot") == 0 && i + 1 < argc) uiShot = argv[++i]; + else if (std::strcmp(argv[i], "--depth") == 0 && i + 1 < argc) + startDepth = TextToInteger(argv[++i]); + else if (std::strcmp(argv[i], "--profile") == 0 && i + 1 < argc) + profilePath = argv[++i]; + else if (std::strcmp(argv[i], "--no-profile") == 0) useProfile = false; + else if (std::strcmp(argv[i], "--descend") == 0 && i + 1 < argc) + { + descendFloors = TextToInteger(argv[++i]); + useRooms = true; // забег без набора комнат бессмыслен + } + else if (std::strcmp(argv[i], "--seed") == 0 && i + 1 < argc) + levelSeed = uint32_t(std::strtoul(argv[++i], nullptr, 10)); + else if (std::strcmp(argv[i], "--project") == 0 && i + 1 < argc) + { + projectArg = argv[++i]; + useRooms = true; // проект открывают, чтобы в него играть + } + else if (std::strcmp(argv[i], "--view") == 0 && i + 1 < argc) + viewOverride = argv[++i]; + else if (std::strcmp(argv[i], "--rooms") == 0) + { + useRooms = true; + // Имя проекта необязательно: без него открывается свой, DESCENT. + if (i + 1 < argc && argv[i + 1][0] != '-') projectArg = argv[++i]; + } else if (std::strcmp(argv[i], "--accept") == 0) accept = true; else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true; else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false; else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false; } + // Проект поднимается ДО окна: прогон забега (--descend) окна не открывает + // вообще, а игре содержимое нужно до первой сборки этажа. + // + // Порядок именно такой: сперва проект — он задаёт вид отображения и кривую + // глубины, — потом по его путям читаются каталог и комнаты. Наоборот + // нельзя: тайлсет рисуется под форму плиты, которую называет проект. + static Project project; + static RoomLibrary rooms; + static EntityCatalog catalog; + bool contentOk = false; + if (useRooms) + { + const std::string dir = FindProjectDir(projectArg ? projectArg : DEFAULT_PROJECT); + std::string why; + + if (dir.empty()) + { + std::printf("проект не найден (искали %s)\n", + projectArg ? projectArg : DEFAULT_PROJECT); + const std::vector avail = AvailableProjects(); + if (!avail.empty()) + { + std::printf(" доступны:"); + for (const std::string& n : avail) std::printf(" %s", n.c_str()); + std::printf("\n"); + } + } + else if (!project.Load(dir, &why)) + { + std::printf("проект не читается: %s\n", why.c_str()); + } + else + { + // Ключ --view перебивает проект: это отладочная ручка, чтобы + // посмотреть чужой проект в другом виде, не правя его манифест. + if (viewOverride) ParseViewMode(viewOverride, project.view); + project.Apply(); + + const bool catOk = catalog.Load(project.catalogPath, &why); + if (!catOk) std::printf("каталог сущностей недоступен (%s)\n", why.c_str()); + + contentOk = catOk && rooms.Load(project.roomsDir, &why); + if (contentOk) + { + std::printf("проект %s (%s, вид %s): комнат %d, каталог %d записей, " + "сид %u, первый этаж %d\n", + project.name.c_str(), project.dir.c_str(), + ViewModeName(project.view), rooms.Count(), catalog.Count(), + unsigned(levelSeed), startDepth); + for (const std::string& f : rooms.failures) + std::printf(" не читается %s\n", f.c_str()); + } + else + { + std::printf("набор комнат недоступен (%s), карта захардкоженная\n", why.c_str()); + } + } + } + + // Профиль — состояние игрока между вылазками. Отсутствие файла не ошибка: + // первый запуск начинается со стартовым снаряжением и пустым схроном. + static Profile profile; + const std::string profFile = profilePath ? std::string(profilePath) : DefaultProfilePath(); + if (useProfile) + { + std::string why; + if (profile.Load(profFile, &why)) + std::printf("профиль: %s, глубина %d, схрон %d, вылазок %d\n", profFile.c_str(), + profile.maxDepth, profile.StashCount(), profile.runs); + else + { + profile.ResetLoadouts(); + std::printf("профиль: новый (%s)\n", why.c_str()); + } + } + + if (sfReport) return RunSpriteReport(); if (accept) return RunAccept(); if (harness.seconds > 0.0f) return RunHeadless(harness); + if (descendFloors > 0) + { + if (!contentOk) { std::printf("прогон забега невозможен без набора комнат\n"); return 1; } + return RunDescend(rooms, catalog, levelSeed, descendFloors); + } - SetConfigFlags(FLAG_VSYNC_HINT); - InitWindow(WINDOW_W, WINDOW_H, "Squad prototype"); - SetExitKey(KEY_ESCAPE); - - Framebuffer fb; - fb.Init(INTERNAL_W, INTERNAL_H); - - ScreenBlitter screen; - screen.Init(INTERNAL_W, INTERNAL_H); + // Окно, кадр и ход времени — движковые (engine/app.h). Esc здесь не + // закрывает игру: он открывает паузу, а выход идёт через меню — закрывать + // игру случайным нажатием посреди вылазки нельзя. + App app; + AppConfig cfg; + cfg.title = "Descent — Tile2D"; + cfg.borderless = !windowed; + if (!app.Init(cfg)) return 1; Game game; + game.combatMode = !sandbox; // прежняя песочница с мишенями — под --sandbox game.enableSolver = harness.solver; game.enableLaneAvoid = harness.laneAvoid; game.squad.meleeCount = harness.meleeCount; // до Init: состав раздаётся там game.squad.pistolCount = harness.pistolCount; + + // Набор подключается ДО Init — первая сборка этажа случается там. + // Профиль тоже: снаряжение бойцов раздаётся при сборке. + if (useProfile) game.profile = &profile; + if (contentOk) + { + game.rooms = &rooms; + game.catalog = &catalog; + game.levelSeed = levelSeed; + game.depth = startDepth; + } + game.Init(); + if (game.levelGenerated) + { + const GeneratedLevel& l = game.level; + std::printf("этаж %d (%s): комнат %d, коридоров %d, тварей %d (долив %d), лута %d\n", + l.floor.depth, ThemeName(l.floor.theme), l.roomsPlaced, l.corridors, + l.monstersPlaced, l.monstersFilled, int(l.loot.size())); + std::printf(" опасность %.1f из %.1f, достижимо тайлов %d, до спуска %d шагов\n", + double(l.dangerSpent), double(l.floor.dangerBudget), l.reachableTiles, + l.exitDistance); + // Всё, из-за чего этаж населён не так, как задумано, печатается + // отдельными строками: молчаливая усушка населения — это не «баланс». + if (l.spawnsDropped > 0) + std::printf(" срезано потолком %d: %d\n", MAX_TARGETS, l.spawnsDropped); + if (l.spawnsUnreachable > 0) + std::printf(" недостижимо от старта: %d\n", l.spawnsUnreachable); + if (game.levelUnknownSpawns > 0) + std::printf(" id нет в игре: %d\n", game.levelUnknownSpawns); + } + + // Библиотека SpriteForge живёт весь запуск: без неё нечего перечитывать + // по сигналу `sf watch`. Не открыта — набор остаётся процедурным. + // + // Открывается она ОДИН раз на оба режима: тайлы и твари берут ассеты из + // одной библиотеки с одной палитрой, и открывать её дважды значило бы + // держать в памяти две копии всех кадров. + static SpriteLibrary forge; + bool forgeOpen = false; + if (sfTiles || sfEnemies || sfAgents) + { + const std::string dir = FindSpriteLibraryDir(); + forgeOpen = !dir.empty() && forge.Open(dir); + if (!forgeOpen) std::printf("библиотека SpriteForge недоступна, спрайты процедурные\n"); + } + if (sfTiles && forgeOpen) + std::printf(game.sprites.LoadTiles(forge) ? "тайлы из SpriteForge: %s\n" + : "тайлы SpriteForge не загрузились (%s)\n", + forge.Dir().c_str()); // View большой (поля света на всю карту + виньетка на кадр) — на куче. static View view; view.Init(); view.Reset(game); + // Анимация тварей: спрайты берёт View, потому что кадр зависит от ВРЕМЕНИ, + // а время — состояние картинки, а не симуляции. + if (sfEnemies && forgeOpen) + { + if (game.sprites.LoadEnemies(forge)) + { + view.forge = &forge; + std::printf("анимированные твари из SpriteForge: %s\n", forge.Dir().c_str()); + } + else + { + std::printf("анимации тварей не загрузились, спрайты процедурные\n"); + } + } + if (sfAgents && forgeOpen) + { + view.forge = &forge; + std::printf("agent paper-dolls from SpriteForge: %s\n", forge.Dir().c_str()); + } + Overlay overlay; if (clean) { overlay.show = false; overlay.showLanes = false; } - double accumulator = 0.0; - while (!WindowShouldClose()) + Ui ui; + // Снимок снимается с БОЯ, а не с меню: --shot нужен для картинок + // в документации, и заставка на них ни при чём. + if (shotFrame >= 0) { ui.screen = Screen::GAME; ui.started = true; } + if (uiShot) { - overlay.HandleKeys(); - if (IsKeyPressed(KEY_R)) { game.Reset(); view.Reset(game); } - if (IsKeyPressed(KEY_F5)) view.horrorEnabled = !view.horrorEnabled; - if (IsKeyPressed(KEY_F6)) view.showSight = !view.showSight; + if (std::strcmp(uiShot, "menu") == 0) { ui.screen = Screen::MENU; ui.started = false; } + else if (std::strcmp(uiShot, "squad") == 0) ui.screen = Screen::SQUAD; + else if (std::strcmp(uiShot, "defeat") == 0) ui.screen = Screen::DEFEAT; + else if (std::strcmp(uiShot, "depth") == 0) ui.screen = Screen::DEPTH; + else if (std::strcmp(uiShot, "upgrade") == 0) ui.screen = Screen::UPGRADE; + else if (std::strcmp(uiShot, "summary") == 0) ui.screen = Screen::SUMMARY; + else if (std::strcmp(uiShot, "pause") == 0) ui.screen = Screen::PAUSE; + } - game.in = SampleInput(); + while (app.NextFrame()) + { + const float frameDt = app.frameDt; - const float frameDt = GetFrameTime(); - accumulator += double(frameDt); + // Меню съедает ввод целиком: пока оно открыто, отряд не шевелится. + // Съёмка экрана отряда: курсор ставится на слот брони, иначе подсказку + // под курсором не снять — она появляется только по наведению. Ровно та + // же причина, по которой у редактора есть --select. + if (uiShot && std::strcmp(uiShot, "squad") == 0 && shotFrame > 0) + SetMousePosition(app.fit.x + 60 * app.fit.scale, app.fit.y + 122 * app.fit.scale); + + const bool halted = ui.Update(game, app.fit, frameDt); + if (ui.worldReset) + { + ui.worldReset = false; + view.Reset(game); // кровь и туман прошлой вылазки в новую не едут + } + + if (!halted) + { + overlay.HandleKeys(); + // Спуск — РЕШЕНИЕ игрока, а не то, что случается от захода на клетку. + // Отряд может стоять на спуске сколько угодно: этаж не зачищен, + // лут не собран, и уходить рано — это нормальный выбор. + if (game.atExit && IsKeyPressed(KEY_F)) + { + game.Descend(); + view.Reset(game); // кровь и туман прошлого этажа вниз не едут + } + // Эвакуация заканчивает забег. Мир при этом не пересобирается: + // экран итога показывается поверх последнего кадра, а следующая + // вылазка соберёт свой этаж сама. + if (game.atExtract && IsKeyPressed(KEY_E) && !game.RunOver()) + { + game.Extract(); + if (useProfile) profile.Save(profFile); + } + if (IsKeyPressed(KEY_R)) { game.Reset(); view.Reset(game); } + if (IsKeyPressed(KEY_F5)) view.horrorEnabled = !view.horrorEnabled; + if (IsKeyPressed(KEY_F6)) view.showSight = !view.showSight; + } + + // `sf watch` пересобрал библиотеку — забрать тайлы не перезапуская игру. + // Кровь при этом сбрасывается по той же причине, что и при смене уровня: + // она запечена в спрайты пола, а пол под ней уже другой. + if (forge.PollReload() && game.sprites.LoadTiles(forge)) view.blood.Reset(); + + game.in = halted ? InputState{} : SampleInput(); // --- fixed timestep 60 Гц, рендер с интерполяцией --------------------- - int steps = 0; + // Сколько шагов прошло, считает движок; ЧТО такое шаг — по-прежнему + // дело игры. Пауза время не копит: иначе после меню симуляция догоняла + // бы пропущенные секунды одним рывком. const auto t0 = std::chrono::steady_clock::now(); - while (accumulator >= FIXED_DT && steps < MAX_STEPS_PER_FRAME) - { - game.Step(FIXED_DT); - accumulator -= FIXED_DT; - ++steps; - } + const int steps = app.Advance(halted); + for (int i = 0; i < steps; ++i) game.Step(FIXED_DT); game.metrics.simMs = float( std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); - if (accumulator > FIXED_DT * MAX_STEPS_PER_FRAME) accumulator = 0.0; - const float alpha = float(accumulator / FIXED_DT); + const float alpha = app.Alpha(); + // Мир рисуется ВСЕГДА, даже под меню: чёрный экран за паузой убивает + // всю атмосферу, ради которой этот кадр и собирается. const auto tr0 = std::chrono::steady_clock::now(); - RenderGame(game, view, fb, alpha, frameDt); + RenderGame(game, view, app.fb, alpha, halted ? 0.0f : frameDt); + if (ui.screen == Screen::GAME) DrawHud(app.fb, game); game.metrics.renderMs = float( std::chrono::duration(std::chrono::steady_clock::now() - tr0).count()); - BeginDrawing(); - ClearBackground(BLACK); - screen.Present(fb, WINDOW_W, WINDOW_H); - overlay.Draw(game, view); - EndDrawing(); + app.Present(); + if (ui.screen == Screen::GAME) overlay.Draw(game, view, app.fit); + ui.Draw(game, app.fit); + app.Finish(); if (shotFrame >= 0 && --shotFrame <= 0) { - TakeScreenshot(shotFile); + app.Screenshot(shotFile); break; } + if (ui.quit) app.quit = true; // «выход» в меню } - screen.Shutdown(); - CloseWindow(); + app.Shutdown(); return 0; } diff --git a/src/render/anim.h b/src/render/anim.h new file mode 100644 index 0000000..8b1e56d --- /dev/null +++ b/src/render/anim.h @@ -0,0 +1,94 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Проигрыватель анимаций: состояние на ОДНУ сущность и больше ничего. +// +// Здесь нет ни кадров, ни спрайтов, ни ассетов — только «какой клип идёт и +// сколько он уже идёт». Пиксели по этому состоянию достаёт SpriteLibrary::Frame, +// поэтому кадры анимации лежат в памяти в одном экземпляре на ассет, а не по +// копии на каждого бойца. +// +// Заголовок сознательно header-only и ни от чего не зависит: тварям, бойцам и +// эффектам нужен один и тот же счётчик времени. +// ----------------------------------------------------------------------------- +#include +#include +#include + +// Клипы, которые игра умеет спрашивать. Имена в манифестах SpriteForge — +// строчными: idle, walk, attack, hurt, die. +enum class AnimKind : uint8_t { IDLE = 0, WALK, ATTACK, HURT, DIE, COUNT }; + +// FNV-1a, 32 бита, по байтам UTF-8 — ровно то, что считает SpriteForge +// (fnv1a32 в format.py) и кладёт в SF_ANIM_* через codegen. Совпадение обязано +// быть побитовым: разойдись хоть один бит — анимация не найдётся, и сущность +// молча замрёт в первом кадре, без единой ошибки в логе. +constexpr uint32_t AnimFnv1a(const char* text) +{ + uint32_t hash = 0x811C9DC5u; + for (const char* p = text; *p != '\0'; ++p) + hash = (hash ^ uint32_t(uint8_t(*p))) * 0x01000193u; + return hash; +} + +// Порядок строк — порядок AnimKind, иначе хеши поедут молча. +inline constexpr const char* ANIM_NAMES[] = {"idle", "walk", "attack", "hurt", "die"}; +static_assert(sizeof(ANIM_NAMES) / sizeof(ANIM_NAMES[0]) == size_t(AnimKind::COUNT), + "ANIM_NAMES обязан покрывать все значения AnimKind"); + +// Хеш имени клипа. Константа времени компиляции: в кадре игры не считается. +constexpr uint32_t AnimNameHash(AnimKind kind) +{ + return AnimFnv1a(ANIM_NAMES[(kind < AnimKind::COUNT) ? int(kind) : 0]); +} + +struct Animator +{ + AnimKind kind = AnimKind::IDLE; + float time = 0.0f; // секунд от начала клипа + bool loop = true; + bool finished = false; // только для незацикленных: клип дошёл до конца + + // Повторный Play той же анимации НЕ сбрасывает время. Логика поведения + // просит анимацию каждый кадр («иду» — значит walk), и сброс намертво + // приморозил бы её к первому кадру. + void Play(AnimKind k, bool looping) + { + if (k == kind) + { + loop = looping; + return; + } + kind = k; + time = 0.0f; + loop = looping; + finished = false; + } + + // Начать текущий клип заново — для повтора того же действия подряд + // (второй удар, второй выстрел): Play такое не отличит. + void Restart() + { + time = 0.0f; + finished = false; + } + + // length — длина клипа в секундах, из SpriteLibrary::AnimationLength. + // 0 значит «клипа нет»: время не двигаем, иначе оно бесконечно растёт + // на ассете без анимаций и первое же появление клипа даст рывок. + void Advance(float dt, float length) + { + if (!(length > 0.0f)) return; + + time += dt; + if (loop) + { + time = std::fmod(time, length); + if (time < 0.0f) time += length; // отмотка назад — тоже по кругу + } + else if (time >= length) + { + time = length; // незацикленный клип замирает на последнем кадре + finished = true; + } + } +}; diff --git a/src/render/atmos.cpp b/src/render/atmos.cpp index 79034f9..22c94d8 100644 --- a/src/render/atmos.cpp +++ b/src/render/atmos.cpp @@ -1,6 +1,6 @@ #include "render/atmos.h" -#include "render/iso.h" +#include "engine/view2d.h" #include "render/noise.h" namespace @@ -64,7 +64,7 @@ void Atmosphere::DrawAsh(Framebuffer& fb, Vec2 camPos, int ox, int oy, const Lig const float bob = std::sin(t * TWO_PI_F * 1.31f + ph) * 2.5f; const int lift = int(g_tune.ashLiftPx * liftFrac + bob); - const Vec2 s = iso::WorldToScreen(p); + const Vec2 s = v2d::WorldToScreen(p); const int sx = int(std::lround(s.x)) + ox; const int sy = int(std::lround(s.y)) + oy - lift; if (unsigned(sx) >= unsigned(fb.w) || unsigned(sy) >= unsigned(fb.h)) continue; diff --git a/src/render/atmos.h b/src/render/atmos.h index 62bc3ad..069dab0 100644 --- a/src/render/atmos.h +++ b/src/render/atmos.h @@ -15,8 +15,8 @@ // «частиц» нет как объектов, аллокаций ноль, и число пылинок можно менять // ручкой в оверлее прямо на ходу. // ----------------------------------------------------------------------------- -#include "core/math.h" -#include "render/framebuffer.h" +#include "engine/math.h" +#include "engine/framebuffer.h" #include "render/lighting.h" struct Atmosphere diff --git a/src/render/blood.cpp b/src/render/blood.cpp index f45ac11..755a469 100644 --- a/src/render/blood.cpp +++ b/src/render/blood.cpp @@ -1,6 +1,6 @@ #include "render/blood.h" -#include "render/iso.h" +#include "engine/view2d.h" #include "render/noise.h" namespace @@ -168,7 +168,7 @@ void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, // запачканные продолжают собирать кровь. if (int(stained.size()) >= BLOOD_TILE_BUDGET) continue; slot = int32_t(stained.size()); - stained.push_back(sprites.FloorTile(tx, ty)); + stained.push_back(sprites.FloorTile(tx, ty, map.ThemeAt(tx, ty))); slotOf[key] = slot; } @@ -184,7 +184,7 @@ void BloodField::Splat(const SpriteSet& sprites, const Tilemap& map, Vec2 world, // Пиксель ромба -> точка мира. Проекция линейна, поэтому // обратный переход точен и швов между тайлами не будет. - const Vec2 p = center + iso::ScreenToWorld( + const Vec2 p = center + v2d::ScreenToWorld( Vec2{float(px - s.ax), float(py - s.ay)}); float cover = 0.0f; diff --git a/src/render/blood.h b/src/render/blood.h index 49eed46..75bf4ff 100644 --- a/src/render/blood.h +++ b/src/render/blood.h @@ -23,11 +23,11 @@ #include #include -#include "core/math.h" -#include "render/framebuffer.h" +#include "engine/math.h" +#include "engine/framebuffer.h" #include "render/sprites.h" #include "sim/events.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" struct BloodField { diff --git a/src/render/fx.cpp b/src/render/fx.cpp index cf1ff4b..b64e6ea 100644 --- a/src/render/fx.cpp +++ b/src/render/fx.cpp @@ -21,10 +21,14 @@ constexpr uint32_t COL_BLADE_TAIL = FXC(120, 165, 235); constexpr uint32_t COL_SPARK_MELEE = FXC(255, 165, 95); // Оборванный взмах — тусклая серая вспышка: свой влез под лезвие. constexpr uint32_t COL_SPARK_ABORT = FXC(150, 150, 170); +// Обстановка: пыльная щепа, а не кровь. Тусклее любого попадания по телу — +// игрок обязан по одному цвету искры понимать, во что он стреляет. +constexpr uint32_t COL_SPARK_SPLINTER = FXC(170, 130, 80); +constexpr uint32_t COL_SPARK_DEBRIS = FXC(200, 160, 105); inline Vec2 ToScreen(Vec2 world, int ox, int oy, int liftPx) { - const Vec2 s = iso::WorldToScreen(world); + const Vec2 s = v2d::WorldToScreen(world); return Vec2{s.x + float(ox), s.y + float(oy) - float(liftPx)}; } @@ -110,6 +114,9 @@ void FxSystem::Ingest(const CombatEventLog& log, const Agent* agents, int agentC // Удар клинком — самое громкое событие в кадре: он всегда в упор. case CombatEventKind::MELEE_HIT: col = COL_SPARK_MELEE; power = 2.2f; break; case CombatEventKind::MELEE_ABORT: col = COL_SPARK_ABORT; power = 0.7f; break; + // Обстановка: щепки на попадание, облако трухи на развал. + case CombatEventKind::HIT_PROP: col = COL_SPARK_SPLINTER; power = 0.6f; break; + case CombatEventKind::PROP_BREAK: col = COL_SPARK_DEBRIS; power = 1.6f; break; default: continue; } if (sparkCount < FX_SPARK_MAX) diff --git a/src/render/fx.h b/src/render/fx.h index 8d2a40b..4d449e0 100644 --- a/src/render/fx.h +++ b/src/render/fx.h @@ -10,9 +10,9 @@ // * короткая вспышка у дула в момент выстрела; // * искра в точке попадания, разная для стены и для цели. // ----------------------------------------------------------------------------- -#include "core/math.h" -#include "render/framebuffer.h" -#include "render/iso.h" +#include "engine/math.h" +#include "engine/framebuffer.h" +#include "engine/view2d.h" #include "sim/agent.h" #include "sim/bullet.h" #include "sim/events.h" diff --git a/src/render/hud.cpp b/src/render/hud.cpp new file mode 100644 index 0000000..91ce060 --- /dev/null +++ b/src/render/hud.cpp @@ -0,0 +1,225 @@ +#include "render/hud.h" + +#include + +#include "engine/microfont.h" +#include "game.h" +#include "render/sprites.h" + +namespace +{ + +constexpr int CARD_W = 92; +constexpr int CARD_H = 22; +constexpr int CARD_GAP = 2; +constexpr int BAR_W = 52; + +const uint32_t COL_FRAME = RGBA(24, 26, 32); +const uint32_t COL_EMPTY = RGBA(44, 48, 56); +const uint32_t COL_DEAD = RGBA(70, 26, 26); + +// Рамка карточки: тёмная подложка, чтобы HUD читался поверх любого пола. +void Panel(Framebuffer& fb, int x, int y, int w, int h, uint32_t fill) +{ + fb.FillRect(x, y, w, h, fill); + fb.Line(x, y, x + w - 1, y, COL_FRAME); + fb.Line(x, y + h - 1, x + w - 1, y + h - 1, COL_FRAME); + fb.Line(x, y, x, y + h - 1, COL_FRAME); + fb.Line(x + w - 1, y, x + w - 1, y + h - 1, COL_FRAME); +} + +// Патроны: по квадратику на выстрел, пока их не больше BAR_W/2. Дальше — +// обычная полоса: сорок квадратиков автомата разглядывать всё равно никто +// не будет, а «сколько осталось» видно и так. +// Запас патронов СВЕРХ магазина — тонкая полоса под патронами. +// +// Это главный ресурс вылазки, и он обязан таять на глазах: игрок решает «ещё +// этаж или наверх» именно по нему. Пустой запас — красная риска на месте +// полосы: не «полоса нулевой длины», которую не отличить от её отсутствия. +void DrawReserve(Framebuffer& fb, int x, int y, const Agent& a) +{ + if (a.ammoReserve < 0) return; // песочница: патроны бесконечны + + const float k = Clampf(float(a.ammoReserve) / std::max(1.0f, g_tune.startAmmo), 0.0f, 1.0f); + fb.FillRect(x, y, BAR_W, 1, COL_EMPTY); + if (a.ammoReserve > 0) + fb.FillRect(x, y, std::max(1, int(float(BAR_W) * k)), 1, + (k < 0.2f) ? RGBA(220, 120, 60) : RGBA(150, 140, 110)); + else + fb.FillRect(x + BAR_W / 2 - 2, y, 4, 1, RGBA(220, 70, 60)); +} + +void DrawAmmo(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col) +{ + const int mag = a.gun.def.magazine; + + if (a.gun.Reloading()) + { + // Перезарядка: полоса набирается слева направо. Это ЕДИНСТВЕННЫЙ момент, + // когда стрелок беспомощен, и его надо видеть без чтения цифр. + const float k = 1.0f - Clampf(a.gun.reloadTimer / std::max(0.01f, a.gun.def.reloadTime), + 0.0f, 1.0f); + fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); + fb.FillRect(x, y, int(float(BAR_W) * k), 3, RGBA(210, 150, 60)); + return; + } + + if (mag > 0 && mag <= BAR_W / 2) + { + const int step = BAR_W / mag; + for (int i = 0; i < mag; ++i) + fb.FillRect(x + i * step, y, std::max(1, step - 1), 3, + (i < a.gun.ammo) ? col : COL_EMPTY); + return; + } + + fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); + fb.FillRect(x, y, int(float(BAR_W) * a.gun.AmmoFrac()), 3, col); +} + +// Клинок: фаза удара. Замах, удар и восстановление — три разных цвета, потому +// что это три разных состояния бойца, а не одна «атака». +void DrawMeleeState(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col) +{ + uint32_t c = COL_EMPTY; + float k = 1.0f; + + switch (a.melee.state) + { + case MeleeState::WINDUP: c = RGBA(220, 180, 70); k = a.melee.Progress(); break; + case MeleeState::STRIKE: c = RGBA(250, 240, 220); k = 1.0f; break; + case MeleeState::RECOVER: c = RGBA(150, 70, 70); k = 1.0f - a.melee.Progress(); break; + default: + // Готов бить — полоса цвета бойца; иначе видно, сколько ждать. + c = (a.melee.cooldown <= 0.0f) ? col : COL_EMPTY; + k = (a.melee.cooldown <= 0.0f) + ? 1.0f + : 1.0f - Clampf(a.melee.cooldown / std::max(0.01f, a.melee.interval), + 0.0f, 1.0f); + break; + } + + fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); + fb.FillRect(x, y, int(float(BAR_W) * Clampf(k, 0.0f, 1.0f)), 3, c); +} + +// ----------------------------------------------------------------------------- +// Строка забега: этаж, отделка, опыт. Единственные БУКВЫ в HUD, и появились +// они по одной причине: номер этажа — это число, и показать его формой нельзя +// (engine/microfont.h). Всё остальное в HUD по-прежнему без единой буквы. +// ----------------------------------------------------------------------------- +void DrawRunBar(Framebuffer& fb, const Game& game) +{ + if (!game.levelGenerated) return; // на захардкоженной карте этажа нет + + const uint32_t COL_TEXT = RGBA(206, 200, 190); + const uint32_t COL_SHADOW = RGBA(6, 6, 8); + const uint32_t COL_READY = RGBA(120, 226, 150); // вниз, глубже + const uint32_t COL_OUT = RGBA(130, 200, 240); // наверх, с добычей + + char buf[48]; + FloorLabel(game.level.floor, buf, sizeof(buf)); + MicroTextShadow(fb, 5, 5, buf, COL_TEXT, COL_SHADOW); + + // Добыча — вторая по важности цифра забега после этажа: именно она стоит + // на кону, и знать её нужно ДО того, как решаешь идти глубже. + // Сумка одна на отряд, поэтому и цифра одна: пересчитывать по бойцам + // больше нечего. + const int carried = game.squad.bag.Count(); + std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried); + MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW); + + // Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка + // «нажми F» висела бы на экране весь забег и перестала бы читаться. + // + // Разные цвета — те же, что у колец на полу: подсказка обязана читаться + // как подпись к тому, на чём стоишь, а не как отдельное сообщение. + const char* hint = nullptr; + uint32_t hintCol = COL_READY; + if (game.atExtract) { hint = "EXTRACT: E"; hintCol = COL_OUT; } + else if (game.atExit) { hint = "DESCEND: F"; hintCol = COL_READY; } + if (!hint) return; + + MicroTextShadow(fb, (fb.w - MicroTextWidth(hint)) / 2, 14, hint, hintCol, COL_SHADOW); +} + +} // namespace + +void DrawHud(Framebuffer& fb, const Game& game) +{ + DrawRunBar(fb, game); + + const int total = NUM_AGENTS * CARD_W + (NUM_AGENTS - 1) * CARD_GAP; + const int x0 = (fb.w - total) / 2; + const int y0 = fb.h - CARD_H - 4; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Agent& a = game.squad.agents[i]; + const int x = x0 + i * (CARD_W + CARD_GAP); + const uint32_t col = SlotColor(i); + + // Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда, + // а красная карточка в углу глаза видна. + uint32_t back = RGBA(12, 13, 16); + if (!a.alive) back = COL_DEAD; + else if (a.hurtTimer > 0.0f) + back = ShadeColor(RGBA(120, 30, 30), 0.4f + a.hurtTimer); + Panel(fb, x, y0, CARD_W, CARD_H, back); + + // Метка слота: тот же цвет, что и у тела на карте. + fb.FillRect(x + 3, y0 + 3, 4, CARD_H - 6, a.alive ? col : ShadeColor(col, 0.35f)); + + if (!a.alive) + { + // Перечёркнутая карточка: потерю отряда нельзя пропустить. + fb.Line(x + 10, y0 + 4, x + CARD_W - 6, y0 + CARD_H - 5, RGBA(180, 60, 60)); + fb.Line(x + 10, y0 + CARD_H - 5, x + CARD_W - 6, y0 + 4, RGBA(180, 60, 60)); + continue; + } + + // Здоровье. + const float hpK = Clampf(a.hp / std::max(1.0f, a.maxHp), 0.0f, 1.0f); + const uint32_t hpCol = (hpK > 0.5f) ? RGBA(150, 200, 130) + : (hpK > 0.25f) ? RGBA(220, 190, 90) + : RGBA(220, 80, 70); + fb.FillRect(x + 10, y0 + 4, BAR_W, 5, COL_EMPTY); + fb.FillRect(x + 10, y0 + 4, int(float(BAR_W) * hpK), 5, hpCol); + + // Броня: тонкая полоска под здоровьем. Её нет — нет и полоски. + if (a.armor > 0.0f) + fb.FillRect(x + 10, y0 + 10, int(float(BAR_W) * Clampf(a.armor / ARMOR_CAP, 0.0f, 1.0f)), + 2, RGBA(120, 150, 190)); + + // Нагрузка. Полоска появляется ТОЛЬКО за нормой переноски: пока боец + // идёт как шёл, показывать нечего, а лишняя линия в каждой карточке + // отняла бы внимание у здоровья и патронов. + // + // Она растёт от нормы к пределу и там же кончается — это и есть шкала + // «насколько ты уже обоз». + if (a.load > CARRY_LIMIT) + { + const float over = Clampf((a.load - CARRY_LIMIT) / (CARRY_MAX - CARRY_LIMIT), + 0.0f, 1.0f); + fb.FillRect(x + 10, y0 + 12, BAR_W, 2, COL_EMPTY); + fb.FillRect(x + 10, y0 + 12, int(float(BAR_W) * over), 2, + (over > 0.85f) ? RGBA(220, 90, 60) : RGBA(200, 160, 70)); + } + + // Оружие: патроны или фаза клинка. + if (a.IsMelee()) DrawMeleeState(fb, x + 10, y0 + 14, a, col); + else + { + DrawAmmo(fb, x + 10, y0 + 14, a, col); + DrawReserve(fb, x + 10, y0 + 19, a); + } + + // Иконка того, что в руках. + fb.Blit(game.sprites.Icon(a.held), x + CARD_W - 12, y0 + CARD_H / 2); + + // Есть цель — уголок над карточкой. Отряд, который никого не видит, + // должен отличаться от отряда, который работает. + const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane; + if (engaged) fb.FillRect(x + CARD_W / 2 - 3, y0 - 3, 6, 2, RGBA(230, 220, 200)); + } +} diff --git a/src/render/hud.h b/src/render/hud.h new file mode 100644 index 0000000..8497eda --- /dev/null +++ b/src/render/hud.h @@ -0,0 +1,21 @@ +#pragma once +// ----------------------------------------------------------------------------- +// HUD отряда. Рисуется В ТОТ ЖЕ софтверный фреймбуфер, что и мир, обычными +// примитивами — значит, попадает под ту же сетку пикселей и не выбивается из +// картинки. Текста здесь нет ни одной буквы: на 480x270 шрифт либо нечитаем, +// либо занимает четверть экрана. Всё, что нужно знать в бою, показано формой +// и цветом: +// +// цвет полосы = номер бойца (тот же, что у его тела); +// длина полосы = здоровье; +// квадратики = патроны в магазине, гаснут по одному; +// полоса-заливка вместо квадратиков = откат клинка; +// иконка справа = что в руках. +// +// Слова живут в меню и на экране отряда (src/ui/menu.cpp) — там есть место. +// ----------------------------------------------------------------------------- +#include "engine/framebuffer.h" + +struct Game; + +void DrawHud(Framebuffer& fb, const Game& game); diff --git a/src/render/lighting.cpp b/src/render/lighting.cpp index 6d7bfe4..2f1bb2d 100644 --- a/src/render/lighting.cpp +++ b/src/render/lighting.cpp @@ -1,4 +1,5 @@ #include "render/lighting.h" +#include "engine/projection.h" namespace { @@ -88,15 +89,25 @@ void Lighting::Update(const Agent* agents, int count, const Tilemap& map, float for (int i = 0; i < MAP_W * MAP_H; ++i) visRaw[i] = 0.0f; // --- 1. видимость пола от каждого агента --------------------------------- - const float rFull = g_tune.sightFullRadius; - const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius); const float coneHalf = g_tune.sightConeDeg * 0.5f * DEG2RAD_F; const float coneSoft = g_tune.sightConeSoftDeg * DEG2RAD_F; - const float personal = g_tune.sightPersonalRadius; for (int a = 0; a < count; ++a) { const Agent& ag = agents[a]; + if (!ag.alive) continue; // павший больше не смотрит по сторонам + + // Радиус обзора СВОЙ у каждого бойца: фонарь в кармане светит дальше, + // саван — ближе (sim/items.h). Без этого экран отряда показывал бы + // «SIGHT 135%», за которым ничего не стоит. + // Глубина сжимает обзор ВСЕМ одинаково, поверх личного множителя: + // фонарь на двадцатом этаже по-прежнему лучше савана, просто оба + // светят хуже, чем на первом. + const float mul = ag.sightMul * sightScale; + const float rFull = g_tune.sightFullRadius * mul; + const float rMax = std::max(rFull + 0.01f, g_tune.sightRadius * mul); + const float personal = g_tune.sightPersonalRadius * mul; + const int x0 = std::max(0, int(std::floor(ag.pos.x - rMax))); const int x1 = std::min(MAP_W - 1, int(std::floor(ag.pos.x + rMax))); const int y0 = std::max(0, int(std::floor(ag.pos.y - rMax))); @@ -271,12 +282,32 @@ ShadeQuad Lighting::QuadFor(int tx, int ty, int clampRow) const const float Bv = c01 - c00; const float K = c00 - c10 - c01 + c11; + const float tw = float(g_proj.tileW); + const float th = float(g_proj.tileH); + ShadeQuad q; - q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f; - q.b = (Bu - Bv + K) / float(TILE_W); - q.c = (Bu + Bv) / float(TILE_H); - q.e = -K / float(TILE_W * TILE_W); - q.f = K / float(TILE_H * TILE_H); q.clampRow = clampRow; + + if (g_proj.mode == ViewMode::TOP_DOWN) + { + // Плита — квадрат, углы лежат по сторонам света: c00 в (0,0), c10 в + // (tw,0), c01 в (0,th). Косой член K тут остался бы честным слагаемым + // u*v, а ShadeQuad такого не умеет — у неё только px, py, px^2, py^2. + // Поэтому он отбрасывается: на тайле в 32 пикселя это доли ступени + // рампы, а размен на лишний член в горячем блите того не стоит. + q.a = c00; + q.b = Bu / tw; + q.c = Bv / th; + return q; + } + + // Изометрия: те же четыре угла попадают в вершины ромба, и билинейка, + // записанная в координатах спрайта, честно раскладывается по этим членам — + // поэтому на общем ребре соседних тайлов значения совпадают и швов нет. + q.a = c00 - (Bu - Bv) * 0.5f - K * 0.25f; + q.b = (Bu - Bv + K) / tw; + q.c = (Bu + Bv) / th; + q.e = -K / (tw * tw); + q.f = K / (th * th); return q; } diff --git a/src/render/lighting.h b/src/render/lighting.h index be5774f..a362747 100644 --- a/src/render/lighting.h +++ b/src/render/lighting.h @@ -6,7 +6,7 @@ // Почему свет НЕ делается пост-проходом по пикселям: // спрайт стены имеет привязку ay = TILE_HALF_H + TILE_H = 24, то есть верхняя // грань поднята на 16 px над ромбом своего тайла. Экранный пиксель крыши, -// прогнанный обратно через iso::ScreenToWorld, даёт ScreenToWorld(0,-16) = +// прогнанный обратно через v2d::ScreenToWorld, даёт ScreenToWorld(0,-16) = // ровно (-1,-1) тайла, то есть СОСЕДНИЙ тайл на северо-запад. Пост-проход // осветил бы крыши чужой видимостью: чёрные крыши на освещённых стенах. // Поэтому свет вплавляется в блит спрайта: спрайт целиком светится от СВОЕГО @@ -18,12 +18,12 @@ // видно сейчас -> нормальная яркость; // вспышка выстрела -> верх рампы, тёплый пересвет. // ----------------------------------------------------------------------------- -#include "core/math.h" -#include "render/framebuffer.h" +#include "engine/math.h" +#include "engine/framebuffer.h" #include "sim/agent.h" #include "sim/events.h" #include "tuning.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" // Свет протекает вглубь блока стен: разделитель y=13..15 толщиной 3 тайла иначе // остаётся чёрным изнутри, и его южная грань висит в пустоте. @@ -52,6 +52,16 @@ struct Lighting bool enabled = true; // F5 — аварийно выключить туман целиком + // Общее сжатие обзора на этом этаже. Чем глубже, тем меньше видно — и это + // главное, чем спуск становится страшнее: не новые спрайты, а сужающийся + // круг вокруг отряда. Значение приходит из кривой глубины + // (engine/progression.h) через View; сама Lighting про глубину не знает. + // + // Живёт здесь, а не в g_tune: tuning общий на весь запуск, и запись туда + // означала бы, что «этаж 12» подкрутил ручку, с которой потом работает + // и первый этаж, и песочница приёмки. + float sightScale = 1.0f; + void Reset(); // Забрать вспышки из журнала боя (то, что накопилось за кадр). diff --git a/src/render/noise.h b/src/render/noise.h index 20703d0..9579869 100644 --- a/src/render/noise.h +++ b/src/render/noise.h @@ -10,4 +10,4 @@ // Состояния нет ни у кого: значение зависит только от аргументов (координата, // номер кадра), общего генератора нет и синхронизировать нечего. // ----------------------------------------------------------------------------- -#include "core/hash.h" +#include "engine/hash.h" diff --git a/src/render/post.cpp b/src/render/post.cpp index 57deae5..b5a2a51 100644 --- a/src/render/post.cpp +++ b/src/render/post.cpp @@ -30,6 +30,9 @@ void Dread::Update(const DreadInput& in, float frameDt) float target = Clampf(0.45f * near_ + 0.75f * unseen + 0.25f * crowd + 0.30f * blind, 0.0f, 1.0f); if (in.friendlyDelta > 0) target = 1.0f; // попали по своему — максимум + // Бойца грызут прямо сейчас — не время для «спокойной» виньетки. + if (in.squadHurtDelta > 0.0f) target = std::max(target, 0.85f); + if (in.squadDownDelta > 0) target = 1.0f; // Асимметрия: страх набегает быстро, отпускает медленно. const float rate = (target > level) ? g_tune.dreadAttack : g_tune.dreadRelease; @@ -61,6 +64,20 @@ void Dread::Update(const DreadInput& in, float frameDt) pulse = std::min(1.0f, pulse + 0.35f * float(in.meleeHitsDelta)); } + // Удар ПО отряду трясёт сильнее всего: это единственное событие, после + // которого игрок обязан посмотреть на HUD. + if (in.squadHurtDelta > 0.0f) + { + const float k = Clampf(in.squadHurtDelta / 25.0f, 0.15f, 1.0f); + shake = std::min(g_tune.shakeMax, shake + g_tune.meleeShake * 1.4f * k); + pulse = std::min(1.0f, pulse + 0.5f * k); + } + if (in.squadDownDelta > 0) + { + shake = g_tune.shakeMax; + pulse = 1.0f; + } + shake *= std::exp(-g_tune.shakeDecay * frameDt); pulse *= std::exp(-6.0f * frameDt); } diff --git a/src/render/post.h b/src/render/post.h index 4b9913f..3870bc7 100644 --- a/src/render/post.h +++ b/src/render/post.h @@ -9,8 +9,8 @@ #include #include -#include "core/math.h" -#include "render/framebuffer.h" +#include "engine/math.h" +#include "engine/framebuffer.h" // Что кадр знает о происходящем — собирается из const Game& в RenderGame. struct DreadInput @@ -24,6 +24,10 @@ struct DreadInput int killsDelta = 0; int friendlyDelta = 0; int meleeHitsDelta = 0; // ударов клинком за кадр + // Досталось СВОИМ. Отдельно от friendlyDelta: то — нарушение правила 6.9, + // это — обычный ход боя, но кадр должен дёрнуться и в том, и в другом. + float squadHurtDelta = 0.0f; // сколько здоровья отряд потерял за кадр + int squadDownDelta = 0; // сколько бойцов упало за кадр }; // Напряжение: медленно отпускает, быстро нарастает. Кормит виньетку, зерно, diff --git a/src/render/scene.cpp b/src/render/scene.cpp index bfa4881..c6a93fb 100644 --- a/src/render/scene.cpp +++ b/src/render/scene.cpp @@ -3,9 +3,12 @@ namespace { -// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь. -constexpr int CULL_MARGIN_X = TILE_W; -constexpr int CULL_MARGIN_Y = WALL_H + TILE_H; +// Запас за краями кадра, чтобы не отсекать спрайты, торчащие внутрь. Считается +// по САМОМУ БОЛЬШОМУ тайлу из возможных, а не по текущей проекции: это верхняя +// оценка, и запас, взятый с избытком, стоит нескольких лишних блитов по краю — +// заниженный стоит мигающих спрайтов на кромке кадра. +constexpr int CULL_MARGIN_X = TILE_MAX_W; +constexpr int CULL_MARGIN_Y = WALL_H + TILE_MAX_H; inline bool OffScreen(int sx, int sy) { @@ -15,7 +18,7 @@ inline bool OffScreen(int sx, int sy) } // namespace -void Scene::Begin(const iso::Camera& cam) +void Scene::Begin(const v2d::Camera& cam) { items.clear(); cam.ScreenOffset(ox, oy); @@ -23,13 +26,13 @@ void Scene::Begin(const iso::Camera& cam) void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias) { - const Vec2 s = iso::WorldToScreen(world); + const Vec2 s = v2d::WorldToScreen(world); const int sx = int(std::lround(s.x)) + ox; const int sy = int(std::lround(s.y)) + oy - pixelLift; if (OffScreen(sx, sy)) return; - iso::DrawItem it; - it.depth = iso::Depth(world) + depthBias; + v2d::DrawItem it; + it.depth = v2d::Depth(world) + depthBias; it.spr = spr; it.x = sx; it.y = sy; @@ -39,13 +42,13 @@ void Scene::Add(const Sprite* spr, Vec2 world, int pixelLift, float depthBias) void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int pixelLift, float depthBias) { - const Vec2 s = iso::WorldToScreen(world); + const Vec2 s = v2d::WorldToScreen(world); const int sx = int(std::lround(s.x)) + ox; const int sy = int(std::lround(s.y)) + oy - pixelLift; if (OffScreen(sx, sy)) return; - iso::DrawItem it; - it.depth = iso::Depth(world) + depthBias; + v2d::DrawItem it; + it.depth = v2d::Depth(world) + depthBias; it.spr = spr; it.x = sx; it.y = sy; @@ -56,8 +59,8 @@ void Scene::AddShaded(const Sprite* spr, Vec2 world, const ShadeQuad& quad, int void Scene::Flush(Framebuffer& fb) { - iso::SortByDepth(items); - iso::FlushDrawList(fb, items); + v2d::SortByDepth(items); + v2d::FlushDrawList(fb, items); } // Ниже этого уровня рампа даёт ступень 0, то есть чистый чёрный. @@ -73,14 +76,14 @@ void DrawFloor(Framebuffer& fb, const SpriteSet& sprites, const Tilemap& map, in if (map.IsWall(tx, ty)) continue; // под стеной пол не виден if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue; - const Vec2 c = iso::WorldToScreen({tx + 0.5f, ty + 0.5f}); + const Vec2 c = v2d::WorldToScreen({tx + 0.5f, ty + 0.5f}); const int sx = int(std::lround(c.x)) + ox; const int sy = int(std::lround(c.y)) + oy; if (OffScreen(sx, sy)) continue; const Sprite* stained = blood ? blood->At(tx, ty) : nullptr; - const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty); - fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, TILE_H - 1)); + const Sprite& s = stained ? *stained : sprites.FloorTile(tx, ty, map.ThemeAt(tx, ty)); + fb.BlitShaded(s, sx, sy, light.QuadFor(tx, ty, g_proj.tileH - 1)); } } } @@ -95,7 +98,7 @@ void CollectWalls(Scene& scene, const SpriteSet& sprites, const Tilemap& map, if (light.MaxCornerOf(tx, ty) < DARK_CUTOFF) continue; // clampRow = TILE_H-1: вертикальные грани светятся как нижняя кромка // крыши, а не как экстраполяция патча на 32 строки вниз. - scene.AddShaded(&sprites.wall, {tx + 0.5f, ty + 0.5f}, - light.QuadFor(tx, ty, TILE_H - 1)); + scene.AddShaded(&sprites.WallTile(tx, ty, map.ThemeAt(tx, ty)), {tx + 0.5f, ty + 0.5f}, + light.QuadFor(tx, ty, g_proj.tileH - 1)); } } diff --git a/src/render/scene.h b/src/render/scene.h index 69d5cf4..477eff6 100644 --- a/src/render/scene.h +++ b/src/render/scene.h @@ -6,19 +6,19 @@ #include #include "render/blood.h" -#include "render/framebuffer.h" -#include "render/iso.h" +#include "engine/framebuffer.h" +#include "engine/view2d.h" #include "render/sprites.h" #include "render/lighting.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" struct Scene { - std::vector items; + std::vector items; int ox = 0; // смещение камеры в пикселях (целое) int oy = 0; - void Begin(const iso::Camera& cam); + void Begin(const v2d::Camera& cam); // world — мировая позиция «подошвы» сущности; // pixelLift — подъём спрайта по экрану (пули летят не по земле); diff --git a/src/render/sprites.cpp b/src/render/sprites.cpp index aa7daab..0631fa9 100644 --- a/src/render/sprites.cpp +++ b/src/render/sprites.cpp @@ -1,97 +1,18 @@ #include "render/sprites.h" -#include "core/math.h" +#include + +#include "engine/hash.h" +#include "engine/math.h" +#include "engine/sfa_library.h" +#include "monster_sprites.h" namespace { -// Занятая часть строки y для ромба 32x16. -void DiamondRow(int y, int& x0, int& x1) -{ - const int k = (y < TILE_HALF_H) ? y : (TILE_H - 1 - y); - const int width = 4 * (k + 1); - x0 = TILE_HALF_W - width / 2; - x1 = x0 + width; -} - -// Обводка по силуэту: любой непрозрачный пиксель, у которого есть прозрачный -// 4-сосед (или край спрайта), перекрашивается в outline. -void OutlineSilhouette(Sprite& s, uint32_t outline) -{ - Sprite copy = s; - for (int y = 0; y < s.h; ++y) - { - for (int x = 0; x < s.w; ++x) - { - if (AlphaOf(copy.Get(x, y)) == 0) continue; - const bool border = - x == 0 || y == 0 || x == s.w - 1 || y == s.h - 1 || - AlphaOf(copy.Get(x - 1, y)) == 0 || AlphaOf(copy.Get(x + 1, y)) == 0 || - AlphaOf(copy.Get(x, y - 1)) == 0 || AlphaOf(copy.Get(x, y + 1)) == 0; - if (border) s.Set(x, y, outline); - } - } -} - -Sprite MakeFloor(uint32_t base) -{ - Sprite s; - s.Init(TILE_W, TILE_H, 0); - s.ax = TILE_HALF_W; - s.ay = TILE_HALF_H; - - const uint32_t edge = ShadeColor(base, 0.78f); - const uint32_t fleck = ShadeColor(base, 1.18f); - - for (int y = 0; y < TILE_H; ++y) - { - int x0 = 0, x1 = 0; - DiamondRow(y, x0, x1); - for (int x = x0; x < x1; ++x) - { - uint32_t c = base; - if (x < x0 + 2 || x >= x1 - 2) c = edge; // рант тайла - else if (((x * 7 + y * 13) % 23) == 0) c = fleck; // «металлическая» рябь - s.Set(x, y, c); - } - } - return s; -} - -Sprite MakeWall(uint32_t top) -{ - Sprite s; - s.Init(TILE_W, WALL_H, 0); - s.ax = TILE_HALF_W; - s.ay = TILE_HALF_H + TILE_H; // низ блока садится на ромб пола - - const uint32_t left = ShadeColor(top, 0.62f); - const uint32_t right = ShadeColor(top, 0.80f); - - // верхняя грань - for (int y = 0; y < TILE_H; ++y) - { - int x0 = 0, x1 = 0; - DiamondRow(y, x0, x1); - for (int x = x0; x < x1; ++x) s.Set(x, y, top); - } - - // вертикальные грани: от нижней кромки ромба вниз на высоту тайла - for (int x = 0; x < TILE_W; ++x) - { - int bottom = -1; - for (int y = 0; y < TILE_H; ++y) - if (AlphaOf(s.Get(x, y)) != 0) bottom = y; - if (bottom < 0) continue; - - const uint32_t side = (x < TILE_HALF_W) ? left : right; - for (int y = bottom + 1; y <= bottom + TILE_H && y < WALL_H; ++y) - s.Set(x, y, side); - } - - OutlineSilhouette(s, ShadeColor(top, 0.35f)); - return s; -} +// DiamondRowN / DiamondRow / OutlineSilhouette / ShadeColor переехали в +// engine/pixel.h: ими рисует и движок (тайлы пола и стен), и игра. Две копии +// наклона ромба означали бы обстановку в одной изометрии, а пол — в другой. // Капсула с обводкой и бликом. Sprite MakeCapsule(int w, int h, float radius, uint32_t body) @@ -126,77 +47,732 @@ Sprite MakeCapsule(int w, int h, float radius, uint32_t body) return s; } -// Боец ближнего боя: та же капсула, но шире в плечах и с клинком у бедра. -// На 480x270 класс обязан читаться силуэтом — цвет уже занят номером агента. -Sprite MakeMeleeCapsule(int w, int h, float radius, uint32_t body) +// --- оружие в руках ---------------------------------------------------------- +// +// Ствол и клинок дорисовываются ПОВЕРХ капсулы по правому краю силуэта: на +// 14 пикселях иначе не видно ничего. Разница между длинным и тяжёлым стволом — +// два пикселя толщины и один длины, и этого достаточно: игрок различает их +// не разглядывая, а по общему пятну. + +const uint32_t kSteel = RGBA(168, 176, 190); +const uint32_t kSteelDark = RGBA(74, 78, 88); +const uint32_t kEdge = RGBA(238, 244, 255); + +void DrawBarrel(Sprite& s, int len, int thick, bool heavy) { - Sprite s = MakeCapsule(w, h, radius, body); + const int bx = s.w - 2; + const int by = s.h - 7; // на уровне пояса - const uint32_t steel = RGBA(196, 206, 222); - const uint32_t edge = RGBA(240, 246, 255); + for (int t = 0; t < thick; ++t) + for (int i = 0; i < len; ++i) + s.Set(bx - i, by + t, (t == 0) ? kSteel : kSteelDark); - // Клинок: короткая вертикаль вдоль правого края силуэта, с бликом. - const int bx = w - 2; - for (int y = 2; y < h - 4; ++y) + // Дуло у тяжёлого ствола шире самого ствола: пятно на конце и есть то, + // по чему дробовик отличается от карабина. + if (heavy) { - s.Set(bx, y, steel); - if (y < h / 2) s.Set(bx, y, edge); + s.Set(bx + 1, by, kSteel); + s.Set(bx + 1, by + 1, kSteel); + s.Set(bx + 1, by - 1, kSteelDark); } - // Гарда, чтобы клинок не читался как случайная полоска. - s.Set(bx - 1, h - 5, steel); - s.Set(bx, h - 5, steel); + // Рукоять вниз — иначе ствол читается как случайная полоска. + s.Set(bx - len + 1, by + thick, kSteelDark); +} + +void DrawBlade(Sprite& s, int len, bool guard) +{ + const int bx = s.w - 2; + const int y0 = std::max(1, s.h - 4 - len); + + for (int y = y0; y < s.h - 4; ++y) s.Set(bx, y, (y < s.h / 2) ? kEdge : kSteel); + if (guard) + { + s.Set(bx - 1, s.h - 5, kSteel); + s.Set(bx, s.h - 5, kSteel); + } +} + +Sprite MakeBody(BodyLook look, uint32_t body) +{ + switch (look) + { + case BodyLook::GUN_SHORT: + { + Sprite s = MakeCapsule(9, 13, 3.6f, body); + DrawBarrel(s, 2, 1, false); + return s; + } + case BodyLook::GUN_HEAVY: + { + Sprite s = MakeCapsule(12, 15, 4.8f, body); + DrawBarrel(s, 4, 2, true); + return s; + } + case BodyLook::BLADE_LONG: + { + Sprite s = MakeCapsule(12, 15, 4.6f, body); + DrawBlade(s, 9, true); + return s; + } + case BodyLook::BLADE_SHORT: + { + Sprite s = MakeCapsule(10, 14, 4.0f, body); + DrawBlade(s, 4, false); + return s; + } + case BodyLook::GUN_LONG: + default: + { + Sprite s = MakeCapsule(10, 14, 4.0f, body); + DrawBarrel(s, 4, 1, false); + return s; + } + } +} + +// Павший: та же капсула, положенная набок и обесцвеченная. Он остаётся на полу +// до конца вылазки — отряд обязан видеть, где и кого потерял. +Sprite MakeCorpse(uint32_t body) +{ + const Sprite up = MakeCapsule(10, 14, 4.0f, ShadeColor(body, 0.45f)); + + Sprite s; + s.Init(up.h, up.w + 2, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + for (int y = 0; y < up.h; ++y) + for (int x = 0; x < up.w; ++x) + { + const uint32_t c = up.Get(x, y); + if (AlphaOf(c) == 0) continue; + s.Set(y, s.h - 3 - x, c); // поворот на 90 градусов + } return s; } -// Стрелок с пистолетом: та же капсула, но уже в плечах и с коротким стволом -// у пояса. Класс оружия обязан читаться силуэтом — цвет уже занят номером. -Sprite MakePistolCapsule(int w, int h, float radius, uint32_t body) +// --- твари ------------------------------------------------------------------- +// +// Не капсулы. Тварь на 480x270 занимает 14-24 пикселя, и опознаётся она ПЯТНОМ: +// сначала силуэт, потом цвет, детали — никогда. Поэтому каждый вид строится как +// объединение эллипсов (голова, корпус, конечности), а объём даёт не «блик +// слева», а свет по КРАЮ силуэта: подсвечена верхне-левая кромка, затенена +// нижне-правая. Тот же приём, что у стены, — и твари садятся в один свет с ней. + +// Эллипс силуэта. Отрицательный ry допустим только по недосмотру, поэтому он +// зажат: вырожденный эллипс молча съел бы часть тела. +struct Blob { - Sprite s = MakeCapsule(w, h, radius, body); + float cx, cy, rx, ry; +}; - const uint32_t steel = RGBA(150, 156, 168); - const uint32_t dark = RGBA(70, 74, 84); +bool InsideBlob(const Blob& b, float x, float y) +{ + const float dx = (x - b.cx) / std::max(0.5f, b.rx); + const float dy = (y - b.cy) / std::max(0.5f, b.ry); + return dx * dx + dy * dy <= 1.0f; +} - // Коротышка-ствол: две пиксельных полоски у бедра, с рукоятью вниз. - const int bx = w - 2; - const int by = h - 7; - s.Set(bx, by, steel); - s.Set(bx - 1, by, steel); - s.Set(bx, by + 1, dark); - s.Set(bx, by + 2, dark); +void FillBlobs(Sprite& s, const Blob* blobs, int count, uint32_t body) +{ + for (int y = 0; y < s.h; ++y) + for (int x = 0; x < s.w; ++x) + for (int i = 0; i < count; ++i) + if (InsideBlob(blobs[i], float(x), float(y))) + { + s.Set(x, y, body); + break; + } +} + +// Объём по краю силуэта: свет падает сверху-слева. Проверяется НЕ сосед, а +// пиксель через два, — иначе на теле шириной 8 подсветка съедает его целиком. +void ShadeSilhouette(Sprite& s, uint32_t body) +{ + const uint32_t hilite = ShadeColor(body, 1.32f); + const uint32_t shadow = ShadeColor(body, 0.66f); + const uint32_t deep = ShadeColor(body, 0.48f); + + const Sprite copy = s; + for (int y = 0; y < s.h; ++y) + for (int x = 0; x < s.w; ++x) + { + if (AlphaOf(copy.Get(x, y)) == 0) continue; + const bool litEdge = AlphaOf(copy.Get(x - 2, y - 1)) == 0; + const bool darkEdge = AlphaOf(copy.Get(x + 2, y + 1)) == 0; + if (litEdge && !darkEdge) s.Set(x, y, hilite); + else if (darkEdge && !litEdge) s.Set(x, y, shadow); + else if (darkEdge) s.Set(x, y, deep); + } +} + +// Рваный низ: несколько столбцов силуэта укорачиваются. Дешёвый приём, но +// именно он отличает бредущее тело от аккуратной капсулы отряда. +void TatterBottom(Sprite& s, uint32_t seed, int maxCut) +{ + for (int x = 0; x < s.w; ++x) + { + const int cut = int(Hash01(Hash2(seed, uint32_t(x))) * float(maxCut + 1)); + for (int i = 0; i < cut; ++i) s.Set(x, s.h - 1 - i, 0); + } +} + +// Пара глаз. Единственная деталь, которую видно на всех размерах, — и она же +// говорит, куда тварь смотрит. +void DrawEyes(Sprite& s, int x0, int x1, int y, uint32_t col) +{ + s.Set(x0, y, col); + s.Set(x1, y, col); +} + +Sprite MakeShambler(uint32_t body) +{ + // Сутулый: голова свешена вперёд и НИЖЕ плеч, руки висят. Читается как + // «идёт, но еле-еле» — ровно то, что он и делает. + Sprite s; + s.Init(13, 17, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + const Blob parts[] = { + {6.5f, 10.0f, 4.2f, 6.0f}, // корпус + {8.0f, 4.5f, 3.0f, 2.8f}, // голова, свешена вправо-вперёд + {2.5f, 10.5f, 1.8f, 4.5f}, // левая рука + {10.5f, 11.5f, 1.6f, 4.0f}, // правая рука, длиннее + {5.0f, 15.0f, 2.4f, 2.4f}, // ноги + }; + FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body); + ShadeSilhouette(s, body); + + // Провал на месте лица: у бредущего его нет, и это страшнее любых глаз. + const uint32_t maw = ShadeColor(body, 0.20f); + for (int x = 7; x < 10; ++x) s.Set(x, 5, maw); + s.Set(8, 6, maw); + + TatterBottom(s, 0x5A17u, 2); + OutlineSilhouette(s, ShadeColor(body, 0.28f)); + return s; +} + +Sprite MakeRusher(uint32_t body) +{ + // Узкий, высокий, наклонён вперёд: в толпе бредущих его видно по одному + // силуэту, и наклон говорит, что он сейчас прыгнет. + Sprite s; + s.Init(11, 18, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + const Blob parts[] = { + {5.0f, 10.5f, 2.6f, 6.0f}, // тонкий корпус + {7.0f, 3.8f, 2.4f, 3.2f}, // вытянутая вперёд башка + {8.6f, 8.0f, 1.4f, 3.6f}, // передняя лапа вынесена + {2.0f, 11.0f, 1.3f, 3.4f}, // задняя + {4.5f, 15.5f, 2.2f, 2.2f}, + }; + FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body); + ShadeSilhouette(s, body); + + // Глаза горят: он единственный, кто добежит, и знать это надо заранее. + DrawEyes(s, 6, 8, 3, ShadeColor(body, 1.85f)); + + TatterBottom(s, 0x9E37u, 3); + OutlineSilhouette(s, ShadeColor(body, 0.30f)); + return s; +} + +Sprite MakeSpitter(uint32_t body) +{ + // Приземистый мешок на коротких ногах с распахнутой пастью: единственный, + // кто бьёт издалека, и по картинке это понятно до первого плевка. + Sprite s; + s.Init(16, 14, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + const Blob parts[] = { + {8.0f, 8.0f, 6.6f, 4.8f}, // раздутый корпус + {8.0f, 3.6f, 3.4f, 2.6f}, // приплюснутая голова + {3.0f, 12.0f, 1.8f, 1.8f}, // ноги-обрубки + {13.0f, 12.0f, 1.8f, 1.8f}, + }; + FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body); + ShadeSilhouette(s, body); + + // Пасть — тёмный провал во всю голову. Это его «ствол». + const uint32_t maw = ShadeColor(body, 0.18f); + const uint32_t bile = ShadeColor(body, 1.70f); + for (int x = 5; x < 12; ++x) s.Set(x, 4, maw); + for (int x = 6; x < 11; ++x) s.Set(x, 5, maw); + s.Set(7, 3, bile); + s.Set(10, 3, bile); + + // Желчные пузыри на боках: намёк на то, чем именно он плюётся. + for (int y = 7; y < 11; ++y) + for (int x = 2; x < s.w - 2; ++x) + { + if (AlphaOf(s.Get(x, y)) == 0) continue; + if (((x * 3 + y * 7) % 13) == 0) s.Set(x, y, bile); + } + + OutlineSilhouette(s, ShadeColor(body, 0.30f)); + return s; +} + +Sprite MakeBrute(uint32_t body) +{ + // Гора мяса: плечи ШИРЕ таза, голова утоплена между ними, кулаки до пола. + // Он должен читаться как «этого вдвоём», причём с одного взгляда. + Sprite s; + s.Init(22, 25, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + const Blob parts[] = { + {11.0f, 9.0f, 9.0f, 6.0f}, // плечевой пояс + {11.0f, 16.0f, 5.5f, 6.0f}, // туловище + {11.0f, 4.5f, 3.0f, 2.8f}, // утопленная голова + { 2.5f, 17.0f, 2.6f, 5.5f}, // левая рука до пола + {19.5f, 17.0f, 2.6f, 5.5f}, // правая + { 7.0f, 23.0f, 3.0f, 2.2f}, // ноги + {15.0f, 23.0f, 3.0f, 2.2f}, + }; + FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body); + ShadeSilhouette(s, body); + + const uint32_t bone = ShadeColor(body, 1.55f); + const uint32_t dark = ShadeColor(body, 0.34f); + + // Костяные наросты на плечах — то, чем он держит удар. + for (int i = 0; i < 4; ++i) + { + s.Set(3 + i, 5 + i, bone); + s.Set(s.w - 4 - i, 5 + i, bone); + } + // Кулаки: то, чем он бьёт, обязано быть видно отдельно от рук. + for (int y = s.h - 6; y < s.h - 2; ++y) + for (int x = 1; x < 4; ++x) + { + if (AlphaOf(s.Get(x, y)) != 0) s.Set(x, y, bone); + if (AlphaOf(s.Get(s.w - 1 - x, y)) != 0) s.Set(s.w - 1 - x, y, bone); + } + DrawEyes(s, 10, 12, 4, dark); + + OutlineSilhouette(s, ShadeColor(body, 0.25f)); + return s; +} + +Sprite MakeLurker(uint32_t body) +{ + // Хват: низкий, паучий, на длинных лапах. Раньше он был просто капсулой — + // и единственная тварь, которая вырывает бойца из строя, выглядела как + // мишень. Силуэт должен кричать «не подходи один». + Sprite s; + s.Init(17, 15, 0); + s.ax = s.w / 2; + s.ay = s.h - 2; + + const Blob parts[] = { + {8.5f, 9.0f, 4.0f, 3.6f}, // низкое тело + {8.5f, 5.5f, 2.4f, 2.2f}, // маленькая голова + {2.0f, 7.0f, 1.3f, 4.6f}, // лапы: вынесены далеко вбок + {15.0f, 7.0f, 1.3f, 4.6f}, + {4.0f, 12.5f, 1.4f, 2.2f}, + {13.0f, 12.5f, 1.4f, 2.2f}, + }; + FillBlobs(s, parts, int(sizeof(parts) / sizeof(parts[0])), body); + ShadeSilhouette(s, body); + + // Когти на концах передних лап — то, чем он держит. + const uint32_t claw = ShadeColor(body, 1.75f); + s.Set(1, 2, claw); s.Set(2, 3, claw); + s.Set(s.w - 2, 2, claw); s.Set(s.w - 3, 3, claw); + + DrawEyes(s, 7, 10, 5, ShadeColor(body, 1.9f)); + + OutlineSilhouette(s, ShadeColor(body, 0.26f)); + return s; +} + +// --- разрушаемая обстановка -------------------------------------------------- +// +// Не капсулы. Хлам обязан читаться как ЧАСТЬ УРОВНЯ, а не как тварь, которую +// забыли покрасить: коробка стоит в той же изометрии 2:1, что пол и стены, и +// светится тем же ShadeQuad. Правило чтения — то же, что у тварей: силуэт несёт +// вид, цвет — материал. +// +// Привязка у всех считается одинаково: ay = h - dh/2, то есть центр НИЖНЕГО +// ромба (основания). Ровно так же посажена стена в MakeWall — иначе обстановка +// поехала бы относительно пола на половину тайла. + +// Изометрическая коробка: верхний ромб dw x dh + вертикальные грани высотой tall. +Sprite MakeBox(int dw, int dh, int tall, uint32_t top) +{ + Sprite s; + s.Init(dw, dh + tall, 0); + s.ax = dw / 2; + s.ay = (dh + tall) - dh / 2; + + const uint32_t left = ShadeColor(top, 0.60f); + const uint32_t right = ShadeColor(top, 0.80f); + + for (int y = 0; y < dh; ++y) + { + int x0 = 0, x1 = 0; + DiamondRowN(y, dw, dh, x0, x1); + for (int x = x0; x < x1; ++x) s.Set(x, y, top); + } + + for (int x = 0; x < dw; ++x) + { + int bottom = -1; + for (int y = 0; y < dh; ++y) + if (AlphaOf(s.Get(x, y)) != 0) bottom = y; + if (bottom < 0) continue; + + const uint32_t side = (x < dw / 2) ? left : right; + for (int y = bottom + 1; y <= bottom + tall && y < s.h; ++y) s.Set(x, y, side); + } + return s; +} + +Sprite MakeCrate(uint32_t body) +{ + Sprite s = MakeBox(20, 10, 11, body); + + const uint32_t plank = ShadeColor(body, 1.22f); + const uint32_t dark = ShadeColor(body, 0.42f); + const uint32_t band = ShadeColor(body, 0.52f); + + // Доски: горизонтальный шов посередине граней и косой крест на них. + // Без него коробка читается как кубик, а не как заколоченный ящик. + for (int x = 0; x < s.w; ++x) + { + if (AlphaOf(s.Get(x, s.h - 6)) == 0) continue; + s.Set(x, s.h - 6, band); + } + for (int i = 0; i < 7; ++i) + { + s.Set(3 + i, s.h - 9 + i, plank); + s.Set(s.w - 4 - i, s.h - 9 + i, plank); + } + + // Светлая грань верхней крышки — единственное, что даёт объём на 20 пикселях. + for (int y = 1; y < 4; ++y) + { + int x0 = 0, x1 = 0; + DiamondRowN(y, 20, 10, x0, x1); + for (int x = x0 + 1; x < x1 - 1; ++x) s.Set(x, y, plank); + } + + OutlineSilhouette(s, dark); + return s; +} + +Sprite MakeBarrel(uint32_t body) +{ + // Бочка — цилиндр: верх эллипс, бока прямые. Ромб тут врёт, поэтому + // силуэт строится отдельно, а не через MakeBox. + const int w = 15, capH = 7, tall = 13; + + Sprite s; + s.Init(w, capH + tall, 0); + s.ax = w / 2; + s.ay = s.h - capH / 2; + + const uint32_t lid = ShadeColor(body, 1.25f); + const uint32_t left = ShadeColor(body, 0.95f); + const uint32_t right = ShadeColor(body, 0.62f); + const uint32_t hoop = ShadeColor(body, 1.45f); + const uint32_t rust = ShadeColor(body, 0.70f); + + const float cx = (w - 1) * 0.5f; + const float rx = cx, ry = (capH - 1) * 0.5f; + + // Крышка: эллипс rx x ry. + for (int y = 0; y < capH; ++y) + for (int x = 0; x < w; ++x) + { + const float dx = (float(x) - cx) / rx; + const float dy = (float(y) - ry) / std::max(0.5f, ry); + if (dx * dx + dy * dy > 1.0f) continue; + s.Set(x, y, (y < capH / 2) ? lid : body); + } + + // Бока: каждый столбец крышки продлевается вниз, к низу чуть сужаясь — + // клёпаный бок, а не труба. + for (int x = 0; x < w; ++x) + { + int bottom = -1; + for (int y = 0; y < capH; ++y) + if (AlphaOf(s.Get(x, y)) != 0) bottom = y; + if (bottom < 0) continue; + + const float t = std::fabs(float(x) - cx) / rx; + const int shrink = (t > 0.86f) ? 2 : 0; // нижняя кромка скруглена + const uint32_t side = (float(x) < cx - 0.5f) ? left : right; + + for (int y = bottom + 1; y <= bottom + tall - shrink && y < s.h; ++y) + s.Set(x, y, side); + } + + // Два обруча: то, по чему бочка отличается от тумбы одним взглядом. + for (int x = 0; x < w; ++x) + for (const int hy : {capH + 3, capH + 8}) + { + if (hy >= s.h || AlphaOf(s.Get(x, hy)) == 0) continue; + s.Set(x, hy, hoop); + } + + // Ржавые потёки: детерминированная рябь, как «металлическая» на полу. + for (int y = capH; y < s.h; ++y) + for (int x = 0; x < w; ++x) + { + if (AlphaOf(s.Get(x, y)) == 0) continue; + if (((x * 5 + y * 11) % 17) == 0) s.Set(x, y, rust); + } + + OutlineSilhouette(s, ShadeColor(body, 0.38f)); + return s; +} + +Sprite MakeLocker(uint32_t body) +{ + // Узкий и высокий: в ряду хлама шкафчик опознаётся по одному силуэту. + Sprite s = MakeBox(14, 7, 21, body); + + const uint32_t door = ShadeColor(body, 0.88f); + const uint32_t seam = ShadeColor(body, 0.45f); + const uint32_t latch = RGBA(196, 178, 120); + const uint32_t vent = ShadeColor(body, 0.55f); + + // Дверцы: шов по центру правой грани и жалюзи сверху. + const int seamX = s.w / 2; + for (int y = s.h - 21; y < s.h - 2; ++y) s.Set(seamX, y, seam); + for (int i = 0; i < 3; ++i) + { + const int y = s.h - 19 + i * 2; + for (int x = seamX + 2; x < s.w - 2; ++x) s.Set(x, y, vent); + } + for (int y = 0; y < s.h; ++y) + if (AlphaOf(s.Get(seamX - 3, y)) != 0 && y > s.h - 20) s.Set(seamX - 3, y, door); + + s.Set(seamX + 1, s.h - 11, latch); + s.Set(seamX + 1, s.h - 10, latch); + + OutlineSilhouette(s, ShadeColor(body, 0.35f)); + return s; +} + +// --- иконки предметов -------------------------------------------------------- +// +// 14x14, по одной форме на ВИД предмета. Читать нужно вид, а не модель: имя +// пишется рядом текстом, и дублировать его пиктограммой незачем. +Sprite MakeIcon(ItemId id) +{ + const ItemDef def = ItemDefOf(id); + + Sprite s; + s.Init(14, 14, 0); + s.ax = 7; + s.ay = 7; + if (def.kind == ItemKind::NONE) return s; + + const uint32_t c = RGBA(def.r, def.g, def.b); + const uint32_t dark = ShadeColor(c, 0.45f); + const uint32_t hot = ShadeColor(c, 1.35f); + + switch (def.kind) + { + case ItemKind::FIREARM: + for (int x = 1; x < 12; ++x) s.Set(x, 6, c); + for (int x = 1; x < 12; ++x) s.Set(x, 7, dark); + for (int y = 7; y < 11; ++y) s.Set(3, y, dark); + s.Set(11, 5, hot); + s.Set(12, 6, hot); + break; + + case ItemKind::BLADE: + for (int i = 0; i < 10; ++i) s.Set(2 + i, 11 - i, (i > 4) ? hot : c); + s.Set(3, 10, dark); + s.Set(4, 11, dark); + s.Set(2, 9, dark); + break; + + case ItemKind::ARMOR: + for (int y = 2; y < 12; ++y) + { + const int inset = (y < 9) ? 2 : (2 + (y - 8)); + for (int x = inset; x < 14 - inset; ++x) + s.Set(x, y, (y == 2 || x == inset || x == 13 - inset) ? dark : c); + } + break; + + case ItemKind::TRINKET: + for (int y = 0; y < 14; ++y) + for (int x = 0; x < 14; ++x) + { + const float dx = float(x) - 6.5f, dy = float(y) - 6.5f; + const float d = std::sqrt(dx * dx + dy * dy); + if (d > 5.5f) continue; + s.Set(x, y, (d > 4.5f) ? dark : c); + } + s.Set(5, 4, hot); + s.Set(4, 5, hot); + break; + + case ItemKind::CONSUMABLE: + for (int i = 3; i < 11; ++i) + { + s.Set(i, 6, c); + s.Set(i, 7, c); + s.Set(6, i, c); + s.Set(7, i, c); + } + s.Set(6, 6, hot); + s.Set(7, 7, hot); + break; + + default: break; + } return s; } } // namespace -uint32_t ShadeColor(uint32_t c, float k) +uint32_t SlotColor(int slot) { - const float r = Clampf(float(c & 0xFF) * k, 0.0f, 255.0f); - const float g = Clampf(float((c >> 8) & 0xFF) * k, 0.0f, 255.0f); - const float b = Clampf(float((c >> 16) & 0xFF) * k, 0.0f, 255.0f); - return RGBA(uint8_t(r), uint8_t(g), uint8_t(b), AlphaOf(c)); -} - -void SpriteSet::Generate() -{ - floorA = MakeFloor(RGBA(50, 54, 62)); - floorB = MakeFloor(RGBA(42, 46, 53)); - wall = MakeWall(RGBA(78, 84, 96)); - - // 5 различимых цветов — по одному на агента - static const uint32_t agentCols[NUM_AGENTS] = { + // 5 различимых цветов — по одному на слот отряда. Номер бойца читается + // только по цвету, поэтому таблица одна на всю игру. + static const uint32_t cols[NUM_AGENTS] = { RGBA(80, 200, 220), RGBA(232, 176, 72), RGBA(116, 204, 116), RGBA(212, 116, 200), RGBA(116, 148, 240), }; + return cols[(slot >= 0 && slot < NUM_AGENTS) ? slot : 0]; +} + +BodyLook LookOf(ItemId weapon) +{ + switch (weapon) + { + case ItemId::PISTOL: return BodyLook::GUN_SHORT; + case ItemId::SHOTGUN: + case ItemId::MAGNUM: return BodyLook::GUN_HEAVY; + case ItemId::BOW: return BodyLook::GUN_LONG; + case ItemId::SABER: + case ItemId::CLEAVER: return BodyLook::BLADE_LONG; + case ItemId::KNIFE: return BodyLook::BLADE_SHORT; + default: return BodyLook::GUN_LONG; + } +} + +void SpriteSet::Generate() +{ + tiles.Generate(); // пол и стены со всеми темами — движок + for (int i = 0; i < NUM_AGENTS; ++i) { - agent[i] = MakeCapsule(10, 14, 4.0f, agentCols[i]); - melee[i] = MakeMeleeCapsule(12, 15, 4.6f, agentCols[i]); - pistol[i] = MakePistolCapsule(9, 13, 3.6f, agentCols[i]); + const uint32_t col = SlotColor(i); + for (int look = 0; look < int(BodyLook::COUNT); ++look) + body[look][i] = MakeBody(BodyLook(look), col); + corpse[i] = MakeCorpse(col); } - target = MakeCapsule(12, 16, 5.0f, RGBA(120, 44, 44)); + for (int k = 0; k < int(EnemyKind::COUNT); ++k) + { + const EnemyDef def = EnemyDefOf(EnemyKind(k)); + const uint32_t col = RGBA(def.r, def.g, def.b); + + switch (EnemyKind(k)) + { + case EnemyKind::SHAMBLER: enemy[k] = MakeShambler(col); break; + case EnemyKind::RUSHER: enemy[k] = MakeRusher(col); break; + case EnemyKind::SPITTER: enemy[k] = MakeSpitter(col); break; + case EnemyKind::BRUTE: enemy[k] = MakeBrute(col); break; + case EnemyKind::LURKER: enemy[k] = MakeLurker(col); break; + + // Обстановка: не тварь и выглядеть как тварь не должна. + case EnemyKind::CRATE: enemy[k] = MakeCrate(col); break; + case EnemyKind::BARREL: enemy[k] = MakeBarrel(col); break; + case EnemyKind::LOCKER: enemy[k] = MakeLocker(col); break; + + // Мишень приёмки: вид не менялся ни на пиксель. + default: enemy[k] = MakeCapsule(12, 16, 5.0f, col); break; + } + } + target = enemy[int(EnemyKind::DUMMY)]; + + for (int i = 0; i < int(ItemId::COUNT); ++i) icon[i] = MakeIcon(ItemId(i)); +} + +bool SpriteSet::LoadTiles(const SpriteLibrary& lib) +{ + if (!lib.IsOpen()) return false; + + // Пол и стены читает движок (engine/tileset.cpp) — но НЕ здесь, а последним + // шагом: этот вызов сразу подменяет набор, а весь метод обязан оставаться + // «всё или ничего». Сначала проверяем то, что грузится с этой стороны. + + struct PropRow { EnemyKind kind; uint32_t asset; }; + static const PropRow propRows[] = { + {EnemyKind::CRATE, uint32_t(SF_ASSET_PROP_CRATE)}, + {EnemyKind::BARREL, uint32_t(SF_ASSET_PROP_BARREL)}, + {EnemyKind::LOCKER, uint32_t(SF_ASSET_PROP_LOCKER)}, + }; + Sprite newProps[3]; + for (int i = 0; i < 3; ++i) + if (!lib.Read(propRows[i].asset, 0, newProps[i])) return false; + + struct IconRow { ItemId item; uint32_t asset; }; +#define SF_ICON(item, asset) {ItemId::item, uint32_t(SF_ASSET_ITEM_ICON_##asset)} + static const IconRow iconRows[] = { + SF_ICON(RIFLE, RIFLE), SF_ICON(PISTOL, PISTOL), SF_ICON(SHOTGUN, SHOTGUN), + SF_ICON(SMG, SMG), SF_ICON(MAGNUM, MAGNUM), SF_ICON(BOW, BOW), + SF_ICON(SABER, SABER), SF_ICON(KNIFE, KNIFE), SF_ICON(CLEAVER, CLEAVER), + SF_ICON(JACKET, JACKET), SF_ICON(VEST, VEST), SF_ICON(PLATE, PLATE), + SF_ICON(SHROUD, SHROUD), SF_ICON(LANTERN, LANTERN), SF_ICON(CHARM, CHARM), + SF_ICON(SPURS, SPURS), SF_ICON(BANDAGE, BANDAGE), SF_ICON(STIMPACK, STIMPACK), + }; +#undef SF_ICON + Sprite newIcons[int(ItemId::COUNT)]; + for (int i = 0; i < int(ItemId::COUNT); ++i) newIcons[i] = icon[i]; + for (const IconRow& row : iconRows) + if (!lib.Read(uint32_t(row.asset), 0, newIcons[int(row.item)])) return false; + + // Тайлы последними: они подменяются внутри TileSet вместе с пересборкой + // всех тем, и откатывать это было бы некуда. К этой строке всё остальное + // уже прочитано, поэтому единственная возможная осечка — сами тайлы. + if (!tiles.LoadFromForge(lib)) return false; + + for (int i = 0; i < 3; ++i) enemy[int(propRows[i].kind)] = std::move(newProps[i]); + for (int i = 0; i < int(ItemId::COUNT); ++i) icon[i] = std::move(newIcons[i]); + return true; +} + +bool SpriteSet::LoadEnemies(const SpriteLibrary& lib) +{ + if (!lib.IsOpen()) return false; + + // Порядок — порядок EnemyKind. DUMMY и обстановка сюда не входят: мишень + // приёмки не двигается по определению, а ящик не ходит. + struct Row { EnemyKind kind; }; + static const Row rows[] = { + {EnemyKind::SHAMBLER}, {EnemyKind::RUSHER}, {EnemyKind::SPITTER}, + {EnemyKind::BRUTE}, {EnemyKind::LURKER}, + }; + + // Сначала проверяем ВСЕ, потом записываем: полусобранный набор — это одна + // анимированная тварь среди четырёх статичных, что хуже пяти статичных. + for (const Row& r : rows) + for (AnimKind anim : {AnimKind::WALK, AnimKind::ATTACK, AnimKind::HURT, + AnimKind::DIE}) + if (lib.AnimationLength(MonsterAsset(r.kind, anim), + uint32_t(SF_ANIM_DEFAULT)) <= 0.0f) return false; + + for (const Row& r : rows) + enemyWalk[int(r.kind)] = MonsterAsset(r.kind, AnimKind::WALK); + return true; } diff --git a/src/render/sprites.h b/src/render/sprites.h index 142ac00..01c7d15 100644 --- a/src/render/sprites.h +++ b/src/render/sprites.h @@ -1,31 +1,117 @@ #pragma once // ----------------------------------------------------------------------------- -// Процедурная генерация всех спрайтов при старте. Файлов с ассетами нет. +// Процедурная генерация всех спрайтов при старте: базовый набор не зависит ни +// от одного файла на диске. +// +// Тайлы можно подменить на собранные SpriteForge (LoadTiles, dev-режим +// --sf-tiles). Бойцы, твари и иконки остаются процедурными сознательно: на +// 480x270 боец занимает 14 пикселей, и цвет там занят НОМЕРОМ слота, а силуэт — +// КЛАССОМ оружия. И то, и другое живёт в коде, а не в манифесте +// (см. docs/12-assets.md). +// +// Правило чтения картинки, из которого всё вытекает: +// * номер бойца -> цвет тела; +// * класс оружия -> силуэт (ствол/клинок, длинный/короткий/тяжёлый); +// * вид твари -> и силуэт, и цвет; их всего пять, и путать их нельзя. // ----------------------------------------------------------------------------- -#include "render/framebuffer.h" +#include "engine/hash.h" +#include "engine/pixel.h" +#include "engine/tileset.h" +#include "engine/framebuffer.h" +#include "sim/items.h" +#include "sim/target.h" #include "tuning.h" +class SpriteLibrary; + +// Силуэт бойца. Не «какое именно оружие», а «как оно читается»: карабин и +// автомат на 14 пикселях — один и тот же длинный ствол, и притворяться, что +// игрок их различит, бессмысленно. +enum class BodyLook : uint8_t +{ + GUN_LONG = 0, // карабин, автомат + GUN_SHORT, // пистолет + GUN_HEAVY, // дробовик, магнум: толстый ствол, широкие плечи + BLADE_LONG, // сабля, тесак + BLADE_SHORT, // нож + COUNT, +}; + +BodyLook LookOf(ItemId weapon); + struct SpriteSet { - Sprite floorA; // шахматка пола, вариант 1 - Sprite floorB; // вариант 2 - Sprite wall; // блок 32x32 - Sprite agent[NUM_AGENTS]; // стрелок: по цвету на агента - Sprite melee[NUM_AGENTS]; // тот же цвет, но силуэт с клинком - Sprite target; - // Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp). + // Пол и стены живут в движке: ими рисует и игра, и редактор комнат + // (engine/tileset.h). Здесь остаётся только то, что знает про правила, — + // бойцы, твари и иконки предметов. + TileSet tiles; - Sprite pistol[NUM_AGENTS]; // тот же цвет, но силуэт с коротким стволом + // Тело бойца: силуэт по оружию x цвет по слоту. + Sprite body[int(BodyLook::COUNT)][NUM_AGENTS]; + // Павший: тот же цвет, но лежит. Отряд обязан видеть, кого потерял. + Sprite corpse[NUM_AGENTS]; + + // Твари. DUMMY — прежняя мишень приёмки, её вид не менялся. + Sprite enemy[int(EnemyKind::COUNT)]; + Sprite target; // синоним enemy[DUMMY], чтобы не ломать вызовы + + // Анимированный вариант твари: ID ассета SpriteForge, 0 — «нет, рисуй + // процедурный». Кадры здесь НЕ хранятся: они лежат в SpriteLibrary в одном + // экземпляре на ассет, и рендер достаёт нужный по времени клипа. + // + // Пока клип один — цикл шага. Появятся idle/attack — это будут соседние + // массивы и соседние ассеты _idle, а не вторая анимация внутри + // этого: бэкенд import имён клипов не читает (assets/manifests/enemies.yaml). + uint32_t enemyWalk[int(EnemyKind::COUNT)] = {}; + + // Иконки предметов для инвентаря и HUD. + Sprite icon[int(ItemId::COUNT)]; + + // Спрайта пули нет: снаряд рисуется светящимся трассером (render/fx.cpp). void Generate(); - // Какой ромб пола лежит на тайле. Одно место на весь рендер: запечённая - // кровь копирует ИМЕННО его, иначе шахматка поедет (render/blood.h). - const Sprite& FloorTile(int tx, int ty) const + // Заменить пол и стену на тайлы из библиотеки SpriteForge. Всё или ничего: + // при любой осечке набор остаётся тем, что дал Generate(), и возвращается + // false. Вызывать только после Generate(). + bool LoadTiles(const SpriteLibrary& lib); + + // Включить анимированных тварей из библиотеки. Тоже всё или ничего: одна + // анимированная тварь среди пяти статичных выглядит хуже пяти статичных. + // Мишень приёмки (DUMMY) и обстановка не анимируются никогда. + bool LoadEnemies(const SpriteLibrary& lib); + + const Sprite& AgentBody(int slot, ItemId weapon) const { - return ((tx + ty) & 1) ? floorA : floorB; + const int s = (slot >= 0 && slot < NUM_AGENTS) ? slot : 0; + return body[int(LookOf(weapon))][s]; + } + + const Sprite& EnemyBody(EnemyKind kind) const + { + const int k = (int(kind) >= 0 && int(kind) < int(EnemyKind::COUNT)) ? int(kind) : 0; + return enemy[k]; + } + + const Sprite& Icon(ItemId id) const + { + const int i = (int(id) >= 0 && int(id) < int(ItemId::COUNT)) ? int(id) : 0; + return icon[i]; + } + + // Какой ромб пола и какой блок стены лежат на тайле. Одно место на весь + // рендер: запечённая кровь копирует ИМЕННО этот ромб, иначе шахматка + // поедет (render/blood.h). Тему тайл несёт сам (Tilemap::ThemeAt). + const Sprite& FloorTile(int tx, int ty, uint8_t theme) const + { + return tiles.Floor(tx, ty, theme); + } + + const Sprite& WallTile(int tx, int ty, uint8_t theme) const + { + return tiles.Wall(tx, ty, theme); } }; -// Затемнить/осветлить цвет (множитель по RGB, альфа сохраняется). -uint32_t ShadeColor(uint32_t c, float k); +// Цвет слота отряда: номер бойца читается по цвету и больше ничем. +uint32_t SlotColor(int slot); diff --git a/src/render/view.cpp b/src/render/view.cpp index 1131a49..251f0da 100644 --- a/src/render/view.cpp +++ b/src/render/view.cpp @@ -2,12 +2,69 @@ #include "game.h" #include "render/noise.h" +#include "engine/projection.h" +#include "engine/sfa_library.h" +#include "agent_paperdoll.h" +#include "monster_sprites.h" namespace { constexpr uint32_t COL_BG = RGBA(0, 0, 0); // ступень 0 рампы == цвет очистки + +// Насколько тварь должна сдвинуться за кадр, чтобы считаться идущей. Ниже — +// дрожание от расталкивания толпы (CrowdPush), под которое шаг проигрывать +// незачем: стоящая в куче тварь перебирала бы ногами на месте. +constexpr float ENEMY_WALK_EPS = 0.004f; + +// На сколько приподнята иконка лежащего предмета. Ноль клал бы её ровно на +// линию пола, где она сливается с рантом тайла и с кровью. +constexpr int ITEM_LIFT_PX = 4; + +// Какой спрайт рисовать для твари: кадр анимации, если игра запущена с +// собранной библиотекой, иначе процедурный. +// +// Проигрыватель двигается ТОЛЬКО когда тварь реально идёт. Иначе цикл шага +// крутился бы у стоящего плевка, который по замыслу держит дистанцию и никуда +// не идёт, — и вся анимация читалась бы как ошибка. +const Sprite* EnemyFrame(View& view, const SpriteSet& sprites, const Target& t, int index, + float frameDt) +{ + const Sprite* fallback = &sprites.EnemyBody(t.kind); + if (!view.forge || index < 0 || index >= MAX_TARGETS) return fallback; + + if (t.IsProp() || t.kind == EnemyKind::DUMMY) return fallback; + + AnimKind wanted = AnimKind::WALK; + if (!t.alive) wanted = AnimKind::DIE; + else if (t.hurtTimer > 0.0f) wanted = AnimKind::HURT; + else if (t.Telegraphing() || t.Busy()) wanted = AnimKind::ATTACK; + + const uint32_t asset = MonsterAsset(t.kind, wanted); + if (asset == 0) return fallback; + + const float length = view.forge->AnimationLength(asset, uint32_t(SF_ANIM_DEFAULT)); + if (!(length > 0.0f)) return fallback; + + Animator& anim = view.enemyAnim[index]; + const bool looping = wanted == AnimKind::WALK; + anim.Play(wanted, looping); + + const bool moved = view.enemyPosKnown[index] && + DistanceSq(view.enemyPrevPos[index], t.pos) > + ENEMY_WALK_EPS * ENEMY_WALK_EPS; + view.enemyPrevPos[index] = t.pos; + view.enemyPosKnown[index] = true; + + if (wanted != AnimKind::WALK || moved) anim.Advance(frameDt, length); + + const int direction = view.forge->DirectionOf(asset, t.facing); + const Sprite* frame = view.forge->Frame(asset, uint32_t(SF_ANIM_DEFAULT), direction, + anim.time, looping); + return frame ? frame : fallback; } +} // namespace + void View::Init() { post.Init(INTERNAL_W, INTERNAL_H); @@ -35,6 +92,17 @@ void View::Reset(const Game& game) prevKills = game.metrics.targetsKilled; prevFriendly = game.metrics.friendlyHits; prevMeleeHits = game.metrics.meleeHits; + prevSquadHp = -1.0f; // после сброса дельта здоровья считается заново + prevSquadAlive = NUM_AGENTS; + + // Твари в новом сценарии стоят по своим местам: фаза шага, доехавшая из + // прошлого прогона, дала бы рывок в первом же кадре. + for (int i = 0; i < MAX_TARGETS; ++i) + { + enemyAnim[i] = Animator{}; + enemyPosKnown[i] = false; + } + for (int i = 0; i < NUM_AGENTS; ++i) agentAnim[i] = Animator{}; } void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, float frameDt) @@ -68,6 +136,21 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa view.prevFriendly = game.metrics.friendlyHits; view.prevMeleeHits = game.metrics.meleeHits; + float squadHp = 0.0f; + int squadAlive = 0; + for (int i = 0; i < NUM_AGENTS; ++i) + { + squadHp += squad.agents[i].hp; + if (squad.agents[i].alive) ++squadAlive; + } + if (view.prevSquadHp >= 0.0f) + { + di.squadHurtDelta = std::max(0.0f, view.prevSquadHp - squadHp); + di.squadDownDelta = std::max(0, view.prevSquadAlive - squadAlive); + } + view.prevSquadHp = squadHp; + view.prevSquadAlive = squadAlive; + // «Отряд не слеп» — это и линия огня, и клинок в зоне удара: боец, // достающий врага, беспомощным не выглядит и напряжение снимает так же. for (int i = 0; i < NUM_AGENTS; ++i) @@ -78,7 +161,7 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa for (const Target& t : game.targets) { - if (!t.alive) continue; + if (!t.alive || t.IsProp()) continue; // ящик в темноте никого не пугает const float d = Distance(t.pos, squad.anchor); di.nearestTargetDist = std::min(di.nearestTargetDist, d); if (d < g_tune.dreadRadius) ++di.targetsNear; @@ -90,6 +173,10 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa // --- свет ------------------------------------------------------------------ view.lighting.enabled = view.horrorEnabled; + // Глубина сжимает обзор. Ставится каждый кадр, а не при смене этажа: + // View переживает Reset, и забытое значение уехало бы вниз вместе с + // отрядом и осталось бы там навсегда. + view.lighting.sightScale = game.level.floor.sightScale; view.lighting.Update(squad.agents.data(), NUM_AGENTS, game.map, frameDt); // --- сцена ----------------------------------------------------------------- @@ -110,35 +197,160 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa &view.blood); CollectWalls(view.scene, game.sprites, game.map, view.lighting); + // Спуск и эвакуация. Рисуются ДО всего остального и всегда, даже в полной + // темноте: обе цели этажа обязаны быть видны с порога, иначе игра + // превращается в обшаривание стен. Пульсация — чтобы их не путали с + // очередным пятном на полу, разный цвет — чтобы не путали между собой. + // + // Зелёное — вниз, глубже. Голубое — наверх, к выходу с добычей. Спутать их + // означает уйти не туда, и цена ошибки здесь — весь забег. + if (game.levelGenerated) + { + const float pulse = 0.55f + 0.45f * std::sin(game.simTime * 3.0f); + + auto ring = [&](Vec2 world, uint8_t rr, uint8_t gg, uint8_t bb) { + const Vec2 s = v2d::WorldToScreen(world); + const int sx = int(std::lround(s.x)) + view.scene.ox; + const int sy = int(std::lround(s.y)) + view.scene.oy; + + // Кольцо лежит В ПЛОСКОСТИ ПОЛА, поэтому и форму берёт у плиты: + // в изометрии это ромб, при виде сверху — квадрат. Своя формула + // контура здесь означала бы ромб поверх квадратной плиты — метку, + // висящую в другой проекции, чем пол под ней. + const int insetX = 3; + const int insetY = std::max(1, insetX * g_proj.tileH / g_proj.tileW); + for (int r = 0; r < 3; ++r) + { + const int rw = g_proj.tileW / 2 - r * insetX; + const int rh = g_proj.tileH / 2 - r * insetY; + if (rw <= 1 || rh <= 0) break; + const float k = pulse * (1.0f - float(r) * 0.25f); + const uint32_t c = FXC(uint8_t(float(rr) * k), uint8_t(float(gg) * k), + uint8_t(float(bb) * k)); + + // Контур сжатой копии плиты: слева и справа от середины строки, + // а на верхней и нижней строке — вся строка целиком. Без этого + // у квадрата не было бы горизонтальных сторон: у ромба они + // получаются сами собой из ступенек, у квадрата — нет. + for (int y = 0; y < g_proj.tileH; ++y) + { + int x0 = 0, x1 = 0; + g_proj.TileRow(y, x0, x1); + + const int halfRow = (x1 - x0) / 2; + const int i = (halfRow * rw) / std::max(1, g_proj.tileW / 2); + const int dy = ((y - g_proj.tileH / 2) * rh) / std::max(1, g_proj.tileH / 2); + if (i <= 0) continue; + + if (y == 0 || y == g_proj.tileH - 1) + for (int x = -i; x <= i; ++x) fb.AddPx(sx + x, sy + dy, c); + else + { + fb.AddPx(sx - i, sy + dy, c); + fb.AddPx(sx + i, sy + dy, c); + } + } + } + }; + + ring(game.level.exitPos, 30, 90, 45); // вниз + ring(game.level.extractPos, 40, 80, 110); // наверх + } + + // Лежащие предметы. Свет читается как у тварей, но с меньшим порогом: + // находка в темноте — это тусклый отблеск, который замечаешь, проходя мимо, + // а не то, что видно через полкарты. + for (const FloorItem& it : game.floorItems) + { + const float lit = view.lighting.LightAt(it.pos); + if (lit < g_tune.fogEntityReveal * 0.5f) continue; + view.scene.AddShaded(&game.sprites.Icon(it.id), it.pos, + ShadeQuad::Flat(std::max(lit, 0.22f)), ITEM_LIFT_PX); + } + // Мишени: показываются только там, где светло, либо если по ним уже работает // кто-то из отряда (иначе стрельба в темноту выглядит абсурдом). for (int i = 0; i < int(game.targets.size()); ++i) { const Target& t = game.targets[i]; - if (!t.alive) continue; + if (!t.alive && (!view.forge || t.IsProp())) continue; const float lit = view.lighting.LightAt(t.pos); bool engaged = false; for (int a = 0; a < NUM_AGENTS; ++a) if (squad.agents[a].currentTargetId == i) { engaged = true; break; } - // Тело — по реальному свету: в темноте мишени физически не видно. - if (lit >= g_tune.fogEntityReveal || engaged) - view.scene.AddShaded(&game.sprites.target, t.pos, - ShadeQuad::Flat(std::max(lit, engaged ? 0.30f : 0.0f))); + // Тело — по реальному свету: в темноте твари физически не видно. + if (lit < g_tune.fogEntityReveal && !engaged) continue; + + float level = std::max(lit, engaged ? 0.30f : 0.0f); + // Замах — единственный телеграф удара, и он обязан быть виден ДАЖЕ + // в темноте: иначе смерть бойца выглядит случайной. + if (t.Telegraphing()) level = std::max(level, 0.85f); + else if (t.hurtTimer > 0.0f) level = std::max(level, 0.55f); + + view.scene.AddShaded(EnemyFrame(view, game.sprites, t, i, frameDt), t.pos, + ShadeQuad::Flat(level)); } for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; const Vec2 p = Lerp(a.prevPos, a.pos, alpha); + + if (!a.alive) + { + // Павший лежит там, где упал, и светится как обычная геометрия: + // подсвечивать труп «чтобы было видно» — врать игроку. + view.scene.AddShaded(&game.sprites.corpse[i], p, + ShadeQuad::Flat(view.lighting.LightAt(p))); + continue; + } + // Свои всегда читаемы: игрок обязан различать агентов даже в темноте. - const float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight); + float lit = std::max(view.lighting.LightAt(p), g_tune.agentMinLight); + if (a.hurtTimer > 0.0f) lit = std::max(lit, 0.55f + 0.45f * a.hurtTimer); + // Класс и ствол читаются силуэтом, номер — цветом. - const Sprite& spr = a.IsMelee() ? game.sprites.melee[i] - : (a.gunId == FirearmId::PISTOL) ? game.sprites.pistol[i] - : game.sprites.agent[i]; - view.scene.AddShaded(&spr, p, ShadeQuad::Flat(lit)); + const ShadeQuad shade = ShadeQuad::Flat(lit); + const Sprite* body = nullptr; + if (view.forge) + { + AnimKind wanted = AnimKind::IDLE; + if (a.fired || (a.IsMelee() && a.melee.state != MeleeState::IDLE)) + wanted = AnimKind::ATTACK; + else if (a.Speed() > 0.04f) + wanted = AnimKind::WALK; + Animator& animator = view.agentAnim[i]; + animator.Play(wanted, wanted != AnimKind::ATTACK); + uint32_t bodyId = AgentBodyAsset(wanted); + if (view.forge->FrameCount(bodyId) == 0) bodyId = AGENT_BODY_ASSET; + const uint32_t clipAnim = AnimNameHash(wanted); + const float length = view.forge->AnimationLength(bodyId, clipAnim); + animator.Advance(frameDt, length); + const int dir = view.forge->DirectionOf(bodyId, a.facing); + body = view.forge->Frame(bodyId, clipAnim, dir, animator.time, animator.loop); + if (body) + { + const Inventory* inv = game.world.Get(a.self); + uint32_t armorId = inv ? AgentArmorAsset(inv->armor, wanted) : 0; + uint32_t weaponId = inv ? AgentWeaponAsset(inv->weapon, wanted) : 0; + if (armorId && view.forge->FrameCount(armorId) == 0) + armorId = AgentArmorAsset(inv->armor); + if (weaponId && view.forge->FrameCount(weaponId) == 0) + weaponId = AgentWeaponAsset(inv->weapon); + const Sprite* armor = armorId ? view.forge->Frame(armorId, clipAnim, dir, animator.time, animator.loop) : nullptr; + const Sprite* weapon = weaponId ? view.forge->Frame(weaponId, clipAnim, dir, animator.time, animator.loop) : nullptr; + // Rear-facing weapons pass behind the body; front-facing ones pass in front. + const bool rear = dir >= 3 && dir <= 5; + if (rear && weapon) view.scene.AddShaded(weapon, p, shade, 0, -0.0002f); + view.scene.AddShaded(body, p, shade); + if (armor) view.scene.AddShaded(armor, p, shade, 0, 0.0001f); + if (!rear && weapon) view.scene.AddShaded(weapon, p, shade, 0, 0.0002f); + } + } + if (!body) + view.scene.AddShaded(&game.sprites.AgentBody(i, a.held), p, shade); } view.scene.Flush(fb); @@ -148,17 +360,20 @@ void RenderGame(const Game& game, View& view, Framebuffer& fb, float alpha, floa // игрок сначала видит огонёк, и только подойдя — того, кому он принадлежит. for (const Target& t : game.targets) { - if (!t.alive) continue; + if (!t.alive || t.IsProp()) continue; // у обстановки глаз нет if (view.lighting.LightAt(t.pos) < g_tune.eyeRevealLight) continue; - // Фаза пульсации своя у каждой мишени и учащается при низком HP. + // Фаза пульсации своя у каждой твари и учащается при низком HP. const uint32_t seed = Hash2(uint32_t(int(t.pos.x * 4.0f)), uint32_t(int(t.pos.y * 4.0f))); - const float hpFrac = Clampf(t.hp / std::max(1.0f, g_tune.targetHP), 0.0f, 1.0f); + const float hpFrac = Clampf(t.hp / std::max(1.0f, t.maxHp), 0.0f, 1.0f); const float rate = Lerpf(3.4f, 1.1f, hpFrac); const float ph = Hash01(seed) + float(view.post.frame) * (rate / 60.0f); - const float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F); + float pulse = 0.55f + 0.45f * std::sin(ph * TWO_PI_F); + // На замахе глаза вспыхивают ровно: это тот же телеграф, что и тело, + // но его видно раньше — сначала загорается взгляд, потом виден удар. + if (t.Telegraphing()) pulse = 1.6f; - const Vec2 sp = iso::WorldToScreen(t.pos); + const Vec2 sp = v2d::WorldToScreen(t.pos); const int ex = int(std::lround(sp.x)) + view.scene.ox; const int ey = int(std::lround(sp.y)) + view.scene.oy - int(g_tune.eyeLiftPx); fb.AddGlowDisc(ex, ey, int(g_tune.eyeRadiusPx) + 1, diff --git a/src/render/view.h b/src/render/view.h index d0241d9..25a20c2 100644 --- a/src/render/view.h +++ b/src/render/view.h @@ -8,20 +8,22 @@ // не могут изменить состояние симуляции. Headless-прогоны (--accept, --headless) // View не создают вообще, поэтому цифры приёмки остаются цифрами симуляции. // ----------------------------------------------------------------------------- +#include "render/anim.h" #include "render/atmos.h" #include "render/blood.h" -#include "render/framebuffer.h" +#include "engine/framebuffer.h" #include "render/fx.h" -#include "render/iso.h" +#include "engine/view2d.h" #include "render/lighting.h" #include "render/post.h" #include "render/scene.h" struct Game; +class SpriteLibrary; struct View { - iso::Camera cam; + v2d::Camera cam; Scene scene; Lighting lighting; FxSystem fx; @@ -37,9 +39,30 @@ struct View int prevFriendly = 0; int prevMeleeHits = 0; + // Здоровье отряда прошлого кадра: урон приходит от тварей россыпью, флага + // «ударили» ни у кого нет, а дельта суммы честно ловит любой источник. + float prevSquadHp = -1.0f; + int prevSquadAlive = NUM_AGENTS; + bool horrorEnabled = true; // F5 — аварийный возврат к прежнему кадру bool showSight = false; // F6 — показать диски обзора + // --- анимация тварей ------------------------------------------------------- + // Библиотека спрайтов, если игру запустили с собранными ассетами. Пусто — + // рисуем процедурные спрайты, и это рабочий режим по умолчанию, а не отказ. + const SpriteLibrary* forge = nullptr; + + // Состояние проигрывателя на КАЖДУЮ цель. Живёт в View, а не в Target, + // ровно по правилу этого файла: фаза шага — свойство картинки, и попав в + // симуляцию, она поехала бы в цифрах приёмки. + Animator enemyAnim[MAX_TARGETS]; + Animator agentAnim[NUM_AGENTS]; + // Где тварь была в прошлом кадре: шаг проигрывается, только когда она идёт. + // Скорости у Target нет (vel — это отдача от удара), поэтому движение + // определяется единственным честным способом — сравнением позиций. + Vec2 enemyPrevPos[MAX_TARGETS]; + bool enemyPosKnown[MAX_TARGETS] = {}; + // Гамма и усиление меняют САМУ таблицу рампы: без перепечатки ручки молчат. float rampGamma = -1.0f; float rampGain = -1.0f; diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 4e978dd..7f57b9b 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -19,7 +19,7 @@ Vec2 SeparationForce(const Agent& a, const Agent* all, int count, float maxSpeed for (int i = 0; i < count; ++i) { const Agent& other = all[i]; - if (&other == &a) continue; + if (&other == &a || !other.alive) continue; const Vec2 delta = a.pos - other.pos; const float d = Length(delta); @@ -133,28 +133,108 @@ void AimAlongMotion(Agent& a, float dt) a.facing = RotateToward(a.facing, AngleOf(a.vel), g_tune.turnRateDeg * DEG2RAD_F * dt); } -ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId) +namespace { - const ecs::Entity e = world.Create(); + +// Заполнить компонент оружия по предмету. Клинок и ствол лежат в одной записи: +// какой из них рабочий, решает cls. +WeaponItem MakeWeaponItem(ItemId id) +{ + const ItemDef def = ItemDefOf(id); WeaponItem item; - item.cls = cls; - item.gunId = gunId; - item.gun = FirearmDefOf(gunId); - item.blade = DefaultBladeDef(); - world.Add(e, item); + item.cls = (def.kind == ItemKind::BLADE) ? WeaponClass::MELEE : WeaponClass::RANGED; + item.gunId = def.gun; + item.bladeId = def.blade; + item.gun = FirearmDefOf(def.gun); + item.blade = BladeDefOf(def.blade); + return item; +} + +} // namespace + +ecs::Entity SpawnWeapon(ecs::World& world, ItemId id) +{ + const ecs::Entity e = world.Create(); + world.Add(e, MakeWeaponItem(id)); return e; } -void SyncLoadout(ecs::World& world, Agent& a) +bool EquipWeapon(ecs::World& world, Agent& a, ItemId id) +{ + if (!IsWeaponItem(id)) return false; + + WeaponItem* item = world.Get(a.weaponEntity); + if (!item) return false; + *item = MakeWeaponItem(id); + + // Смена оружия обрывает всё незавершённое: недорубленный взмах чужим + // клинком и патроны от другого магазина — это чужое состояние. + a.melee.state = MeleeState::IDLE; + a.melee.phase = 0.0f; + a.melee.cooldown = 0.0f; + a.melee.hitMask = 0; + a.melee.comboStep = 0; + a.melee.comboTimer = 0.0f; + a.gun.reloadTimer = 0.0f; + a.gun.cooldown = 0.0f; + a.gun.recoilDeg = 0.0f; + a.gun.ammo = item->gun.magazine; + + SyncLoadout(world, a); + return true; +} + +bool DamageAgent(Agent& a, float amount) +{ + if (!a.alive || amount <= 0.0f) return false; + + a.hp -= amount * (1.0f - Clampf(a.armor, 0.0f, ARMOR_CAP)); + a.hurtTimer = 0.35f; + if (a.hp > 0.0f) return false; + + a.hp = 0.0f; + a.alive = false; + a.vel = {}; + a.currentTargetId = -1; + a.hasLane = false; + a.laneIntent = false; + a.melee.state = MeleeState::IDLE; + a.melee.targetId = -1; + return true; +} + +void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& buffs) { a.bodyRadius = g_tune.bodyRadius; + // Снаряжение: четыре числа и ни одной ручки к правилам боя (sim/items.h). + const Inventory* inv = world.Get(a.self); + if (inv) + { + a.armor = inv->Armor(); + // Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе + // скорости: движению всё равно, почему боец медленный. Разделять их + // пришлось бы везде, где скорость читается, а смысла в этом ноль. + a.load = inv->Load() + bagShare; + a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry); + a.sightMul = inv->SightMul() + buffs.sight; + a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp; + a.held = inv->weapon; + if (a.hp > a.maxHp) a.hp = a.maxHp; + } + else + { + a.maxHp = g_tune.agentHP + buffs.hp; + a.load = bagShare; + } + const WeaponItem* item = world.Get(a.weaponEntity); if (!item) return; // оружия нет — бойцу нечем ни стрелять, ни бить a.cls = item->cls; a.gunId = item->gunId; + a.bladeId = item->bladeId; // Ствол: статы обновляются целиком, состояние (патроны, откат, увод) — нет. // Магазин мог смениться вместе с оружием, поэтому патроны подрезаем. diff --git a/src/sim/agent.h b/src/sim/agent.h index 01f3d71..511e6bd 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -11,12 +11,13 @@ // ----------------------------------------------------------------------------- #include -#include "core/math.h" +#include "engine/math.h" #include "ecs/ecs.h" +#include "sim/items.h" #include "sim/loadout.h" #include "sim/target.h" #include "tuning.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" enum class AgentState : uint8_t { @@ -44,9 +45,49 @@ struct Agent WeaponClass cls = WeaponClass::RANGED; FirearmId gunId = FirearmId::RIFLE; + BladeId bladeId = BladeId::SABER; + // Что именно в руках. Рендеру нужен силуэт, HUD — имя; выводить их из + // cls+gunId+bladeId в двух местах значило бы держать правило в двух местах. + ItemId held = ItemId::RIFLE; Firearm gun; // работает только у RANGED MeleeWeapon melee; // работает только у MELEE + // --- живучесть ------------------------------------------------------------ + // Мёртвый боец остаётся в массиве: NUM_AGENTS — величина времени сборки, + // и по ней ходят все циклы, слоты строя, метрики и оверлей. Убирать его + // из пула значило бы переиндексировать полмира на каждой смерти. Поэтому + // смерть — это флаг, а не удаление; все системы обязаны его проверять. + bool alive = true; + float hp = 0.0f; + float maxHp = 0.0f; + float hurtTimer = 0.0f; // недавно получил урон: для рендера и HUD + + // Бойца держит тварь (EnemyKind::LURKER). Не флаг, а таймер, и держащий + // продлевает его каждый шаг: убили тварь — таймер гаснет сам, и никому, + // кроме sim/enemy.cpp, про захват знать не надо. + // + // Захват останавливает НОГИ и только их. Стрелять и рубить боец продолжает: + // правила огня (6.3) и сектор клинка захват не читают вообще — иначе + // единственным выходом из хватки был бы чужой выстрел. + float grabbedTimer = 0.0f; + + // Итог снаряжения, пересчитывается в SyncLoadout. Правила боя эти числа + // не читают — только движение, урон и обзор. + float armor = 0.0f; // доля поглощённого урона, 0..ARMOR_CAP + float speedMul = 1.0f; + // Патроны СВЕРХ магазина. Перезарядка тянет отсюда, и это главный ресурс + // вылазки: чем глубже отряд, тем ближе тот момент, когда стрелять станет + // нечем, — давит не время, а исчерпание. + // + // -1 означает «бесконечно» и стоит в ПЕСОЧНИЦЕ приёмки: там на бойцах нет + // снаряжения вообще, а критерии меряют огневую работу за минуту, и запас + // патронов сделал бы их проверкой запаса, а не правил стрельбы. + int ammoReserve = -1; + // Сколько боец несёт. Считается из инвентаря каждый шаг (SyncLoadout) и + // лежит здесь, чтобы HUD и экран отряда не лезли в ECS за компонентом. + float load = 0.0f; + float sightMul = 1.0f; + // --- служебное (рендер/отладка/метрики), на правила не влияет --- Vec2 prevPos{}; // для интерполяции кадра bool hasLane = false; // есть валидная линия огня прямо сейчас @@ -59,6 +100,8 @@ struct Agent float Speed() const { return Length(vel); } + bool Grabbed() const { return grabbedTimer > 0.0f; } + // Полураствор конуса выстрела прямо сейчас. Стоя он почти нулевой, на ходу // растёт. Ровно этот конус проверяется на своих перед спуском. float SpreadRad() const { return gun.SpreadRad(Speed()); } @@ -110,10 +153,31 @@ void IntegrateAgent(Agent& a, Vec2 desiredVel, float maxSpeed, float dt, const T void AimAt(Agent& a, Vec2 point, float dt); void AimAlongMotion(Agent& a, float dt); -// Система: скопировать статы с сущности-оружия и общие размеры из tuning. -// Рабочее состояние (патроны, фаза удара, откат, увод ствола) НЕ трогается. -void SyncLoadout(ecs::World& world, Agent& a); +// Система: скопировать статы с сущности-оружия и снаряжения, общие размеры из +// tuning. Рабочее состояние (патроны, фаза удара, откат, увод) НЕ трогается. +// bagShare — доля общей сумки отряда, приходящаяся на этого бойца. Инвентарь +// про отряд не знает, поэтому долю считает вызывающий (Game::Step). +// Прибавки отряда (улучшения профиля) приходят снаружи по той же причине, что +// и доля сумки: агент про профиль не знает. +struct SquadBuffs +{ + float sight = 0.0f; // прибавка к множителю обзора + float hp = 0.0f; // прибавка к запасу здоровья + float carry = 0.0f; // прибавка к норме переноски +}; -// Создать сущность оружия нужного класса и вернуть её. Стволы и клинки живут -// в одном пуле WeaponItem: подменить одно другим — это одна запись компонента. -ecs::Entity SpawnWeapon(ecs::World& world, WeaponClass cls, FirearmId gunId); +void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f, + const SquadBuffs& buffs = SquadBuffs{}); + +// Создать сущность оружия и вернуть её. Стволы и клинки живут в одном пуле +// WeaponItem: подменить одно другим — это одна запись компонента. +ecs::Entity SpawnWeapon(ecs::World& world, ItemId item); + +// Взять предмет в руки: меняет и сущность-оружие, и класс бойца. Не оружие — +// отказ. Возвращает false, если ничего не изменилось. +bool EquipWeapon(ecs::World& world, Agent& a, ItemId item); + +// Урон бойцу с учётом брони. Возвращает true, если этот удар его добил. +// Единственное место, где агент теряет здоровье: броня применяется здесь, +// иначе половина источников урона про неё забудет. +bool DamageAgent(Agent& a, float amount); diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 4f746a5..3a95780 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -29,6 +29,30 @@ void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, world.Add(b.self, b); } +void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, float damage, + float range) +{ + std::vector& bullets = world.All(); + if (int(bullets.size()) >= MAX_BULLETS) return; + + Bullet b; + b.self = world.Create(); + b.pos = from; + b.prevPos = from; + b.origin = from; + b.vel = Normalized(dir) * speed; + b.damage = damage; + b.knockback = 0.0f; + b.radius = 0.10f; // плевок крупнее пули: его должно быть видно + b.life = range / std::max(0.001f, speed); + b.owner = -1; + b.hostile = true; + b.tracerLength = 0.45f; + b.tracerR = 150; b.tracerG = 230; b.tracerB = 120; + + world.Add(b.self, b); +} + void UpdateBullets(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, std::vector& targets, float dt, Metrics& metrics, @@ -58,11 +82,12 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, float tWall = 0.0f; if (map.RaycastBlocked(b.prevPos, b.pos, &tWall)) tBest = tWall; - if (g_tune.friendlyFire) + if (g_tune.friendlyFire || b.hostile) { for (int i = 0; i < agentCount; ++i) { - if (i == b.owner) continue; // своя пуля стрелка не бьёт + if (!agents[i].alive) continue; // упавший больше не тело + if (!b.hostile && i == b.owner) continue; // своя пуля стрелка не бьёт float t = 0.0f; const Vec2 cp = ClosestPointOnSegment(agents[i].pos, b.prevPos, b.pos, &t); if (Distance(cp, agents[i].pos) > agents[i].bodyRadius + br) continue; @@ -75,7 +100,9 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, } } - for (int i = 0; i < int(targets.size()); ++i) + // Плевок твари по своим не бьёт: у противника нет своих, и мишени + // для него — не цели. + for (int i = 0; !b.hostile && i < int(targets.size()); ++i) { if (!targets[i].alive) continue; float t = 0.0f; @@ -96,24 +123,45 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, if (hitAgent >= 0) { - metrics.LogFriendlyHit(b.owner, hitAgent, agents[hitAgent].pos, - agents[hitAgent].state == AgentState::ADVANCING); - if (events) events->Push(CombatEventKind::HIT_ALLY, b.owner, impact, dir); + if (b.hostile) + { + // Урон от твари — не нарушение 6.9 и в FRIENDLY_HITS не идёт. + const bool down = DamageAgent(agents[hitAgent], b.damage); + if (events) + { + events->Push(CombatEventKind::ENEMY_HIT, hitAgent, impact, dir); + if (down) events->Push(CombatEventKind::AGENT_DOWN, hitAgent, + agents[hitAgent].pos, dir); + } + } + else + { + metrics.LogFriendlyHit(b.owner, hitAgent, agents[hitAgent].pos, + agents[hitAgent].state == AgentState::ADVANCING); + if (events) events->Push(CombatEventKind::HIT_ALLY, b.owner, impact, dir); + } dead[deadCount++] = b.self; continue; } if (hitTarget >= 0) { Target& t = targets[hitTarget]; + const bool prop = t.IsProp(); t.hp -= b.damage; + t.hurtTimer = g_tune.targetHurtTime; KnockTarget(t, dir, b.knockback); - if (events) events->Push(CombatEventKind::HIT_TARGET, b.owner, impact, dir); + if (events) + events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET, + b.owner, impact, dir); if (t.hp <= 0.0f) { t.alive = false; - t.respawnTimer = g_tune.targetRespawn; - ++metrics.targetsKilled; - if (events) events->Push(CombatEventKind::HIT_KILL, b.owner, t.pos, dir); + t.respawnTimer = t.RespawnDelay(); + // Разбитый ящик — не убитый враг: ни в KILLS, ни в напряжение. + if (!prop) ++metrics.targetsKilled; + if (events) + events->Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL, + b.owner, t.pos, dir); } dead[deadCount++] = b.self; continue; diff --git a/src/sim/bullet.h b/src/sim/bullet.h index 83bf119..4b183e4 100644 --- a/src/sim/bullet.h +++ b/src/sim/bullet.h @@ -9,12 +9,12 @@ // ----------------------------------------------------------------------------- #include -#include "core/math.h" +#include "engine/math.h" #include "ecs/ecs.h" #include "sim/agent.h" #include "sim/events.h" #include "sim/target.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" struct Metrics; @@ -32,6 +32,11 @@ struct Bullet float life = 0.0f; // сек до самоуничтожения (дальность / скорость) int owner = -1; + // Чей снаряд. Пуля отряда ищет мишени (и своих — правило 6.9), плевок твари + // ищет ТОЛЬКО бойцов. Один флаг вместо двух списков: иначе пришлось бы + // дублировать весь проход по отрезку. + bool hostile = false; + // Визуал трассера едет вместе со снарядом: он у каждого ствола свой, // а к моменту отрисовки стрелок мог уже сменить оружие или умереть. float tracerLength = 1.3f; @@ -47,6 +52,11 @@ struct Bullet void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, int owner); +// Плевок твари. Летит до своей дальности и бьёт первого встречного бойца. +// Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих. +void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, float damage, + float range); + // events == nullptr в headless-прогонах: эффектов там нет, а правила журнал не читают. void UpdateBullets(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, diff --git a/src/sim/enemy.cpp b/src/sim/enemy.cpp new file mode 100644 index 0000000..097520e --- /dev/null +++ b/src/sim/enemy.cpp @@ -0,0 +1,295 @@ +#include "sim/enemy.h" + +#include "sim/bullet.h" + +namespace +{ + +// Ближайший ЖИВОЙ боец. Упавшего тварь не добивает: иначе отряд, потерявший +// одного, теряет всех подряд, пока игрок ничего не может сделать. +int NearestAgent(Vec2 from, const Agent* agents, int count, float maxDist, float* outDist) +{ + int best = -1; + float bestD = maxDist; + for (int i = 0; i < count; ++i) + { + if (!agents[i].alive) continue; + const float d = Distance(from, agents[i].pos); + if (d < bestD) + { + bestD = d; + best = i; + } + } + if (outDist) *outDist = bestD; + return best; +} + +// Расталкивание тварей между собой. Без него толпа сливается в одну точку и +// вместо десяти тел отряд видит одно — а бьют его при этом десятью ударами. +Vec2 CrowdPush(const Target& self, int selfIndex, const std::vector& all) +{ + Vec2 push{}; + for (int i = 0; i < int(all.size()); ++i) + { + if (i == selfIndex) continue; + const Target& o = all[i]; + if (!o.alive || o.IsDummy() || o.IsProp()) continue; + + const Vec2 d = self.pos - o.pos; + const float gap = self.radius + o.radius; + const float len = Length(d); + if (len >= gap || len < 1e-4f) continue; + push += (d / len) * ((gap - len) / gap); + } + return push; +} + +// Зазор, с которым плевок вылетает из пасти, а не из центра тела: стартуй +// снаряд внутри собственной капсулы — и он бы упирался в неё же. Это геометрия +// спавна, а не баланс, поэтому число живёт здесь, а не в каталоге видов. +constexpr float kSpitMuzzleGap = 0.12f; + +// Урон бойцу от твари — в одном месте на все пять видов атак: множитель +// сложности, событие попадания и падение. Разложенное по веткам, это правило +// первым же дополнением разъехалось бы на пять слегка разных версий. +void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events) +{ + const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale); + if (!events) return; + events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir); + if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir); +} + +} // namespace + +void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, + std::vector& targets, float dt, CombatEventLog* events) +{ + // Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение + // бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили + // хвата — продлевать стало некому, и через enemyGrabHold боец свободен. + // В песочнице приёмки этот цикл ничего не делает: там никого не хватают. + for (int i = 0; i < agentCount; ++i) + agents[i].grabbedTimer = std::max(0.0f, agents[i].grabbedTimer - dt); + + for (int ti = 0; ti < int(targets.size()); ++ti) + { + Target& t = targets[ti]; + // Мишень приёмки не шевелится по определению, обстановка — тем более: + // у ящика нет ни скорости, ни агро, ни удара. Отсекаем их здесь, а не + // полагаемся на нули в каталоге. + if (t.IsDummy() || t.IsProp() || !t.alive) continue; + + t.attackTimer = std::max(0.0f, t.attackTimer - dt); + + // --- захват уже держит (GRAB) ----------------------------------------- + // Пока держит — не ходит, не выбирает цель заново и не замахивается. + // Всё, что у неё есть, уже вцепилось в бойца. + if (t.grabbed >= 0) + { + Agent& held = agents[t.grabbed]; + t.grabTimer -= dt; + t.facing = AngleOf(Normalized(held.pos - t.pos, FromAngle(t.facing))); + + // Время вышло или боец упал: упавшего твари не добивают, держать + // его тем более незачем. + if (!held.alive || t.grabTimer <= 0.0f) + { + if (events) events->Push(CombatEventKind::ENEMY_RELEASE, t.grabbed, held.pos, + FromAngle(t.facing)); + t.grabbed = -1; + t.grabTimer = 0.0f; + t.attackTimer = t.interval; + continue; + } + + held.grabbedTimer = std::max(held.grabbedTimer, g_tune.enemyGrabHold); + + t.grabTickTimer -= dt; + if (t.grabTickTimer <= 0.0f) + { + t.grabTickTimer += std::max(0.05f, t.grabTick); + HurtAgent(held, t.grabbed, t.damage, FromAngle(t.facing), events); + } + continue; + } + + // --- рывок в полёте (LUNGE) ------------------------------------------- + // Именно движение по времени, а не телепорт: бросок обязан быть виден, + // иначе непонятно, чем именно снесло бойца. + if (t.lungeTimer > 0.0f) + { + t.lungeTimer -= dt; + + const Vec2 from = t.pos; + const float speed = t.lungeDist / std::max(0.02f, t.lungeTime); + t.pos += t.lungeDir * (speed * dt); + map.ResolveCircle(t.pos, t.radius); // сквозь стены не рвётся никто + + // Урон всем, кого зацепило ПО ПУТИ, а не одной жертве: рывок — + // это отрезок, и стоять на нём не должно быть безопасно никому. + // Маска считается по индексам бойцов, поэтому и ограничена 32. + for (int i = 0; i < agentCount && i < 32; ++i) + { + if (!agents[i].alive) continue; + if (t.lungeMask & (1u << i)) continue; // каждого — один раз за рывок + if (DistancePointToSegment(agents[i].pos, from, t.pos) > + t.radius + agents[i].bodyRadius) + continue; + t.lungeMask |= (1u << i); + HurtAgent(agents[i], i, t.damage, t.lungeDir, events); + } + + if (t.lungeTimer <= 0.0f) + { + t.lungeTimer = 0.0f; + t.attackTimer = t.interval; // откат считается от КОНЦА рывка + } + continue; + } + + // --- кого преследуем -------------------------------------------------- + float dist = 0.0f; + const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius, &dist); + if (victim >= 0) t.aggro = true; // заметил — уже не забудет + t.victim = victim; + + if (victim < 0) + { + // Никого в радиусе: замах в пустоту не доводится. + t.windupTimer = 0.0f; + continue; + } + + const Vec2 toVictim = agents[victim].pos - t.pos; + t.facing = AngleOf(Normalized(toVictim, FromAngle(t.facing))); + + // --- замах доведён: бьём ---------------------------------------------- + // Ось удара НЕ доводится: замахнулся — бьёт туда, куда замахнулся. + // Ровно это даёт игроку возможность увести бойца из-под удара. + if (t.Telegraphing()) + { + t.windupTimer -= dt; + if (t.windupTimer > 0.0f) continue; // на замахе тварь стоит + + t.windupTimer = 0.0f; + const Vec2 axis = FromAngle(t.facing); + + switch (t.attack) + { + // Плевок: единственная атака, которую перекрывает стена, — + // снаряд летит и во что-нибудь упирается. + case AttackKind::SPIT: + SpawnHostileBullet(world, t.pos + axis * (t.radius + kSpitMuzzleGap), axis, + t.projectileSpeed, t.damage * g_tune.enemyDamageScale, + std::max(t.spitRange, t.attackRange)); + if (events) events->Push(CombatEventKind::ENEMY_SPIT, ti, t.pos, axis); + t.attackTimer = t.interval; + break; + + // Рывок: с этого шага тварь едет по прямой и откат ей встанет + // только в конце броска. + case AttackKind::LUNGE: + t.lungeDir = axis; + t.lungeTimer = t.lungeTime; + t.lungeMask = 0; + if (events) events->Push(CombatEventKind::ENEMY_LUNGE, ti, t.pos, axis); + break; + + // Удар оземь: КРУГ вокруг себя. Под него попадает и тот, кто + // просто стоял рядом с жертвой, — разбирать громилу вдвоём + // теперь стоит здоровья обоим. + case AttackKind::SLAM: + { + if (events) events->Push(CombatEventKind::ENEMY_SLAM, ti, t.pos, axis); + for (int i = 0; i < agentCount; ++i) + { + if (!agents[i].alive) continue; + const Vec2 d = agents[i].pos - t.pos; + if (Length(d) > t.slamRadius + agents[i].bodyRadius) continue; + HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events); + } + t.attackTimer = t.interval; + break; + } + + // Захват: промахнуться им можно — ушёл из вытянутых лап за время + // замаха, и хват остался ни с чем на целый интервал. + case AttackKind::GRAB: + { + if (dist <= t.attackRange + agents[victim].bodyRadius) + { + t.grabbed = victim; + t.grabTimer = t.grabTime; + t.grabTickTimer = 0.0f; // первый тик сразу: хватку видно + agents[victim].grabbedTimer = + std::max(agents[victim].grabbedTimer, g_tune.enemyGrabHold); + if (events) events->Push(CombatEventKind::ENEMY_GRAB, victim, + agents[victim].pos, axis); + } + else + { + t.attackTimer = t.interval; + } + break; + } + + // Взмах: сектор перед собой. Достаёт всех, кто в него влез, — + // от такого удара уходят вбок, а не назад. + case AttackKind::SWIPE: + default: + { + const float half = t.swipeArcDeg * 0.5f * DEG2RAD_F; + for (int i = 0; i < agentCount; ++i) + { + if (!agents[i].alive) continue; + const Vec2 d = agents[i].pos - t.pos; + if (Length(d) > t.attackRange + agents[i].bodyRadius) continue; + if (std::fabs(AngleDiff(t.facing, AngleOf(d))) > half) continue; + HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events); + } + t.attackTimer = t.interval; + break; + } + } + continue; + } + + // --- начать замах ----------------------------------------------------- + // Телеграф обязателен для КАЖДОЙ атаки, но дистанция начала у них разная: + // рывок затевается ИЗДАЛЕКА (он для того и нужен, чтобы закрыть разрыв), + // остальные — по своей досягаемости. + const float reach = t.attackRange + agents[victim].bodyRadius; + const float trigger = (t.attack == AttackKind::LUNGE) ? std::max(reach, t.lungeDist) + : reach; + if (dist <= trigger && t.attackTimer <= 0.0f) + { + t.windupTimer = t.windup * std::max(0.05f, g_tune.enemyWindupScale); + if (events) events->Push(CombatEventKind::ENEMY_WINDUP, ti, t.pos, + FromAngle(t.facing)); + continue; + } + + // --- ход -------------------------------------------------------------- + // Стрелок держит дистанцию: подходить вплотную ему незачем, а отряду + // нужен противник, до которого ещё надо дойти. + Vec2 dir{}; + if (t.ranged && dist < t.attackRange * 0.55f) dir = Normalized(-toVictim); + else if (dist > reach * 0.9f) dir = Normalized(toVictim); + + Vec2 move = dir * t.speed; + move += CrowdPush(t, ti, targets) * t.speed; + + t.pos += move * dt; + map.ResolveCircle(t.pos, t.radius); + } +} + +int CountAggroEnemies(const std::vector& targets) +{ + int n = 0; + for (const Target& t : targets) + if (t.alive && !t.IsDummy() && t.victim >= 0) ++n; + return n; +} diff --git a/src/sim/enemy.h b/src/sim/enemy.h new file mode 100644 index 0000000..f89a046 --- /dev/null +++ b/src/sim/enemy.h @@ -0,0 +1,39 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Поведение тварей. Отдельный файл, потому что здесь единственное место, где +// противник ЧИТАЕТ бойцов: sim/target.h про Agent не знает и знать не должен. +// +// Правила простые и намеренно читаемые с экрана: +// 1. заметил — идёт; +// 2. дошёл — замахивается, и замах ВИДНО (windupTimer); +// 3. довёл замах — бьёт по геометрии СВОЕЙ атаки (AttackKind). +// Между 2 и 3 у игрока есть время отойти или убить. Без телеграфа удар твари +// неотличим от случайной потери здоровья, и бой перестаёт быть боем. +// +// Пункт 3 — это пять разных задач для игрока, а не один урон: +// SWIPE — сектор перед тварью: уводи бойца вбок; +// LUNGE — рывок по прямой: освободи ОСЬ, а не точку; +// SPIT — снаряд: единственное, что перекрывается стеной; +// SLAM — круг вокруг твари: не толпитесь возле неё вдвоём; +// GRAB — захват: боец встал намертво, вытаскивать его придётся огнём. +// Числа этих форм — в EnemyDef (sim/target.cpp), общие множители — в tuning.h. +// +// ИИ не решает, кого атаковать, «по-умному»: ближайший живой боец в радиусе. +// Прототип проверяет отряд, а не противника, — и цена ошибки здесь должна быть +// в геометрии, а не в чужой хитрости. +// ----------------------------------------------------------------------------- +#include + +#include "ecs/ecs.h" +#include "sim/agent.h" +#include "sim/events.h" +#include "sim/target.h" +#include "engine/tilemap.h" + +// Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще: +// --accept обязан считать ровно то же, что считал. +void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, + std::vector& targets, float dt, CombatEventLog* events); + +// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD. +int CountAggroEnemies(const std::vector& targets); diff --git a/src/sim/events.h b/src/sim/events.h index 30be39b..6417f21 100644 --- a/src/sim/events.h +++ b/src/sim/events.h @@ -12,7 +12,7 @@ // ----------------------------------------------------------------------------- #include -#include "core/math.h" +#include "engine/math.h" enum class CombatEventKind : uint8_t { @@ -27,6 +27,38 @@ enum class CombatEventKind : uint8_t MELEE_ABORT, // взмах оборван: в дугу влез свой RELOAD_START, // магазин пуст, боец пошёл перезаряжаться RELOAD_DONE, // магазин снаряжён + + // --- разрушаемая обстановка ----------------------------------------------- + // Отдельно от HIT_TARGET/HIT_KILL по одной причине: ящик не кровоточит. + // Слой крови (render/blood.cpp) знает только позицию события, поэтому + // отличить попадание в бочку от попадания в тварь можно ТОЛЬКО видом + // события. Заодно разбитый хлам не идёт в счётчик убитых и в напряжение. + HIT_PROP, // пуля/клинок в обстановку: щепки, не кровь + PROP_BREAK, // объект развалился + + // --- события противника --------------------------------------------------- + // Отдельные виды, а не HIT_ALLY: попадание по своему — это нарушение + // критерия приёмки, и смешивать его с уроном от твари нельзя ни в метриках, + // ни в эффектах. + // + // ПОЛЕ owner здесь двузначно, и это осознанно: у события «тварь что-то + // делает» интересен индекс ТВАРИ (эффект рисуется на ней), у события + // «бойцу прилетело» — индекс БОЙЦА (эффект рисуется на нём). Ниже указано + // для каждого вида отдельно; путать их нельзя — индексы из разных массивов. + ENEMY_WINDUP, // замах начался. owner = ТВАРЬ, pos = тварь, dir = ось атаки + ENEMY_HIT, // тварь достала бойца. owner = БОЕЦ, pos = точка удара + ENEMY_SPIT, // плевок ушёл. owner = ТВАРЬ, pos = пасть, dir = направление + ENEMY_LUNGE, // рывок пошёл. owner = ТВАРЬ, pos = откуда, dir = ось рывка + ENEMY_SLAM, // удар оземь. owner = ТВАРЬ, pos = центр круга поражения + ENEMY_GRAB, // захват состоялся. owner = БОЕЦ, pos = боец, dir = от твари к нему + ENEMY_RELEASE, // захват кончился. owner = БОЕЦ, pos = боец + AGENT_DOWN, // боец упал. owner = БОЕЦ + + // --- спуск ------------------------------------------------------------------ + LOOT_TAKEN, // боец подобрал предмет. owner = БОЕЦ, pos = где лежало + ITEM_USED, // боец потратил расходник: бинт, стим, пачку патронов + DESCEND, // отряд дошёл до спуска. pos = точка спуска + EXTRACT, // отряд дошёл до эвакуации. pos = лестница наверх }; struct CombatEvent diff --git a/src/sim/items.h b/src/sim/items.h new file mode 100644 index 0000000..2a01b54 --- /dev/null +++ b/src/sim/items.h @@ -0,0 +1,370 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Предметы и снаряжение: ЧТО боец несёт на себе. +// +// Разделение то же, что у оружия (sim/weapon_defs.h): +// * ItemDef — неизменяемое описание предмета, одна запись каталога; +// * Inventory — компонент бойца: что в руках, что надето, что в сумке. +// +// Снаряжение НЕ трогает правила боя. Оно даёт бойцу четыре числа — +// броня, скорость, запас здоровья, дальность обзора, — и всё. Никакой предмет +// не может ослабить правило «не бить своих»: у него просто нет такой ручки. +// Это не самоограничение, а следствие устройства: FireWeapons и UpdateMelee +// про Inventory не знают вообще, они читают уже готовые статы агента. +// +// Оружие живёт двумя сущностями: ItemId в инвентаре — это ЧТО взято в руки, +// а сущность-оружие ECS хранит рабочие статы. SyncLoadout переносит одно +// в другое каждый шаг, поэтому «поменять ствол» — это одна запись ItemId. +// ----------------------------------------------------------------------------- +#include + +#include "sim/weapon_defs.h" + +// Сколько патронов в найденной пачке. Не «магазин»: магазины у стволов разные, +// а пачка — это просто запас, из которого магазин потом набивается. +constexpr int AMMO_BOX_ROUNDS = 40; + +enum class ItemKind : uint8_t +{ + NONE = 0, + FIREARM, // в руки, класс RANGED + BLADE, // в руки, класс MELEE + ARMOR, // на корпус + TRINKET, // в карман: свет, чутьё, живучесть + CONSUMABLE, // расходник +}; + +enum class ItemId : uint8_t +{ + NONE = 0, + + // --- стволы --- + RIFLE, PISTOL, SHOTGUN, SMG, MAGNUM, BOW, + // --- клинки --- + SABER, KNIFE, CLEAVER, + // --- броня --- + JACKET, // куртка: почти ничего, зато не мешает + VEST, // жилет: ровный размен брони на скорость + PLATE, // плита: держит удар, но боец становится обозом + SHROUD, // саван: брони нет, зато тише и быстрее + // --- карман --- + LANTERN, // фонарь: видно дальше, и тебя тоже + CHARM, // оберег: запас здоровья + SPURS, // шпоры: скорость + // --- расходники --- + BANDAGE, + STIMPACK, + AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем + + COUNT, +}; + +struct ItemDef +{ + const char* name = "-"; + ItemKind kind = ItemKind::NONE; + + // Что взять в руки, если kind — оружие. + FirearmId gun = FirearmId::RIFLE; + BladeId blade = BladeId::SABER; + + // Модификаторы бойца. Складываются по всем надетым предметам. + float armor = 0.0f; // доля поглощённого урона, суммарно жёстко режется + float speedMul = 1.0f; // множитель скорости хода + float hpBonus = 0.0f; + float sightMul = 1.0f; // множитель радиуса обзора (render-only) + + float heal = 0.0f; // расходник: сколько здоровья возвращает + int rounds = 0; // расходник: сколько патронов добавляет в запас + + // Сколько предмет весит. Единица условная, но соотношения — нет: плита + // тяжелее всего, что можно взять в руки, а карманное почти ничего не весит. + // + // Вес — это то, чем «унести всё» перестало быть бесплатным. До него решение + // на выходе сводилось к счёту свободных ячеек, а шесть бинтов и шесть плит + // стоили одинаково (docs/17-extraction.md). + // + // По умолчанию НОЛЬ, а не единица: ItemId::NONE — это пустая ячейка и + // «брони нет», и весить пустота не должна. С единицей боец без снаряжения + // таскал бы три килограмма ничего. + float weight = 0.0f; + + // Цвет иконки в инвентаре. Нейтральные байты RGB — в sim/ типов рендера нет. + uint8_t r = 200, g = 200, b = 200; +}; + +// Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет. +constexpr float ARMOR_CAP = 0.72f; + +// ----------------------------------------------------------------------------- +// Переноска. Порогов ДВА, и это главное решение всей вехи. +// +// Один жёсткий предел означал бы «сумка кончилась» — то же самое, что счёт +// ячеек, только считать труднее. Настоящий выбор возникает там, где лишнее +// взять МОЖНО, но за это платят: до нормы отряд идёт как шёл, дальше — тем +// медленнее, чем больше нагрёб. А наверх идти через весь этаж, который уже +// растревожен. +// +// Верхний предел всё-таки есть: без него отряд уносил бы этаж целиком со +// скоростью ноль, и решение снова исчезло бы — только с другой стороны. +// +// Числа выставлены по каталогу лута, а не на глаз. Средняя находка первых +// тиров весит около 1.1, поздних — около 1.7, значит полная сумка из шести +// вещей тянет на 7–10, плюс ствол в руках. +// +// Отсюда пороги. Норма 7.5 — это ствол и три-четыре находки: отряд, который +// взял нужное и ушёл, идёт как шёл. Полная сумка (6.6–10 плюс ствол) неизбежно +// оказывается за нормой — так и задумано: «унести всё» обязано стоить скорости. +// Предел 14 оставляет место найденной плите (6), но с ней в сумке больше почти +// ничего не влезет — и это ровно тот выбор, ради которого вес заводился. +// +// Норму 6 пришлось поднять: прогон показал, что «карабин, сабля, фонарь и +// жилет» весят 6.9, то есть под неё не проходил даже обычный набор. Число +// противоречило собственному замыслу, и заметила это проверка, а не глаз. +// +// Сверяться с прогоном тут ОБЯЗАТЕЛЬНО, но не по столбцу «груз»: `--descend` +// кладёт в сумки бинты, чтобы проверить перенос между этажами, и по ним вес +// выглядит втрое меньше настоящего. Правило проверяется отдельно — там же, +// в прогоне, куском на выдуманном инвентаре. +// ----------------------------------------------------------------------------- +constexpr float CARRY_LIMIT = 7.5f; // до этого веса скорость не страдает +constexpr float CARRY_MAX = 14.0f; // выше не поднимет: руки заняты +constexpr float CARRY_SLOW = 0.55f; // во сколько раз медленнее на пределе + +// Множитель скорости от нагрузки. Между нормой и пределом падает линейно: +// нелинейная кривая тут была бы честнее физически и хуже играбельно — игрок +// должен чувствовать «каждая лишняя вещь стоит столько-то». +// bonus — прибавка к норме от улучшений профиля (sim/profile.h). Предел +// двигается вместе с нормой: улучшение обязано увеличивать И то, сколько можно +// нести свободно, И то, сколько вообще можно поднять, — иначе прокачка +// переноски упиралась бы в невидимую стену. +inline float CarryMul(float load, float bonus = 0.0f) +{ + const float limit = CARRY_LIMIT + bonus; + const float top = CARRY_MAX + bonus; + if (load <= limit) return 1.0f; + if (load >= top) return CARRY_SLOW; + return 1.0f + (CARRY_SLOW - 1.0f) * (load - limit) / (top - limit); +} + +inline ItemDef ItemDefOf(ItemId id) +{ + ItemDef d; + switch (id) + { + // --- стволы --- + case ItemId::RIFLE: + d.name = "CARBINE"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::RIFLE; + d.weight = 2.2f; + d.r = 208; d.g = 196; d.b = 150; break; + case ItemId::PISTOL: + d.name = "PISTOL"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::PISTOL; + d.weight = 0.7f; + d.r = 190; d.g = 180; d.b = 168; break; + case ItemId::SHOTGUN: + d.name = "SHOTGUN"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SHOTGUN; + d.weight = 2.8f; + d.r = 214; d.g = 158; d.b = 104; break; + case ItemId::SMG: + d.name = "SMG"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::SMG; + d.weight = 1.6f; + d.r = 176; d.g = 200; d.b = 220; break; + case ItemId::MAGNUM: + d.name = "MAGNUM"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::MAGNUM; + d.weight = 1.5f; + d.r = 226; d.g = 210; d.b = 176; break; + case ItemId::BOW: + d.name = "BOW"; d.kind = ItemKind::FIREARM; d.gun = FirearmId::BOW; + d.weight = 1.4f; + d.r = 154; d.g = 190; d.b = 164; break; + + // --- клинки --- + case ItemId::SABER: + d.name = "SABER"; d.kind = ItemKind::BLADE; d.blade = BladeId::SABER; + d.weight = 1.2f; + d.r = 200; d.g = 212; d.b = 228; break; + case ItemId::KNIFE: + d.name = "KNIFE"; d.kind = ItemKind::BLADE; d.blade = BladeId::KNIFE; + d.speedMul = 1.06f; + d.weight = 0.4f; + d.r = 214; d.g = 222; d.b = 232; break; + case ItemId::CLEAVER: + d.name = "CLEAVER"; d.kind = ItemKind::BLADE; d.blade = BladeId::CLEAVER; + d.speedMul = 0.94f; + d.weight = 2.2f; + d.r = 196; d.g = 174; d.b = 160; break; + + // --- броня --- + case ItemId::JACKET: + d.name = "JACKET"; d.kind = ItemKind::ARMOR; + d.armor = 0.10f; d.speedMul = 1.00f; + d.weight = 1.2f; + d.r = 122; d.g = 112; d.b = 96; break; + case ItemId::VEST: + d.name = "VEST"; d.kind = ItemKind::ARMOR; + d.armor = 0.28f; d.speedMul = 0.95f; d.hpBonus = 10.0f; + d.weight = 3.0f; + d.r = 108; d.g = 118; d.b = 108; break; + case ItemId::PLATE: + d.name = "PLATE"; d.kind = ItemKind::ARMOR; + d.armor = 0.48f; d.speedMul = 0.82f; d.hpBonus = 25.0f; + d.weight = 6.0f; + d.r = 140; d.g = 146; d.b = 156; break; + case ItemId::SHROUD: + d.name = "SHROUD"; d.kind = ItemKind::ARMOR; + d.armor = 0.04f; d.speedMul = 1.12f; d.sightMul = 0.92f; + d.weight = 0.8f; + d.r = 88; d.g = 84; d.b = 100; break; + + // --- карман --- + case ItemId::LANTERN: + d.name = "LANTERN"; d.kind = ItemKind::TRINKET; + d.sightMul = 1.35f; + d.weight = 0.5f; + d.r = 240; d.g = 206; d.b = 130; break; + case ItemId::CHARM: + d.name = "CHARM"; d.kind = ItemKind::TRINKET; + d.hpBonus = 30.0f; + d.weight = 0.3f; + d.r = 196; d.g = 130; d.b = 190; break; + case ItemId::SPURS: + d.name = "SPURS"; d.kind = ItemKind::TRINKET; + d.speedMul = 1.14f; + d.weight = 0.3f; + d.r = 198; d.g = 184; d.b = 120; break; + + // --- расходники --- + case ItemId::BANDAGE: + d.name = "BANDAGE"; d.kind = ItemKind::CONSUMABLE; d.heal = 25.0f; + d.weight = 0.2f; + d.r = 216; d.g = 208; d.b = 196; break; + case ItemId::STIMPACK: + d.name = "STIM"; d.kind = ItemKind::CONSUMABLE; d.heal = 60.0f; + d.weight = 0.25f; + d.r = 130; d.g = 220; d.b = 170; break; + case ItemId::AMMO: + d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS; + d.weight = 1.0f; + d.r = 200; d.g = 176; d.b = 96; break; + + default: break; + } + return d; +} + +inline const char* ItemName(ItemId id) { return ItemDefOf(id).name; } + +inline bool IsWeaponItem(ItemId id) +{ + const ItemKind k = ItemDefOf(id).kind; + return k == ItemKind::FIREARM || k == ItemKind::BLADE; +} + +// --- инвентарь ---------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Сумка — ОДНА НА ОТРЯД, а не шесть ячеек на каждого. +// +// Раньше каждый боец таскал своё, и это порождало ровно одну проблему, которую +// нечем было решить: пачка патронов, поднятая бойцом с клинком, доставалась +// только ему и лежала мёртвым грузом до конца забега. Перекладывать между +// бойцами игра не умела, и научить её значило бы завести целый обмен. +// +// Отряд ходит одной кучей и дерётся как одно целое — пусть и несёт как одно +// целое. Личным остаётся НАДЕТОЕ: руки, броня, карман. Их не разделить, они и +// есть «кто этот боец». +// ----------------------------------------------------------------------------- +constexpr int SQUAD_BAG_SLOTS = 16; + +struct SquadBag +{ + ItemId slots[SQUAD_BAG_SLOTS] = {}; + + float Load() const + { + float w = 0.0f; + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) w += ItemDefOf(slots[i]).weight; + return w; + } + + int Count() const + { + int n = 0; + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) + if (slots[i] != ItemId::NONE) ++n; + return n; + } + + int FreeSlot() const + { + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) + if (slots[i] == ItemId::NONE) return i; + return -1; + } + + int Find(ItemKind kind) const + { + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) + if (slots[i] != ItemId::NONE && ItemDefOf(slots[i]).kind == kind) return i; + return -1; + } + + // Влезет ли ещё одна вещь. Ячейки — это «сколько разных вещей», вес — + // «сколько они тянут» на КАЖДОГО, и одно другое не заменяет. + // + // carriers — сколько живых бойцов делят ношу. Меньше живых — тяжелее всем, + // и это не побочный эффект: потерять бойца значит взвалить его долю на + // остальных, и обратно наверх они пойдут медленнее. + bool CanTake(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f) const + { + if (FreeSlot() < 0) return false; + const int n = (carriers > 0) ? carriers : 1; + return weaponLoad + (Load() + ItemDefOf(id).weight) / float(n) <= CARRY_MAX + bonus; + } + + bool Take(ItemId id, int carriers, float weaponLoad, float bonus = 0.0f) + { + if (!CanTake(id, carriers, weaponLoad, bonus)) return false; + const int slot = FreeSlot(); + if (slot < 0) return false; + slots[slot] = id; + return true; + } + + void Clear() + { + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemId::NONE; + } +}; + +// Компонент ECS на сущности бойца: только НАДЕТОЕ. +struct Inventory +{ + ItemId weapon = ItemId::RIFLE; // в руках + ItemId armor = ItemId::NONE; + ItemId trinket = ItemId::NONE; + + // Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак + // весит, нож — нет. + float Armor() const + { + const float a = ItemDefOf(armor).armor + ItemDefOf(trinket).armor; + return (a < ARMOR_CAP) ? a : ARMOR_CAP; + } + float SpeedMul() const + { + return ItemDefOf(armor).speedMul * ItemDefOf(trinket).speedMul * + ItemDefOf(weapon).speedMul; + } + float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; } + float SightMul() const { return ItemDefOf(armor).sightMul * ItemDefOf(trinket).sightMul; } + + // Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи + // (SyncLoadout): инвентарь про отряд не знает. + // + // Надетое сюда НЕ входит, и это не поблажка. За броню уже заплачено + // множителем скорости (плита — 0.82), и считать её вес вторично значило бы + // наказывать за один выбор дважды. Зато плита, НАЙДЕННАЯ и лежащая в + // сумке, весит полной мерой: тащить её наверх — это и есть выбор. + float Load() const { return ItemDefOf(weapon).weight; } +}; diff --git a/src/sim/loadout.h b/src/sim/loadout.h index a401f75..246228d 100644 --- a/src/sim/loadout.h +++ b/src/sim/loadout.h @@ -16,8 +16,8 @@ // ----------------------------------------------------------------------------- #include -#include "core/hash.h" -#include "core/math.h" +#include "engine/hash.h" +#include "engine/math.h" #include "sim/weapon_defs.h" #include "tuning.h" @@ -58,9 +58,12 @@ struct Firearm // Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш // от (владелец, номер выстрела), поэтому прогон воспроизводим побитово. - float ShotOffset(int ownerIndex, float spreadRad) const + // pellet различает дробины одного залпа: без него вся картечь ушла бы + // в одну точку и была бы неотличима от одиночного выстрела. + float ShotOffset(int ownerIndex, float spreadRad, int pellet = 0) const { - const uint32_t h = Hash2(uint32_t(ownerIndex) * 2654435761u, shotSeq); + const uint32_t h = Hash3(uint32_t(ownerIndex) * 2654435761u, shotSeq, + uint32_t(pellet) * 0x85EBCA6Bu); // Треугольное распределение: центр конуса вероятнее краёв. const float a = HashSigned(h); const float b = HashSigned(HashU32(h ^ 0x9E3779B9u)); @@ -102,7 +105,10 @@ struct MeleeWeapon int targetId = -1; bool inReach = false; // цель в зоне удара прямо сейчас bool arcBlockedByAlly = false; // в секторе свой -> бить нельзя (аналог 6.3) - uint32_t hitMask = 0; // кого уже задели ЭТИМ взмахом + // Кого уже задели ЭТИМ взмахом. Шестьдесят четыре бита, а не тридцать два: + // ширина маски — это и есть потолок числа целей в мире (MAX_TARGETS), а + // этажи на глубине упираются именно в него. + uint64_t hitMask = 0; // --- связка --- int comboStep = 0; // какой удар связки будет следующим diff --git a/src/sim/loot.cpp b/src/sim/loot.cpp new file mode 100644 index 0000000..5989769 --- /dev/null +++ b/src/sim/loot.cpp @@ -0,0 +1,66 @@ +#include "sim/loot.h" + +#include "sim/agent.h" + +void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay) +{ + if (id == ItemId::NONE) return; + + FloorItem it; + it.self = world.Create(); + it.id = id; + it.pos = pos; + it.noPickup = pickupDelay; + world.Add(it.self, it); +} + +int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag, + int carriers, float carryBonus, CombatEventLog* events, float dt) +{ + std::vector& items = world.All(); + if (items.empty()) return 0; + + const float reach = g_tune.lootPickupRadius; + int taken = 0; + + for (FloorItem& it : items) + { + if (it.taken) continue; + if (it.noPickup > 0.0f) + { + it.noPickup -= dt; + continue; // только что выброшено — пусть полежит + } + + // Слоты перебираются по порядку, а не «ближайшим бойцом»: при равном + // расстоянии двоих спор решается номером слота, как и везде в + // прототипе (docs/02-architecture.md, «Детерминизм»). + for (int i = 0; i < agentCount; ++i) + { + Agent& a = agents[i]; + if (!a.alive) continue; + if (DistanceSq(a.pos, it.pos) > reach * reach) continue; + + // Не влезает — предмет остаётся лежать. Причин две: кончились + // ячейки или кончилась переноска (sim/items.h). Обе одинаково + // обязаны быть видны как вещь под ногами, а не как пропажа. + const Inventory* inv = world.Get(a.self); + const float weaponLoad = inv ? inv->Load() : 0.0f; + if (!bag.Take(it.id, carriers, weaponLoad, carryBonus)) continue; + + it.taken = true; + ++taken; + if (events) events->Push(CombatEventKind::LOOT_TAKEN, i, it.pos, Vec2{}); + break; + } + } + + // Снятие отдельным проходом: Destroy перетасовывает пул swap-remove, и + // делать это внутри цикла по нему означало бы пропускать элементы. + if (taken > 0) + { + for (int i = int(items.size()) - 1; i >= 0; --i) + if (items[size_t(i)].taken) world.Destroy(items[size_t(i)].self); + } + return taken; +} diff --git a/src/sim/loot.h b/src/sim/loot.h new file mode 100644 index 0000000..15ef26d --- /dev/null +++ b/src/sim/loot.h @@ -0,0 +1,55 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Предмет, лежащий на полу, и его подбор. +// +// Зачем отдельная сущность, а не «выдать предмет при убийстве»: в игре про +// спуск находка — это МЕСТО, до которого надо дойти. Лут, падающий в инвентарь +// сам, не заставляет никуда идти и превращает «лучше лут глубже» в строку в +// логе. Лежащий предмет виден в темноте плохо, лежит он там, куда отряд ещё не +// заходил, и за ним надо разомкнуть строй — это и есть цена находки. +// +// Подбор ПАССИВНЫЙ: боец, прошедший по предмету, поднимает его. Отдельной +// кнопки нет намеренно — игрок и так не нажимает «огонь» (спек 5), и заводить +// ради лута первое ручное действие в игре значило бы менять её жанр. +// +// FloorItem — компонент ECS в своём пуле, как Target и Bullet. Правила боя про +// него не знают вообще: ни решатель, ни LaneClear, ни клинок его не видят. +// ----------------------------------------------------------------------------- +#include + +#include "engine/math.h" +#include "ecs/ecs.h" +#include "sim/events.h" +#include "sim/items.h" + +struct Agent; + +struct FloorItem +{ + ecs::Entity self = ecs::NO_ENTITY; + ItemId id = ItemId::NONE; + Vec2 pos{}; + + // Предмет не удаляется в момент подбора: пул перетасовывается swap-remove, + // а по нему в этот момент идёт цикл. Помеченные снимаются одним проходом + // после него. + bool taken = false; + + // Сколько секунд предмет НЕ подбирается. Нужно ровно одному случаю: вещь, + // которую боец только что выбросил, лежит у него под ногами, и пассивный + // подбор поднял бы её тем же шагом. Выбросить стало бы невозможно. + float noPickup = 0.0f; +}; + +// pickupDelay > 0 — предмет полежит, прежде чем его снова можно подобрать. +void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay = 0.0f); + +// Подбор за шаг. Возвращает, сколько предметов поднято. +// +// Мёртвый боец ничего не подбирает, полная сумка — тоже: предмет остаётся +// лежать, и это правильно. «Некуда положить» обязано быть видно как лежащий +// под ногами предмет, а не как молча пропавшая находка. +// Подбирают В ОБЩУЮ СУМКУ: кто прошёл по предмету, тот его и поднял, но +// достанется он всему отряду. carriers — на скольких живых делится ноша. +int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& bag, + int carriers, float carryBonus, CombatEventLog* events, float dt); diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp new file mode 100644 index 0000000..0f52bfd --- /dev/null +++ b/src/sim/profile.cpp @@ -0,0 +1,234 @@ +#include "sim/profile.h" + +#include +#include +#include + +#include "engine/text_parse.h" +#include "sim/spawn_catalog.h" + +namespace fs = std::filesystem; + +namespace +{ + +// Стартовые комплекты по слотам строя. Те же, что раздаёт Game::EquipKits, и +// намеренно скромные: это дно, с которого начинают заново, а не награда. +const StarterKit STARTER[NUM_AGENTS] = { + {ItemId::SABER, ItemId::VEST}, + {ItemId::SABER, ItemId::JACKET}, + {ItemId::RIFLE, ItemId::JACKET}, + {ItemId::RIFLE, ItemId::JACKET}, + {ItemId::PISTOL, ItemId::SHROUD}, +}; + +ItemId ItemFromToken(const std::string& token) +{ + if (token == "-") return ItemId::NONE; + ItemId id = ItemId::NONE; + // Неизвестное имя — пустая ячейка, а не отказ читать профиль: предмет мог + // исчезнуть из игры между версиями, и терять из-за этого весь схрон нельзя. + ItemIdOfId(token, id); + return id; +} + +} // namespace + +int Profile::CheckpointCount() const +{ + // Достигнутая глубина открывает все чекпоинты не глубже неё. + return 1 + std::max(0, (maxDepth - 1) / CHECKPOINT_STEP); +} + +int Profile::DeepestCheckpoint() const { return CheckpointDepth(CheckpointCount() - 1); } + +bool Profile::StashPush(ItemId id) +{ + if (id == ItemId::NONE) return true; // «положить ничего» — не ошибка + if (int(stash.size()) >= STASH_CAP) return false; + stash.push_back(id); + return true; +} + +bool Profile::StashRemoveAt(int index) +{ + if (index < 0 || index >= int(stash.size())) return false; + stash.erase(stash.begin() + long(index)); + return true; +} + +void Profile::ResetLoadouts() +{ + for (int i = 0; i < NUM_AGENTS; ++i) + { + loadout[i] = Inventory{}; + loadout[i].weapon = STARTER[i].weapon; + loadout[i].armor = STARTER[i].armor; + } +} + +const char* UpgradeName(Upgrade u) +{ + switch (u) + { + case Upgrade::AMMO: return "AMMO RESERVE"; + case Upgrade::CARRY: return "CARRY LIMIT"; + case Upgrade::SIGHT: return "SIGHT RANGE"; + default: return "VIGOR"; + } +} + +const char* UpgradeEffect(Upgrade u) +{ + switch (u) + { + case Upgrade::AMMO: return "+20 ROUNDS PER RAID"; + case Upgrade::CARRY: return "+1.5 BEFORE SLOWDOWN"; + case Upgrade::SIGHT: return "+6% VISION"; + default: return "+12 HEALTH"; + } +} + +bool Profile::Load(const std::string& path, std::string* err) +{ + std::ifstream in(path); + if (!in) + { + if (err) *err = "no profile at " + path; + return false; + } + + Profile p; + p.ResetLoadouts(); + p.stash.clear(); + + std::string line; + int lineNo = 0; + + while (std::getline(in, line)) + { + ++lineNo; + const std::vector t = text::Split(text::StripComment(line)); + if (t.empty()) continue; + + if (t[0] == "maxdepth" && t.size() >= 2) { p.maxDepth = std::max(1, text::ToInt(t[1], 1)); continue; } + if (t[0] == "runs" && t.size() >= 2) { p.runs = text::ToInt(t[1], 0); continue; } + if (t[0] == "extractions" && t.size() >= 2) { p.extractions = text::ToInt(t[1], 0); continue; } + if (t[0] == "wipes" && t.size() >= 2) { p.wipes = text::ToInt(t[1], 0); continue; } + if (t[0] == "xp" && t.size() >= 2) { p.bankedXp = text::ToFloat(t[1], 0.0f); continue; } + + // upgrades <патроны> <переноска> <обзор> <живучесть> + if (t[0] == "upgrades") + { + for (int u = 0; u < int(Upgrade::COUNT) && size_t(1 + u) < t.size(); ++u) + p.upgrade[u] = std::max(0, std::min(UPGRADE_MAX_LEVEL, + text::ToInt(t[size_t(1 + u)], 0))); + continue; + } + + // agent <слот> <оружие> <броня> <карман> <сумка x BAG_SLOTS> + if (t[0] == "agent" && t.size() >= 5) + { + const int slot = text::ToInt(t[1], -1); + if (slot < 0 || slot >= NUM_AGENTS) + { + if (err) *err = "line " + std::to_string(lineNo) + ": bad agent slot"; + return false; + } + + Inventory inv; + inv.weapon = ItemFromToken(t[2]); + inv.armor = ItemFromToken(t[3]); + inv.trinket = ItemFromToken(t[4]); + // Сумка в профиле больше не пишется: она общая на отряд и уезжает + // в схрон целиком при эвакуации (docs/17-extraction.md). Лишние + // лексемы в строке старого профиля просто игнорируются — читать + // прежние файлы это не мешает. + + // Боец без оружия не может ни стрелять, ни рубить, и починить это + // в игре нечем: пустые руки не меняются на что-то из сумки, если + // сумка тоже пуста. + if (inv.weapon == ItemId::NONE) inv.weapon = STARTER[slot].weapon; + p.loadout[slot] = inv; + continue; + } + + if (t[0] == "stash") + { + for (size_t i = 1; i < t.size(); ++i) p.StashPush(ItemFromToken(t[i])); + continue; + } + + if (err) *err = "line " + std::to_string(lineNo) + ": unknown field `" + t[0] + "`"; + return false; + } + + *this = std::move(p); + return true; +} + +bool Profile::Save(const std::string& path, std::string* err) const +{ + std::error_code ec; + fs::create_directories(fs::path(path).parent_path(), ec); + + std::ofstream f(path, std::ios::binary); // \n без \r: файл читают глазами + if (!f) + { + if (err) *err = "cannot open profile for writing"; + return false; + } + + f << "# профиль: всё, что переживает вылазку. Формат — sim/profile.h\n"; + f << "maxdepth " << maxDepth << "\n"; + f << "runs " << runs << "\n"; + f << "extractions " << extractions << "\n"; + f << "wipes " << wipes << "\n"; + f << "xp " << int(bankedXp) << "\n"; + + for (int i = 0; i < NUM_AGENTS; ++i) + { + const Inventory& inv = loadout[i]; + f << "agent " << i << " " << IdOfItem(inv.weapon) << " " << IdOfItem(inv.armor) << " " + << IdOfItem(inv.trinket); + f << "\n"; + } + + // Схрон разбит на строки по восемь: одна строка на полсотни предметов + // не читается ни в редакторе, ни в diff. + for (size_t i = 0; i < stash.size(); i += 8) + { + f << "stash"; + for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << IdOfItem(stash[k]); + f << "\n"; + } + + if (!f.good()) + { + if (err) *err = "profile write failed"; + return false; + } + return true; +} + +std::string DefaultProfilePath() +{ + // Тот же порядок кандидатов, что у остальных данных: запуск из build/bin и + // из корня репозитория — оба рабочие. Профиль ищем существующий, а если + // его нет — пишем туда, где лежат ассеты. + static const char* const candidates[] = { + "save/profile.txt", "../save/profile.txt", "../../save/profile.txt", + }; + std::error_code ec; + for (const char* c : candidates) + if (fs::exists(fs::path(c), ec)) return c; + + for (const char* c : {"assets", "../assets", "../../assets"}) + { + if (!fs::is_directory(fs::path(c), ec)) continue; + return std::string(fs::path(c).parent_path().string().empty() + ? "save/profile.txt" + : (fs::path(c).parent_path() / "save" / "profile.txt").string()); + } + return "save/profile.txt"; +} diff --git a/src/sim/profile.h b/src/sim/profile.h new file mode 100644 index 0000000..d22ba53 --- /dev/null +++ b/src/sim/profile.h @@ -0,0 +1,140 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Профиль: всё, что переживает забег. +// +// Игра стала экстракшен-шутером, и это меняет смысл спуска целиком. Раньше +// глубина была прогрессом: чем дальше, тем лучше. Теперь она СТАВКА — унесённое +// остаётся, потерянное не возвращается, и каждый этаж это заново заданный +// вопрос «ещё ниже или назад». +// +// Отсюда всё устройство файла: +// * снаряжение бойцов лежит ЗДЕСЬ, а не собирается заново при старте. То, в +// чём отряд ушёл вниз, — это то, в чём он вернулся с прошлой вылазки; +// * схрон — общий запас сверх надетого. Он и есть накопленный результат; +// * максимальная глубина открывает чекпоинты. Без них десятитысячный этаж +// недостижим не по сложности, а по времени. +// +// Формат текстовый и построчный по той же причине, что у комнат: профиль лежит +// рядом с игрой, его правят руками при отладке, и он обязан читаться глазами. +// +// Профиль — состояние ИГРОКА, а не мира, поэтому он не в ecs::World и не +// сбрасывается ни Reset, ни Descend. +// ----------------------------------------------------------------------------- +#include +#include + +#include "sim/items.h" +#include "tuning.h" + +// Через сколько этажей ставится чекпоинт. Пять — это «достаточно редко, чтобы +// дойти до следующего было событием, и достаточно часто, чтобы потеря забега +// не откатывала в самое начало». +constexpr int CHECKPOINT_STEP = 5; + +// Сколько предметов помещается в схрон. Не «на всякий случай»: схрон без +// потолка превращает экстракшен в склад, и решение «что взять вниз» исчезает. +constexpr int STASH_CAP = 48; + +// Чем комплектуется боец, у которого не осталось ничего. Это дно, ниже +// которого забег не проваливается: без него полный вайп означал бы отряд с +// голыми руками и конец игры вместо нового захода. +struct StarterKit +{ + ItemId weapon = ItemId::RIFLE; + ItemId armor = ItemId::JACKET; +}; + +// ----------------------------------------------------------------------------- +// Во что вкладывается вынесенный опыт. +// +// Улучшения РЕСУРСНЫЕ, а не силовые: больше патронов, больше переноски, дальше +// видно, больше запаса здоровья. Игра про исчерпание, и вкладываться в неё надо +// в то, что кончается, — иначе прокачка съедает сам жанр: отряд, который бьёт +// вдвое сильнее, перестаёт бояться, а страх тут единственная механика, которую +// нельзя восстановить балансом. +// +// Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре +// остаётся снаряжение, и второе личное измерение превратило бы сборы перед +// вылазкой в бухгалтерию на пятерых. +// ----------------------------------------------------------------------------- +enum class Upgrade : uint8_t +{ + AMMO = 0, // запас патронов на вылазку + CARRY, // норма переноски до перегруза + SIGHT, // радиус обзора + VIGOR, // запас здоровья + COUNT, +}; + +constexpr int UPGRADE_MAX_LEVEL = 5; + +// Шаг улучшения за уровень. +constexpr float UPGRADE_AMMO_STEP = 20.0f; +constexpr float UPGRADE_CARRY_STEP = 1.5f; +constexpr float UPGRADE_SIGHT_STEP = 0.06f; +constexpr float UPGRADE_VIGOR_STEP = 12.0f; + +// Цена следующего уровня. Растёт линейно: первая покупка обязана случиться на +// второй-третьей вылазке, иначе опыт снова «просто число», а десятая — стоить +// заметно, иначе кривая кончается. +inline float UpgradeCost(int currentLevel) { return 150.0f * float(currentLevel + 1); } + +const char* UpgradeName(Upgrade u); +const char* UpgradeEffect(Upgrade u); + +struct Profile +{ + // Уровни улучшений, по одному на каждое. Живут в профиле, а не в игре: + // это то, что переживает вылазку. + int upgrade[int(Upgrade::COUNT)] = {}; + + int UpgradeLevel(Upgrade u) const { return upgrade[int(u)]; } + bool CanBuy(Upgrade u) const + { + return upgrade[int(u)] < UPGRADE_MAX_LEVEL && bankedXp >= UpgradeCost(upgrade[int(u)]); + } + bool Buy(Upgrade u) + { + if (!CanBuy(u)) return false; + bankedXp -= UpgradeCost(upgrade[int(u)]); + ++upgrade[int(u)]; + return true; + } + + // Снаряжение пяти бойцов ровно в том виде, в каком они вернулись. + Inventory loadout[NUM_AGENTS]; + + // Общий запас сверх надетого. + std::vector stash; + + // --- прогресс ------------------------------------------------------------- + int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж + int runs = 0; // сколько вылазок начато + int extractions = 0; // сколько закончилось эвакуацией + int wipes = 0; + float bankedXp = 0.0f; // опыт, вынесенный из успешных вылазок + + // --- чекпоинты ------------------------------------------------------------ + // Самый глубокий чекпоинт, с которого можно начать. Глубина 1 доступна + // всегда: начать сверху — законный выбор, а не наказание. + int DeepestCheckpoint() const; + // Чекпоинт номер i (0 — первый этаж). Возвращает глубину. + static int CheckpointDepth(int index) { return 1 + index * CHECKPOINT_STEP; } + int CheckpointCount() const; + + // --- схрон ---------------------------------------------------------------- + bool StashPush(ItemId id); // false — схрон полон + bool StashRemoveAt(int index); + int StashCount() const { return int(stash.size()); } + + // Сбросить снаряжение к стартовому комплекту. Схрон и прогресс не трогает: + // потерять вылазку — не то же самое, что потерять всё нажитое. + void ResetLoadouts(); + + bool Load(const std::string& path, std::string* err = nullptr); + bool Save(const std::string& path, std::string* err = nullptr) const; +}; + +// Где лежит профиль: рядом с бинарником или в корне репозитория. Возвращает +// путь, даже если файла ещё нет, — по нему и будет первая запись. +std::string DefaultProfilePath(); diff --git a/src/sim/spawn_catalog.cpp b/src/sim/spawn_catalog.cpp new file mode 100644 index 0000000..67383cd --- /dev/null +++ b/src/sim/spawn_catalog.cpp @@ -0,0 +1,136 @@ +#include "sim/spawn_catalog.h" + +#include "sim/loot.h" + +namespace +{ + +// Порядок здесь не важен, важна полнота: каждый вид, который может встретиться +// в комнате, обязан иметь id. Мишень приёмки (DUMMY) в каталоге движка есть +// намеренно — ею удобно ставить неподвижную цель, не заводя тварь. +struct Row +{ + const char* id; + EnemyKind kind; +}; + +const Row ROWS[] = { + {"dummy", EnemyKind::DUMMY}, + {"shambler", EnemyKind::SHAMBLER}, + {"rusher", EnemyKind::RUSHER}, + {"spitter", EnemyKind::SPITTER}, + {"brute", EnemyKind::BRUTE}, + {"lurker", EnemyKind::LURKER}, + {"crate", EnemyKind::CRATE}, + {"barrel", EnemyKind::BARREL}, + {"locker", EnemyKind::LOCKER}, +}; + +// Вторая половина того же моста: id каталога -> предмет игры. Отдельная +// таблица, а не поле в первой: тварь и предмет — разные пулы и разные +// перечисления, и объединяющий их «универсальный id» пришлось бы разбирать +// в каждом месте использования. +struct LootRow +{ + const char* id; + ItemId item; +}; + +const LootRow LOOT_ROWS[] = { + {"rifle", ItemId::RIFLE}, {"pistol", ItemId::PISTOL}, + {"shotgun", ItemId::SHOTGUN}, {"smg", ItemId::SMG}, + {"magnum", ItemId::MAGNUM}, {"bow", ItemId::BOW}, + {"saber", ItemId::SABER}, {"knife", ItemId::KNIFE}, + {"cleaver", ItemId::CLEAVER}, + {"jacket", ItemId::JACKET}, {"vest", ItemId::VEST}, + {"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD}, + {"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM}, + {"spurs", ItemId::SPURS}, + {"ammo", ItemId::AMMO}, + {"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK}, +}; + +} // namespace + +bool EnemyKindOfId(const std::string& id, EnemyKind& out) +{ + for (const Row& r : ROWS) + { + if (id != r.id) continue; + out = r.kind; + return true; + } + return false; +} + +bool ItemIdOfId(const std::string& id, ItemId& out) +{ + for (const LootRow& r : LOOT_ROWS) + { + if (id != r.id) continue; + out = r.item; + return true; + } + return false; +} + +const char* IdOfEnemyKind(EnemyKind kind) +{ + for (const Row& r : ROWS) + if (r.kind == kind) return r.id; + return "?"; +} + +const char* IdOfItem(ItemId item) +{ + for (const LootRow& r : LOOT_ROWS) + if (r.item == item) return r.id; + return "-"; +} + +int SpawnFromLevel(ecs::World& world, const std::vector& list, const Tilemap& map, + int* unknown) +{ + int placed = 0; + int skipped = 0; + + for (const LevelSpawn& s : list) + { + EnemyKind kind = EnemyKind::DUMMY; + if (!EnemyKindOfId(s.id, kind)) { ++skipped; continue; } + + // Потолок тот же, что у захардкоженной расстановки: маска задетых целей + // в клинке 32-битная, и тридцать третья цель молча получала бы удары + // дважды за взмах (tuning.h). + if (placed >= MAX_TARGETS) { ++skipped; continue; } + + // Сопротивление отбросу — свойство вида: расписывать его по слотам, + // как у мишеней приёмки, здесь не по чему, слотов нет. + MakeTarget(world, kind, s.pos, -1.0f, &map); + ++placed; + } + + if (unknown) *unknown = skipped; + return placed; +} + +int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int* unknown) +{ + int placed = 0; + int skipped = 0; + + for (const LevelSpawn& s : list) + { + ItemId item = ItemId::NONE; + if (!ItemIdOfId(s.id, item)) { ++skipped; continue; } + + // Из стены предмет не выталкивается, в отличие от твари: генератор + // кладёт лут только на достижимый пол, и «поправить» его здесь значило + // бы прятать ошибку раскладки вместо того, чтобы её увидеть. + SpawnFloorItem(world, item, s.pos); + ++placed; + } + + if (unknown) *unknown = skipped; + return placed; +} diff --git a/src/sim/spawn_catalog.h b/src/sim/spawn_catalog.h new file mode 100644 index 0000000..8be4f06 --- /dev/null +++ b/src/sim/spawn_catalog.h @@ -0,0 +1,40 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Единственное место, где строковый id движка превращается в вид игры. +// +// Движок (и редактор вместе с ним) знает про сущности только то, что написано +// в assets/catalog/entities.txt: id, цвет, радиус, спрайт. Что такое SHAMBLER +// и чем он бьёт — знание игры, и оно живёт в sim/target.cpp. Таблица ниже +// связывает одно с другим, и других таких таблиц в проекте быть не должно: +// как только их станет две, они разойдутся. +// +// Новая тварь — это строка в каталоге, запись в EnemyDefOf и строка здесь. +// Ни редактор, ни формат комнат при этом не меняются вообще. +// ----------------------------------------------------------------------------- +#include +#include + +#include "ecs/ecs.h" +#include "engine/level_gen.h" +#include "sim/items.h" +#include "sim/target.h" +#include "engine/tilemap.h" + +// false — такого id в игре нет. Это не авария: комната могла ссылаться на +// сущность, которую из игры убрали, и уровень обязан собраться без неё. +bool EnemyKindOfId(const std::string& id, EnemyKind& out); +bool ItemIdOfId(const std::string& id, ItemId& out); + +// Обратный перевод — для отчётов, dev-вывода и записи профиля. +const char* IdOfEnemyKind(EnemyKind kind); +// "-" для ItemId::NONE и для предмета, которого нет в каталоге: пустая ячейка +// в файле профиля обязана быть видимой, а не отсутствующей. +const char* IdOfItem(ItemId item); + +// Выкатить в мир то, что нагенерил движок. Возвращает, сколько сущностей +// реально встало; unknown (если передан) — сколько id не опознано. +int SpawnFromLevel(ecs::World& world, const std::vector& list, const Tilemap& map, + int* unknown = nullptr); + +// То же для лежащих предметов. +int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int* unknown = nullptr); diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp index c872350..78a7e55 100644 --- a/src/sim/squad.cpp +++ b/src/sim/squad.cpp @@ -76,9 +76,15 @@ void Squad::Init(ecs::World& world, Vec2 at) const bool isMelee = (i < meleeCount); const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount); - a.cls = isMelee ? WeaponClass::MELEE : WeaponClass::RANGED; - a.gunId = isPistol ? FirearmId::PISTOL : FirearmId::RIFLE; - a.weaponEntity = SpawnWeapon(world, a.cls, a.gunId); + // Стартовое оружие — то же, что и было: клинок, карабин, пистолет. + // Снаряжения на бойце НЕТ: броня меняет скорость, а на скорости меряются + // критерии приёмки. Комплекты раздаёт игровой режим (Game::EquipKits). + const ItemId weapon = isMelee ? ItemId::SABER + : isPistol ? ItemId::PISTOL + : ItemId::RIFLE; + + a.weaponEntity = SpawnWeapon(world, weapon); + world.Add(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE}); a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f; a.pos = SlotWorld(i); @@ -93,6 +99,8 @@ void Squad::Init(ecs::World& world, Vec2 at) SyncLoadout(world, a); a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином + a.alive = true; + a.hp = a.maxHp; } } @@ -141,9 +149,17 @@ Vec2 Squad::GoalFor(int i) const Vec2 Squad::Centroid() const { + // Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд + // к телу павшего. Vec2 c{}; - for (int i = 0; i < NUM_AGENTS; ++i) c += agents[i].pos; - return c / float(NUM_AGENTS); + int n = 0; + for (int i = 0; i < NUM_AGENTS; ++i) + { + if (!agents[i].alive) continue; + c += agents[i].pos; + ++n; + } + return (n > 0) ? c / float(n) : anchor; } void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt) @@ -255,6 +271,13 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes, { Agent& a = agents[i]; a.prevPos = a.pos; + if (!a.alive) { a.vel = {}; continue; } // упавший лежит там, где упал + + // Захваченного не двигает НИЧТО: ни строй, ни решатель, ни расступание + // из чужой линии. В этом вся цена захвата — боец выпал из геометрии + // отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт. + // Оружие при этом работает: захват держит ноги, а не руки. + if (a.Grabbed()) { a.vel = {}; continue; } const Vec2 goal = GoalFor(i); const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE; @@ -274,7 +297,9 @@ void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes, } // --- обычные слои, порядок 6.7 -------------------------------------- - const float maxSpeed = baseSpeed * a.SpeedScale(); + // speedMul — это снаряжение: плита тормозит, шпоры разгоняют. Без + // снаряжения он равен 1, и движение остаётся прежним до последнего бита. + const float maxSpeed = baseSpeed * a.SpeedScale() * a.speedMul; Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek; desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation; diff --git a/src/sim/squad.h b/src/sim/squad.h index 145a84c..8b51387 100644 --- a/src/sim/squad.h +++ b/src/sim/squad.h @@ -13,7 +13,7 @@ #include "ecs/ecs.h" #include "sim/agent.h" #include "sim/target.h" -#include "world/tilemap.h" +#include "engine/tilemap.h" struct LaneRegistry; // ai/lane_registry.h — расступание из чужих линий (6.6) @@ -43,6 +43,25 @@ struct AgentSpan struct Squad { + // Что добавили улучшения профиля. Ноль по умолчанию — в песочнице приёмки + // профиля нет вовсе, и там всё считается ровно как считалось. + float carryBonus = 0.0f; + float sightBonus = 0.0f; + float hpBonus = 0.0f; + + // Общая сумка отряда. Здесь, а не на бойце: отряд ходит одной кучей и несёт + // как одно целое (sim/items.h). Личным остаётся только надетое. + SquadBag bag; + + // Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего. + int Carriers() const + { + int n = 0; + for (int i = 0; i < NUM_AGENTS; ++i) + if (agents[i].alive) ++n; + return n > 0 ? n : 1; + } + AgentSpan agents; Vec2 anchor{}; diff --git a/src/sim/target.cpp b/src/sim/target.cpp index 0eee752..465cc1d 100644 --- a/src/sim/target.cpp +++ b/src/sim/target.cpp @@ -32,29 +32,260 @@ const float kTargetKnockResist[NUM_TARGETS] = { 0.85f, }; +// Кто стоит в каждой точке в боевом режиме. Ближняя кучка — та, на которую +// отряд наткнётся первой: бегун, двое бредущих, плевок и ещё бегун. Громилы +// сидят в дальних комнатах: до них надо дойти. +const EnemyKind kTargetKinds[NUM_TARGETS] = { + EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SHAMBLER, + EnemyKind::SPITTER, EnemyKind::RUSHER, + EnemyKind::SHAMBLER, EnemyKind::SPITTER, EnemyKind::BRUTE, + EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SPITTER, + EnemyKind::BRUTE, +}; + +// Твари СВЕРХ мишеней: расставлены по всей карте, а не только у старта. +// Без них зачистка первой кучки заканчивала вылазку, и карта стояла пустой. +// В песочнице приёмки их нет вообще — та расстановка не менялась. +const float kExtraSpots[NUM_EXTRA_ENEMIES][2] = { + {14.5f, 12.5f}, {18.5f, 16.5f}, {12.5f, 24.5f}, + {36.5f, 12.5f}, {40.5f, 18.5f}, {38.5f, 27.5f}, + {21.5f, 36.5f}, {25.5f, 41.5f}, {33.5f, 39.5f}, + {16.5f, 32.5f}, {30.5f, 33.5f}, {41.5f, 43.5f}, +}; + +// Хваты стоят ПОДАЛЬШЕ от старта, в глубине карты: захват — это ловушка для +// отряда, который уже разошёлся по позициям, а не встречающий у порога. +const EnemyKind kExtraKinds[NUM_EXTRA_ENEMIES] = { + EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SHAMBLER, + EnemyKind::SPITTER, EnemyKind::SHAMBLER, EnemyKind::RUSHER, + EnemyKind::LURKER, EnemyKind::BRUTE, EnemyKind::SPITTER, + EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::LURKER, +}; + +// Разрушаемая обстановка. Стоит ПАРАМИ и по краям комнат, а не по центру: +// брошенный скарб жмётся к стенам, и ровно поэтому он читается как обстановка, +// а не как расставленные мишени. Две штуки — у самого старта: игрок обязан +// узнать, что хлам ломается, до того как в него полетит первая тварь. +const float kPropSpots[NUM_PROPS][2] = { + {24.5f, 24.5f}, {23.5f, 25.5f}, // у старта: пара ящиков под рукой + {33.5f, 19.5f}, // ближняя кучка: укрытие сбоку + {28.5f, 6.5f}, {26.5f, 6.5f}, // северная комната, вдоль стены + { 5.5f, 41.5f}, // западная комната + {44.5f, 33.5f}, {42.5f, 44.5f}, // восточная и юго-восточный угол +}; + +const EnemyKind kPropKinds[NUM_PROPS] = { + EnemyKind::CRATE, EnemyKind::BARREL, + EnemyKind::CRATE, + EnemyKind::LOCKER, EnemyKind::BARREL, + EnemyKind::CRATE, + EnemyKind::BARREL, EnemyKind::LOCKER, +}; + } // namespace -void SpawnTargets(ecs::World& world) +EnemyDef EnemyDefOf(EnemyKind kind) { - for (int i = 0; i < NUM_TARGETS; ++i) + EnemyDef d; + switch (kind) { - Target t; - t.self = world.Create(); - t.pos = {kTargetSpots[i][0], kTargetSpots[i][1]}; + // Бредущий: основа толпы. Медленный настолько, что от него можно уйти + // шагом, — и ровно поэтому страшен только гурьбой. + case EnemyKind::SHAMBLER: + d.name = "SHAMBLER"; + d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f; + d.aggroRadius = 9.5f; d.attackRange = 0.85f; d.damage = 13.0f; + d.windup = 0.42f; d.interval = 1.25f; + // Взмах лапой: широкий сектор перед собой. Отходить от него назад + // бесполезно — он и так короткий; уходить надо вбок. + d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f; + d.lightPower = 0.05f; + d.r = 116; d.g = 52; d.b = 52; + break; + + // Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда + // стрелок остаётся с ним один на один. + case EnemyKind::RUSHER: + d.name = "RUSHER"; + d.hp = 110.0f; d.radius = 0.27f; d.speed = 3.3f; d.knockResist = 0.0f; + d.aggroRadius = 12.0f; d.attackRange = 0.75f; d.damage = 14.0f; + d.windup = 0.28f; d.interval = 1.10f; + // Рывок: разрыв в три метра он закрывает за пятую долю секунды и + // сносит ВСЕХ по прямой. Замах длиннее прежнего удара намеренно — + // это единственное окно, в котором отряд успевает разойтись с оси. + d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f; + d.lightPower = 0.07f; + d.r = 158; d.g = 74; d.b = 44; + break; + + // Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт + // по площадям — из-за него отряд не может просто встать в линию. + case EnemyKind::SPITTER: + d.name = "SPITTER"; + d.hp = 110.0f; d.radius = 0.31f; d.speed = 1.15f; d.knockResist = 0.1f; + d.aggroRadius = 10.5f; d.attackRange = 6.2f; d.damage = 16.0f; + d.windup = 0.55f; d.interval = 1.60f; + d.ranged = true; d.projectileSpeed = 8.5f; + // Снаряд летит дальше, чем плевок «достаёт»: отойти от него на шаг + // мало, надо разрывать дистанцию или прятаться за стену. + d.attack = AttackKind::SPIT; d.spitRange = 8.4f; + d.lightPower = 0.10f; + d.r = 96; d.g = 132; d.b = 72; + break; + + // Громила: медленный, толстый, с одного удара снимает половину бойца. + // Клинком его надо разбирать вдвоём, и это правильная цена. + case EnemyKind::BRUTE: + d.name = "BRUTE"; + d.hp = 520.0f; d.radius = 0.46f; d.speed = 1.05f; d.knockResist = 0.85f; + d.aggroRadius = 8.5f; d.attackRange = 1.25f; d.damage = 46.0f; + d.windup = 0.95f; d.interval = 2.60f; + // Удар оземь: круг вокруг себя, а не выпад в жертву. Разбирать + // громилу вдвоём теперь стоит денег — оба стоят в круге. + d.attack = AttackKind::SLAM; d.slamRadius = 2.15f; + d.lightPower = 0.12f; + d.r = 142; d.g = 60; d.b = 78; + break; + + // Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют. + // Опасен не уроном, а тем, что вырывает одного из строя: линия огня + // рушится, отряд разваливается, и остальные твари доедают. + case EnemyKind::LURKER: + d.name = "LURKER"; + d.hp = 150.0f; d.radius = 0.30f; d.speed = 1.90f; d.knockResist = 0.15f; + d.aggroRadius = 11.0f; d.attackRange = 0.90f; d.damage = 9.0f; + d.windup = 0.50f; d.interval = 2.40f; + // Урон тикает, пока держит: 9 за тик каждые 0.45 с — само по себе + // терпимо, смертельна именно неподвижность. + d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f; + d.lightPower = 0.09f; + d.r = 88; d.g = 62; d.b = 118; + break; + + // --- разрушаемая обстановка ------------------------------------------- + // Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы + // UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем + // ни идти, ни бить. Единственное, что у них есть, — HP и радиус. + // + // Сопротивление отбросу единица: ящик от попадания не ездит по полу. + // Живучесть подобрана от ОДНОГО магазина: труху сносит очередью на ходу, + // шкафчик требует остановиться — и в этом вся разница между ними. + case EnemyKind::CRATE: + d.name = "CRATE"; + d.hp = 60.0f; d.radius = 0.38f; d.knockResist = 1.0f; + d.r = 104; d.g = 76; d.b = 46; + break; + + case EnemyKind::BARREL: + d.name = "BARREL"; + d.hp = 95.0f; d.radius = 0.33f; d.knockResist = 1.0f; + d.r = 92; d.g = 84; d.b = 58; + break; + + case EnemyKind::LOCKER: + d.name = "LOCKER"; + d.hp = 140.0f; d.radius = 0.36f; d.knockResist = 1.0f; + d.r = 78; d.g = 88; d.b = 96; + break; + + // Мишень приёмки: всё по нулям, поведения нет. Вид атаки ей выставлен + // формально — с нулевым уроном и нулевой дальностью он не сработает + // никогда, а UpdateEnemies до неё вообще не доходит. + default: + d.name = "TARGET"; + d.hp = g_tune.targetHP; + d.radius = g_tune.targetRadius; + d.attack = AttackKind::SWIPE; + d.r = 120; d.g = 44; d.b = 44; + break; + } + return d; +} + +const char* EnemyName(EnemyKind kind) { return EnemyDefOf(kind).name; } + +void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist, + const Tilemap* map) +{ + const EnemyDef def = EnemyDefOf(kind); + + Target t; + t.self = world.Create(); + t.kind = kind; + t.pos = pos; + t.home = t.pos; + t.vel = {}; + t.hp = def.hp; + t.maxHp = def.hp; + t.radius = def.radius; + t.respawnTimer = 0.0f; + t.alive = true; + // У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно — + // свойство вида. Отрицательное значение означает «взять из каталога». + t.knockResist = (knockResist >= 0.0f) ? knockResist : def.knockResist; + + t.speed = def.speed; + t.aggroRadius = def.aggroRadius; + t.attackRange = def.attackRange; + t.damage = def.damage; + t.windup = def.windup; + t.interval = def.interval; + t.ranged = def.ranged; + t.projectileSpeed = def.projectileSpeed; + + t.attack = def.attack; + t.swipeArcDeg = def.swipeArcDeg; + t.lungeDist = def.lungeDist; + t.lungeTime = def.lungeTime; + t.spitRange = def.spitRange; + t.slamRadius = def.slamRadius; + t.grabTime = def.grabTime; + t.grabTick = def.grabTick; + + // Точки расставлены руками, а планировка генерируется: тварь, поставленная + // в стену, никогда бы оттуда не вышла — она сдвигается только когда идёт + // за кем-то, а из стены ей никого не видно. + if (map) + { + map->ResolveCircle(t.pos, t.radius); t.home = t.pos; - t.vel = {}; - t.hp = g_tune.targetHP; - t.maxHp = g_tune.targetHP; - t.radius = g_tune.targetRadius; - t.respawnTimer = 0.0f; - t.alive = true; - t.knockResist = kTargetKnockResist[i]; - world.Add(t.self, t); + } + + world.Add(t.self, t); +} + +void SpawnTargets(ecs::World& world, bool hostile, const Tilemap* map) +{ + // Три полосы подряд в одном пуле: мишени, твари сверх них, обстановка. + // В песочнице приёмки живёт только первая — ни одной лишней сущности, + // и все замеры остаются сравнимыми с прежними. + const int total = hostile ? (NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS) : NUM_TARGETS; + + for (int i = 0; i < total; ++i) + { + const int extraIdx = i - NUM_TARGETS; + const int propIdx = extraIdx - NUM_EXTRA_ENEMIES; + const bool extra = (extraIdx >= 0 && propIdx < 0); + const bool prop = (propIdx >= 0); + + const EnemyKind kind = !hostile ? EnemyKind::DUMMY + : prop ? kPropKinds[propIdx] + : extra ? kExtraKinds[extraIdx] + : kTargetKinds[i]; + + const Vec2 pos = prop ? Vec2{kPropSpots[propIdx][0], kPropSpots[propIdx][1]} + : extra ? Vec2{kExtraSpots[extraIdx][0], kExtraSpots[extraIdx][1]} + : Vec2{kTargetSpots[i][0], kTargetSpots[i][1]}; + + MakeTarget(world, kind, pos, hostile ? -1.0f : kTargetKnockResist[i], map); } } void KnockTarget(Target& t, Vec2 dir, float speed) { + // Обстановка не ездит по полу от попаданий. Домой её никто не тянет (это + // свойство мишени приёмки), так что сдвинутый ящик уехал бы навсегда. + if (t.IsProp()) return; t.vel += dir * (speed * t.KnockScale()); } @@ -67,30 +298,58 @@ void UpdateTargets(std::vector& targets, float dt) for (Target& t : targets) { - t.radius = g_tune.targetRadius; - t.maxHp = g_tune.targetHP; + t.hurtTimer = std::max(0.0f, t.hurtTimer - dt); + + if (t.IsDummy()) + { + // Мишень приёмки: ручки tuning продолжают крутить именно её. + t.radius = g_tune.targetRadius; + t.maxHp = g_tune.targetHP; + } t.pos += t.vel * dt; t.vel *= damp; - t.pos += (t.home - t.pos) * pull; + // Возврат на своё место — свойство МИШЕНИ, а не тела: тварь, которую + // тянет домой, не может ни догнать отряд, ни отойти от него. + if (t.IsDummy()) t.pos += (t.home - t.pos) * pull; if (t.alive) continue; + // Разбитый хлам остаётся разбитым: RespawnDelay() возвращает ему + // отрицательный срок, и без этой проверки он воскресал бы в тот же кадр. + if (t.IsProp()) continue; + t.respawnTimer -= dt; if (t.respawnTimer <= 0.0f) { + const EnemyDef def = EnemyDefOf(t.kind); t.alive = true; - t.hp = g_tune.targetHP; + t.hp = t.IsDummy() ? g_tune.targetHP : def.hp; t.pos = t.home; t.vel = {}; + t.victim = -1; + t.aggro = false; + t.attackTimer = 0.0f; + t.windupTimer = 0.0f; + // Недоведённый рывок и незакрытый захват пережили бы смерть и + // вернулись бы вместе с тварью. Захваченного бойца при этом никто + // не держит: его таймер гаснет сам, пока держать некому. + t.lungeTimer = 0.0f; + t.lungeMask = 0; + t.grabbed = -1; + t.grabTimer = 0.0f; + t.grabTickTimer = 0.0f; } } } int CountAliveTargets(const std::vector& targets) { + // Хлам здесь не считается: это знаменатель «по скольким целям отряд может + // работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает + // огневую позицию хуже и зачистку — незаконченной. int n = 0; for (const Target& t : targets) - if (t.alive) ++n; + if (t.alive && !t.IsProp()) ++n; return n; } diff --git a/src/sim/target.h b/src/sim/target.h index 7b14539..93086f4 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -1,21 +1,116 @@ #pragma once // ----------------------------------------------------------------------------- -// Мишени: статичные, не атакуют, респавнятся на том же месте. -// 12 штук кучками (5 / 3 / 3 / 1) — расстановка захардкожена. +// Противник. Одна сущность на две роли, и это сделано намеренно. // -// Target — компонент ECS: сущности мишеней создаются в SpawnTargets, а вектор, -// по которому ходят правила, — это плотный массив пула (см. ecs/ecs.h). +// * EnemyKind::DUMMY — прежняя МИШЕНЬ: стоит на месте, не атакует, после +// смерти возвращается. На ней меряются все критерии приёмки, поэтому её +// поведение не изменилось ни на кадр: --accept обязан считать то же самое. +// * остальные виды — живые твари: замечают отряд, идут, бьют, стреляют. +// +// Разводить их по двум компонентам не за чем: решатель огневых позиций, правило +// 6.3, клинок и пули работают с ними одинаково — как с телом, у которого есть +// позиция, радиус и здоровье. Разница живёт ровно в одном месте: UpdateEnemies +// (sim/enemy.cpp) шевелит тех, у кого kind != DUMMY. +// +// Target — компонент ECS: сущности создаются в SpawnTargets, а вектор, по +// которому ходят правила, — это плотный массив пула (см. ecs/ecs.h). // ----------------------------------------------------------------------------- +#include #include -#include "core/math.h" +#include "engine/math.h" #include "ecs/ecs.h" #include "tuning.h" +#include "engine/tilemap.h" + +enum class EnemyKind : uint8_t +{ + DUMMY = 0, // мишень приёмки: не ходит, не бьёт + SHAMBLER, // бредущий: медленный, живучий, бьёт вплотную + RUSHER, // бегун: тонкий, быстрый, налетает первым + SPITTER, // плевок: держится поодаль и стреляет + BRUTE, // громила: медленный, толстый, сносит одним ударом + LURKER, // хват: подбирается и держит бойца на месте + + // --- разрушаемая обстановка ----------------------------------------------- + // Ниже этой черты идут НЕ противники: ящик не ходит, не бьёт и не пугает. + // Живут они в том же пуле Target намеренно — пуля, клинок, стена и правило + // 6.3 работают с ними как с любым телом «позиция + радиус + HP», и ни одну + // из этих систем трогать не пришлось. Разница ровно в двух местах: + // приоритет цели (ai/) и то, что UpdateEnemies их не шевелит. + CRATE, // ящик: труха и хлам, разлетается быстро + BARREL, // бочка: ржавая, гулкая, держит дольше + LOCKER, // шкафчик: жестяной, самый живучий из хлама + COUNT, +}; + +// Первый вид обстановки. Всё, что >= него, — хлам, а не тварь. +constexpr EnemyKind FIRST_PROP_KIND = EnemyKind::CRATE; + +// Чем именно бьёт вид. Один удар на всех делал любую тварь одинаковой: подошла — +// сняла здоровье. Разная ГЕОМЕТРИЯ атаки — единственное, что заставляет игрока +// по-разному расставлять отряд против разных тварей. +// +// Общее для всех пяти: сначала замах (windupTimer), и он ВИДЕН. Без телеграфа +// смерть бойца выглядит случайной, а не проигранной. +enum class AttackKind : uint8_t +{ + SWIPE = 0, // сектор перед собой: уводи бойца вбок, а не назад + LUNGE, // рывок на lungeDist за lungeTime; урон всем, кого задел по пути + SPIT, // снаряд: единственная атака, которую перекрывает стена + SLAM, // круг радиусом slamRadius вокруг себя: опасен и соседям жертвы + GRAB, // захват: держит бойца grabTime секунд и тикает уроном +}; + +// Описание вида: одна запись каталога, как у оружия. +struct EnemyDef +{ + const char* name = "dummy"; + + float hp = 500.0f; + float radius = 0.34f; + float speed = 0.0f; // 0 — с места не сходит + float knockResist = 0.0f; + + float aggroRadius = 0.0f; // с какой дистанции замечает отряд + float attackRange = 0.0f; + float damage = 0.0f; + float windup = 0.0f; // телеграф: замах видно до удара + float interval = 1.0f; // между атаками + + bool ranged = false; + float projectileSpeed = 9.0f; + + // --- геометрия атаки ------------------------------------------------------ + // Числа здесь, а не в коде поведения: форма удара — свойство ВИДА, и менять + // её надо в каталоге, рядом с остальными статами. Общие множители (сложность, + // длина всех телеграфов) живут в tuning.h. + AttackKind attack = AttackKind::SWIPE; + float swipeArcDeg = 90.0f; // SWIPE: полная ширина сектора + float lungeDist = 0.0f; // LUNGE: длина рывка, она же дистанция начала + float lungeTime = 0.18f; // LUNGE: за сколько он проходится + // SPIT: сколько летит снаряд. Больше attackRange намеренно — иначе плевок + // гаснет ровно там, откуда цель уже успела шагнуть, и стрелять смысла нет. + float spitRange = 0.0f; + float slamRadius = 0.0f; // SLAM: радиус круга поражения + float grabTime = 0.0f; // GRAB: сколько держит + float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит + + float lightPower = 0.0f; // светятся ли глаза (render-only) + + // Цвет тела. Нейтральные байты: типов рендера в sim/ нет. + uint8_t r = 120, g = 44, b = 44; +}; + +EnemyDef EnemyDefOf(EnemyKind kind); +const char* EnemyName(EnemyKind kind); struct Target { ecs::Entity self = ecs::NO_ENTITY; + EnemyKind kind = EnemyKind::DUMMY; + Vec2 pos{}; // Своё место. Удар отбрасывает мишень, после чего она возвращается сюда: // без отдачи попадание в упор ничем не отличается от промаха. @@ -38,6 +133,74 @@ struct Target const float r = Clampf(knockResist * g_tune.knockResistScale, 0.0f, 0.98f); return 1.0f - r; } + + // --- статы вида (копия записи каталога, чтобы ИИ не лазал в таблицу) ------ + float speed = 0.0f; + float aggroRadius = 0.0f; + float attackRange = 0.0f; + float damage = 0.0f; + float windup = 0.0f; + float interval = 1.0f; + bool ranged = false; + float projectileSpeed = 9.0f; + + // --- геометрия атаки (копия записи каталога) ------------------------------ + AttackKind attack = AttackKind::SWIPE; + float swipeArcDeg = 90.0f; + float lungeDist = 0.0f; + float lungeTime = 0.18f; + float spitRange = 0.0f; + float slamRadius = 0.0f; + float grabTime = 0.0f; + float grabTick = 0.5f; + + // --- состояние ИИ --------------------------------------------------------- + int victim = -1; // индекс бойца, за которым идёт; -1 — никого + float attackTimer = 0.0f; // до следующей атаки + float windupTimer = 0.0f; // > 0 — замах уже виден игроку + float facing = 0.0f; + float hurtTimer = 0.0f; // недавно получил урон (render-only) + bool aggro = false; // заметил отряд + // Опыт за эту смерть уже начислен. Не «мёртв ли» — смерть длится много + // шагов до респавна, и без отдельного флага опыт капал бы каждый кадр, + // пока тело лежит. Респавн флаг снимает: вернувшаяся тварь — новая цель. + bool countedDead = false; + + // --- рывок (LUNGE) -------------------------------------------------------- + // Рывок — это ДВИЖЕНИЕ по времени, а не телепорт: игрок обязан видеть бросок + // и успевать понять, кого именно им снесло. + float lungeTimer = 0.0f; // > 0 — рывок идёт прямо сейчас + Vec2 lungeDir{}; // ось зафиксирована на старте и не доводится + uint32_t lungeMask = 0; // кого уже задел ЭТИМ рывком: бит = индекс бойца + + // --- захват (GRAB) -------------------------------------------------------- + int grabbed = -1; // кого держит; -1 — никого + float grabTimer = 0.0f; // сколько ещё продержит + float grabTickTimer = 0.0f; // до следующего тика урона + + bool IsDummy() const { return kind == EnemyKind::DUMMY; } + + // Хлам, а не противник: ящик, бочка, шкафчик. Проверяется во всех местах, + // где «цель» означала «кого-то, кто может убить»: приоритет ИИ, счётчик + // напряжения, глаза в темноте, знаменатель покрытия в решателе. + bool IsProp() const { return kind >= FIRST_PROP_KIND; } + + // Мишень приёмки встаёт быстро — на ней меряют. Тварь не возвращается, + // пока отряд не успеет уйти: иначе зачистка не имеет смысла. + // Разбитый ящик не встаёт НИКОГДА: разрушаемая обстановка, которая + // отрастает обратно, — это не обстановка, а мишень. + float RespawnDelay() const + { + if (IsProp()) return -1.0f; + return IsDummy() ? g_tune.targetRespawn : g_tune.enemyRespawn; + } + + // Замах виден: единственный признак, по которому игрок успевает среагировать. + bool Telegraphing() const { return windupTimer > 0.0f; } + + // Атака уже пошла и назад не отыгрывается: тварь в рывке или держит бойца. + // В этих фазах она не выбирает цель заново и не ходит по своим делам. + bool Busy() const { return lungeTimer > 0.0f || grabbed >= 0; } }; // Импульс от удара (dir нормализован вызывающим). speed — импульс ДО того, @@ -45,7 +208,23 @@ struct Target // иначе половина источников урона про него забудет. void KnockTarget(Target& t, Vec2 dir, float speed); -void SpawnTargets(ecs::World& world); +// Собрать сущность-цель заданного вида в точке и добавить её в мир. +// knockResist < 0 — взять из каталога вида; иначе это расписанное по слоту +// значение мишени приёмки. map (если передан) выталкивает тело из стены. +// +// Одна функция на обе расстановки — захардкоженную и собранную из комнат +// (sim/spawn_catalog.h). Двадцать полей Target, скопированных в два места, +// разошлись бы на первой же новой характеристике вида. +void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist, + const Tilemap* map); + +// hostile = false — прежняя расстановка мишеней приёмки (все DUMMY). +// hostile = true — та же расстановка видами тварей ПЛЮС NUM_EXTRA_ENEMIES +// по всей карте: игра. +// map нужен, чтобы вытолкнуть тварь из стены, если точка попала в блок. +void SpawnTargets(ecs::World& world, bool hostile = false, const Tilemap* map = nullptr); + +// Отдача, возврат мишени на место и респавн. ИИ здесь нет — он в sim/enemy.cpp. void UpdateTargets(std::vector& targets, float dt); // Число живых мишеней (знаменатель в оценке покрытия, спек 6.5). diff --git a/src/sim/weapon_defs.h b/src/sim/weapon_defs.h index b731b09..4853a36 100644 --- a/src/sim/weapon_defs.h +++ b/src/sim/weapon_defs.h @@ -21,7 +21,7 @@ // ----------------------------------------------------------------------------- #include -#include "core/math.h" +#include "engine/math.h" #include "tuning.h" enum class WeaponClass : uint8_t @@ -38,8 +38,20 @@ inline const char* WeaponClassName(WeaponClass c) enum class FirearmId : uint8_t { RIFLE = 0, // штатный карабин отряда: числа из tuning.h - PISTOL = 1, // короткоствол: ближе, быстрее, мельче магазин - COUNT = 2, + PISTOL, // короткоствол: ближе, быстрее, мельче магазин + SHOTGUN, // картечь: залп дробин, в упор рвёт, дальше двух шагов бесполезен + SMG, // шквал: очередь дешёвыми патронами, ствол уводит адски + MAGNUM, // один тяжёлый выстрел: медленно, больно, ошибок не прощает + BOW, // тихий составной лук: редкий тяжёлый снаряд без дульной вспышки + COUNT, +}; + +enum class BladeId : uint8_t +{ + SABER = 0, // штатный клинок: числа из tuning.h + KNIFE, // нож: узкая дуга, зато почти без замаха + CLEAVER, // тесак: широкий, тяжёлый, сносит с ног + COUNT, }; // --- ствол ------------------------------------------------------------------- @@ -69,6 +81,10 @@ struct FirearmDef int magazine = 24; float reloadTime = 1.9f; + // Сколько снарядов уходит за один спуск. Дробины летят в ОДНОМ уже + // проверенном конусе разброса — правило 6.3 от залпа не слабеет. + int pellets = 1; + // визуал атаки (нейтральные числа: пиксели, секунды, байты RGB) float tracerLength = 1.30f; float muzzleRadiusPx = 5.0f; @@ -104,6 +120,7 @@ struct WeaponItem { WeaponClass cls = WeaponClass::RANGED; FirearmId gunId = FirearmId::RIFLE; + BladeId bladeId = BladeId::SABER; FirearmDef gun{}; BladeDef blade{}; }; @@ -180,17 +197,175 @@ inline FirearmDef PistolDef() return d; } +// Картечь: один спуск — залп дробин в общем конусе. Дальность позорная, зато +// в упор снимает столько же, сколько очередь карабина. Конус широкий всегда, +// поэтому проверка «свой на линии» отсекает выстрел куда чаще, чем у ствола — +// это не баг картечи, это её цена. +inline FirearmDef ShotgunDef() +{ + FirearmDef d; + d.name = "shotgun"; + d.range = 3.4f; + d.fireInterval = 0.85f; + d.damage = 26.0f; // на дробину + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.32f; + d.bulletSpeed = 19.0f; + d.bulletRadius = 0.05f; + d.knockback = 0.55f; + + d.spreadStandDeg = 7.5f; + d.spreadMoveDeg = 13.0f; + d.spreadRecoilDeg = 1.4f; + d.spreadRecoilMax = 5.0f; + d.recoilDecay = 5.0f; + + d.magazine = 5; + d.reloadTime = 2.6f; + d.pellets = 6; + + d.tracerLength = 0.55f; + d.muzzleRadiusPx = 7.5f; + d.muzzleTime = 0.10f; + d.muzzleFlamePx = 0.50f; + d.flashPower = 0.85f; + d.flashRadius = 5.2f; + d.shakePerShot = 1.55f; + d.tracerR = 255; d.tracerG = 190; d.tracerB = 130; + d.muzzleR = 255; d.muzzleG = 196; d.muzzleB = 110; + return d; +} + +// Шквал: патроны дешёвые, урон мизерный, но ствол не молчит. Увод копится +// быстрее, чем оседает, поэтому длинная очередь сама себя разбрасывает. +inline FirearmDef SmgDef() +{ + FirearmDef d; + d.name = "smg"; + d.range = 5.4f; + d.fireInterval = 0.09f; + d.damage = 6.0f; + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.28f; + d.bulletSpeed = 20.0f; + d.bulletRadius = 0.035f; + d.knockback = 0.16f; + + d.spreadStandDeg = 1.9f; + d.spreadMoveDeg = 7.0f; + d.spreadRecoilDeg = 0.7f; + d.spreadRecoilMax = 7.0f; + d.recoilDecay = 5.5f; + + d.magazine = 40; + d.reloadTime = 2.2f; + + d.tracerLength = 0.95f; + d.muzzleRadiusPx = 3.8f; + d.muzzleTime = 0.05f; + d.muzzleFlamePx = 0.22f; + d.flashPower = 0.38f; + d.flashRadius = 3.0f; + d.shakePerShot = 0.22f; + d.tracerR = 220; d.tracerG = 240; d.tracerB = 255; + d.muzzleR = 210; d.muzzleG = 230; d.muzzleB = 255; + return d; +} + +// Тяжёлый револьвер: бьёт дальше карабина и валит с ног, но между выстрелами +// целая секунда и в барабане шесть патронов. Оружие для того, кто попадает. +inline FirearmDef MagnumDef() +{ + FirearmDef d; + d.name = "magnum"; + d.range = 8.6f; + d.fireInterval = 0.95f; + d.damage = 78.0f; + d.laneHalfWidth = 0.10f; + d.muzzleOffset = 0.30f; + d.bulletSpeed = 30.0f; + d.bulletRadius = 0.05f; + d.knockback = 1.30f; + + d.spreadStandDeg = 0.6f; + d.spreadMoveDeg = 8.0f; // с рук на бегу — не стрелять + d.spreadRecoilDeg = 2.2f; + d.spreadRecoilMax = 6.0f; + d.recoilDecay = 3.0f; + + d.magazine = 6; + d.reloadTime = 3.0f; + + d.tracerLength = 1.85f; + d.muzzleRadiusPx = 6.5f; + d.muzzleTime = 0.09f; + d.muzzleFlamePx = 0.45f; + d.flashPower = 0.80f; + d.flashRadius = 5.0f; + d.shakePerShot = 1.90f; + d.tracerR = 255; d.tracerG = 246; d.tracerB = 226; + d.muzzleR = 255; d.muzzleG = 232; d.muzzleB = 176; + return d; +} + +inline FirearmDef BowDef() +{ + FirearmDef d; + d.name = "bow"; + d.range = 8.0f; + d.fireInterval = 0.82f; + d.damage = 54.0f; + d.laneHalfWidth = 0.08f; + d.muzzleOffset = 0.34f; + d.bulletSpeed = 15.0f; + d.bulletRadius = 0.035f; + d.knockback = 0.72f; + d.spreadStandDeg = 0.7f; + d.spreadMoveDeg = 7.5f; + d.spreadRecoilDeg = 0.0f; + d.spreadRecoilMax = 0.0f; + d.recoilDecay = 8.0f; + d.magazine = 1; + d.reloadTime = 0.72f; + d.tracerLength = 0.38f; + d.muzzleRadiusPx = 0.0f; + d.muzzleTime = 0.0f; + d.muzzleFlamePx = 0.0f; + d.flashPower = 0.0f; + d.flashRadius = 0.0f; + d.shakePerShot = 0.18f; + d.tracerR = 178; d.tracerG = 205; d.tracerB = 180; + d.muzzleR = 0; d.muzzleG = 0; d.muzzleB = 0; + return d; +} + inline FirearmDef FirearmDefOf(FirearmId id) { - return (id == FirearmId::PISTOL) ? PistolDef() : RifleDef(); + switch (id) + { + case FirearmId::PISTOL: return PistolDef(); + case FirearmId::SHOTGUN: return ShotgunDef(); + case FirearmId::SMG: return SmgDef(); + case FirearmId::MAGNUM: return MagnumDef(); + case FirearmId::BOW: return BowDef(); + default: return RifleDef(); + } } inline const char* FirearmName(FirearmId id) { - return (id == FirearmId::PISTOL) ? "PISTOL" : "RIFLE"; + switch (id) + { + case FirearmId::PISTOL: return "PISTOL"; + case FirearmId::SHOTGUN: return "SHOTGUN"; + case FirearmId::SMG: return "SMG"; + case FirearmId::MAGNUM: return "MAGNUM"; + case FirearmId::BOW: return "BOW"; + default: return "RIFLE"; + } } -// Клинок пока один, и его числа целиком в tuning. +// Штатный клинок: числа целиком в tuning, ручки оверлея крутят именно его. inline BladeDef DefaultBladeDef() { BladeDef d; @@ -205,3 +380,59 @@ inline BladeDef DefaultBladeDef() d.lungeSpeed = g_tune.meleeLungeSpeed; return d; } + +// Нож: узкая дуга и почти нет замаха. В тесноте это решает — сектор шириной +// в ладонь почти всегда чист, тогда как тесак некуда занести. +inline BladeDef KnifeDef() +{ + BladeDef d; + d.name = "knife"; + d.reach = 1.00f; + d.arcDeg = 55.0f; + d.windup = 0.08f; + d.strike = 0.09f; + d.recover = 0.13f; + d.interval = 0.34f; + d.damage = 62.0f; + d.knockback = 0.9f; + d.lungeSpeed = 4.2f; + return d; +} + +// Тесак: широкий замах, тяжёлый урон, долгое восстановление. Своих распугивает +// сам по себе — резерв сектора у него больше всех. +inline BladeDef CleaverDef() +{ + BladeDef d; + d.name = "cleaver"; + d.reach = 1.65f; + d.arcDeg = 135.0f; + d.windup = 0.30f; + d.strike = 0.18f; + d.recover = 0.42f; + d.interval = 0.95f; + d.damage = 240.0f; + d.knockback = 4.6f; + d.lungeSpeed = 2.8f; + return d; +} + +inline BladeDef BladeDefOf(BladeId id) +{ + switch (id) + { + case BladeId::KNIFE: return KnifeDef(); + case BladeId::CLEAVER: return CleaverDef(); + default: return DefaultBladeDef(); + } +} + +inline const char* BladeName(BladeId id) +{ + switch (id) + { + case BladeId::KNIFE: return "KNIFE"; + case BladeId::CLEAVER: return "CLEAVER"; + default: return "SABER"; + } +} diff --git a/src/tuning.h b/src/tuning.h index f534a73..5d99176 100644 --- a/src/tuning.h +++ b/src/tuning.h @@ -1,41 +1,53 @@ #pragma once #include + +#include "engine/config.h" // ----------------------------------------------------------------------------- -// Единственное место, где живут числа прототипа. +// Единственное место, где живут числа ИГРЫ. // В коде не должно быть магических констант — всё берётся отсюда. // -// * блок COMPILE-TIME — размеры буферов/карты, менять только пересборкой; +// * блок COMPILE-TIME — размеры буферов игры, менять только пересборкой; // * struct Tuning — всё, что подкручивается в рантайме ([ ] выбор, - = шаг). +// +// Размеры кадра, тайла и карты сюда БОЛЬШЕ НЕ ВХОДЯТ: это хозяйство движка, оно +// в engine/config.h. Имена те же, файл включён выше — по коду игры ничего не +// меняется, но движок теперь собирается без единого файла игры. // ----------------------------------------------------------------------------- // --- compile-time константы -------------------------------------------------- -constexpr int INTERNAL_W = 480; // внутреннее разрешение фреймбуфера -constexpr int INTERNAL_H = 270; -constexpr int WINDOW_SCALE = 3; // целочисленный апскейл -constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE; -constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE; - -constexpr int TILE_W = 32; // ромб пола -constexpr int TILE_H = 16; -constexpr int TILE_HALF_W = TILE_W / 2; -constexpr int TILE_HALF_H = TILE_H / 2; -constexpr int WALL_H = 32; // спрайт стены 32x32 (верхний ромб + грани) - -constexpr float FIXED_DT = 1.0f / 60.0f; // шаг симуляции -constexpr int MAX_STEPS_PER_FRAME = 5; // защита от спирали смерти - -constexpr int MAP_W = 48; -constexpr int MAP_H = 48; - // Точка старта отряда (центральная арена). constexpr float SQUAD_START_X = 26.5f; // ближе к кучке: дальность стала 7.2 constexpr float SQUAD_START_Y = 26.5f; constexpr int NUM_AGENTS = 5; -constexpr int NUM_TARGETS = 12; +constexpr int NUM_TARGETS = 12; // мишени приёмки: расстановка захардкожена constexpr int MAX_BULLETS = 512; +// Твари, которых боевой режим доставляет СВЕРХ мишеней. В песочнице приёмки их +// нет: расстановка мишеней и все замеры на ней остаются прежними. +constexpr int NUM_EXTRA_ENEMIES = 12; + +// Разрушаемые объекты (ящики, бочки, шкафчики). Спавнятся ТОЛЬКО в боевом режиме; +// приёмка их не видит. Приоритет — ниже всех: ИИ переключается на них только +// если ни одного живого врага в поле зрения нет. +constexpr int NUM_PROPS = 8; + +// Потолок на число целей в мире. Он не «на всякий случай»: MeleeWeapon::hitMask — +// это маска «кого уже задел ЭТИМ взмахом», индексируемая номером цели, и цель +// за её пределами молча получала бы удары дважды за взмах. +// +// Было 32. Игра про бесконечный спуск упёрлась в этот потолок примерно на +// шестнадцатом этаже: бюджет опасности перестал влезать в число тел, и глубже +// этажи переставали становиться страшнее. Маска расширена до 64 бит — это не +// «запас на будущее», а ровно та цифра, которой сейчас не хватало. +constexpr int MAX_TARGETS = 64; +static_assert(NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS <= MAX_TARGETS, + "целей больше, чем битов в MeleeWeapon::hitMask"); +// У движка потолок населения свой (MAX_LEVEL_SPAWNS в engine/level_gen.h): +// генератор уровней не обязан знать про маску удара клинком. Что одно влезает +// в другое, проверяется там, где видны оба, — в src/game.h. + // Сколько слотов строя занимают бойцы ближнего боя (слоты 0..N-1). // В клине это остриё и левое крыло — те, кто и так идёт первым. constexpr int DEFAULT_MELEE_COUNT = 2; @@ -140,6 +152,64 @@ struct Tuning // серию заново с первого удара. float meleeComboWindow = 1.10f; + // --- живучесть отряда и тварей ------------------------------------------- + // У мишеней приёмки эти числа не работают: DUMMY не бьёт и не ходит, + // поэтому --accept от них не зависит вообще. + float agentHP = 100.0f; // запас бойца до брони и оберегов + float enemyDamageScale = 1.0f; // общая сложность: во сколько раз больно + float enemyRespawn = 16.0f; // через сколько тварь возвращается + float enemyLightPower = 0.14f; // как ярко тлеют глаза в темноте + // Сколько цель «светится» после попадания. Единственный признак, по которому + // в темноте видно, что отряд по кому-то реально работает, а не мажет. + float targetHurtTime = 0.22f; + + // --- расход ------------------------------------------------------------------ + // Патроны на вылазку. Не «на этаж»: запас едет вниз вместе с отрядом, и + // решение «ещё глубже или наверх» упирается именно в него. + float startAmmo = 120.0f; + // При какой доле здоровья боец сам возьмётся за бинт и на каком удалении + // от ближайшей твари это ему позволено. Лечиться в бою нельзя: перевязка + // посреди схватки обесценила бы урон. + float healAtFrac = 0.55f; + float healSafeDist = 9.0f; + + // --- спуск, находки, опыт --------------------------------------------------- + // Ничего из этого не работает в песочнице приёмки: там нет ни лута, ни + // спуска, и `--accept` этих чисел не касается. + // + // Радиус подбора заметно больше тела бойца: подбор пассивный, и промазать + // мимо лежащего предмета, пройдя вплотную, игрок не должен — он ведь по + // нему не кликал. + float lootPickupRadius = 0.65f; + // На каком расстоянии от точки спуска отряд считается дошедшим. Меряется + // по ЯКОРЮ, а не по бойцам: спуск — это решение игрока, а не то, что + // случается от того, что один боец забрёл не туда. + float descendRadius = 1.10f; + // Сколько опыта даёт единица опасности убитого. Опыт этажа сверх этого + // множится на xpScale из кривой глубины (engine/progression.h). + float xpPerDanger = 10.0f; + + // --- разрушаемая обстановка ------------------------------------------------ + // Радиус, в котором ОДНО ПРИСУТСТВИЕ живой твари запрещает бойцу работать + // по хламу. Намеренно шире дальности любого ствола: привязать его к дальности + // значило бы «пока не достаю — стреляю по ящику», и боец встречал бы + // подошедшую тварь с пустым магазином. Ящик ломают, когда вокруг ЧИСТО. + float propYieldRadius = 15.0f; + + // --- атаки тварей --------------------------------------------------------- + // Форма каждой атаки (сектор, радиус круга, длина рывка) — свойство ВИДА и + // живёт в EnemyDef (sim/target.cpp). Здесь только то, что общее для всех: + // именно этими двумя ручками правится «успеваю ли я среагировать». + // + // enemyWindupScale — множитель ВСЕХ телеграфов сразу. Опускать его ниже + // ~0.5 нечестно: замах становится короче времени реакции, и смерть бойца + // снова выглядит случайной. + float enemyWindupScale = 1.0f; + // Сколько захват держится без подтверждения от твари. Тварь каждый шаг + // продлевает его на эту величину; убили её — боец освобождается сам, и + // ни bullet.cpp, ни клинку не приходится знать про захват вообще. + float enemyGrabHold = 0.15f; + // мишени float targetHP = 500.0f; float targetRespawn = 3.0f; @@ -412,6 +482,13 @@ inline const TunableParam TUNABLES[] = { {"reloadTime", &Tuning::reloadRifle, 0.05f, 0.1f, 8.0f}, {"bulletKnock", &Tuning::bulletKnock, 0.05f, 0.0f, 6.0f}, {"knockResist", &Tuning::knockResistScale, 0.05f, 0.0f, 2.0f}, + // --- живучесть и сложность --- + {"agentHP", &Tuning::agentHP, 5.0f, 10.0f, 500.0f}, + {"enemyDamage", &Tuning::enemyDamageScale, 0.05f, 0.0f, 4.0f}, + {"enemyRespawn", &Tuning::enemyRespawn, 1.0f, 1.0f, 300.0f}, + {"enemyWindup", &Tuning::enemyWindupScale, 0.05f, 0.25f, 3.0f}, + {"enemyGrabHold", &Tuning::enemyGrabHold, 0.01f, 0.02f, 1.0f}, + {"propYield", &Tuning::propYieldRadius, 0.5f, 0.0f, 64.0f}, // --- ближний бой --- {"meleeReach", &Tuning::meleeReach, 0.05f, 0.4f, 4.0f}, {"meleeArc", &Tuning::meleeArcDeg, 5.0f, 20.0f, 300.0f}, diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp new file mode 100644 index 0000000..cdf3992 --- /dev/null +++ b/src/ui/menu.cpp @@ -0,0 +1,1279 @@ +#include "ui/menu.h" + +#include + +#include +#include + +#include "game.h" + +namespace +{ + +// --- палитра ----------------------------------------------------------------- +const Color COL_DIM = {6, 6, 8, 218}; +const Color COL_TEXT = {206, 200, 190, 255}; +const Color COL_FAINT = {110, 106, 100, 255}; +const Color COL_PICK = {236, 226, 206, 255}; +const Color COL_BLOOD = {150, 44, 40, 255}; +const Color COL_DEAD = {60, 58, 56, 255}; // пункт, который сейчас не работает +const Color COL_EDGE = {58, 54, 52, 255}; // рамка спокойной ячейки +const Color COL_HOVER = {74, 40, 34, 130}; // подложка под курсором +const Color COL_REJECT = {150, 40, 34, 150}; // слот не примет то, что тащат +const Color COL_SHADOW = {8, 6, 6, 220}; // контур курсора + +// ----------------------------------------------------------------------------- +// Кадр 480x270 лежит в окне со сдвигом и ЦЕЛЫМ масштабом (ScreenFitRect), +// поэтому вся разметка ниже задана в пикселях кадра и переводится в окно только +// здесь. Считать в пикселях окна нельзя: на каждом мониторе множитель свой, +// и меню разъедется с картинкой. +// ----------------------------------------------------------------------------- +struct Layout +{ + int x = 0, y = 0; // левый верхний угол кадра в окне + int u = 1; // размер «пикселя» кадра в пикселях окна + int w = 0, h = 0; // размер кадра в пикселях окна + int fw = 0, fh = 0; // размер кадра в его собственных пикселях +}; + +Layout Of(const ScreenRect& fit) +{ + Layout l; + l.u = (fit.scale > 0) ? fit.scale : 1; + l.x = fit.x; + l.y = fit.y; + l.w = fit.w; + l.h = fit.h; + l.fw = fit.w / l.u; + l.fh = fit.h / l.u; + 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 TextW(const Layout& l, const char* s, int size) +{ + return MeasureText(s, size * l.u) / l.u; +} + +void TextCentered(const Layout& l, const char* s, int fy, int size, Color c) +{ + Text(l, s, (l.fw - TextW(l, s, size)) / 2, fy, size, c); +} + +void Box(const Layout& l, const UiRect& 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 UiRect& 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); +} + +void Shade(const Layout& l, Color c) +{ + DrawRectangle(l.x, l.y, l.w, l.h, c); +} + +// Полоска-разделитель под заголовком. +void Rule(const Layout& l, int fy, int halfWidth) +{ + Box(l, UiRect{l.fw / 2 - halfWidth, fy, halfWidth * 2, 1}, COL_BLOOD); +} + +// ----------------------------------------------------------------------------- +// Сетка экранов. Все числа — в пикселях кадра и живут ровно здесь; сами +// прямоугольники собираются один раз в BuildWidgets, а рисование и попадание +// мыши читают уже готовую таблицу. Разъехаться подсветке и клику негде. +// ----------------------------------------------------------------------------- +constexpr int BTN_PAD_X = 10; // поле кнопки вокруг подписи +constexpr int BTN_PAD_Y = 4; + +constexpr int MENU_Y0 = 122, MENU_STEP = 20, MENU_SIZE = 12; +constexpr int PAUSE_Y0 = 132, PAUSE_STEP = 20, PAUSE_SIZE = 11; +constexpr int DEFEAT_Y0 = 172, DEFEAT_STEP = 22, DEFEAT_SIZE = 11; + +// Экран отряда: карточки бойцов, три слота снаряжения, сетка сумки. +constexpr int SQ_CARD_X = 22, SQ_CARD_Y = 40, SQ_CARD_W = 76, SQ_CARD_H = 30; +constexpr int SQ_CARD_STEP = 88; + +constexpr int SQ_EQUIP_X = 34, SQ_EQUIP_Y = 88, SQ_EQUIP_W = 180, SQ_EQUIP_H = 20; +constexpr int SQ_EQUIP_STEP = 23; +constexpr int SQ_LABEL_W = 46; // место под слово HANDS/ARMOR/POCKET внутри слота + +constexpr int SQ_STAT_X = 40, SQ_STAT_Y = 158, SQ_STAT_STEP = 9; + +// Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому +// что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы +// покупкой кота в мешке. +constexpr int UP_X = 60, UP_Y = 70, UP_W = 360, UP_H = 26, UP_STEP = 32; +constexpr int SQ_STAT_LINES = 4; + +// Сумка общая, и ячеек в ней шестнадцать вместо шести на бойца. В два столбца +// они не влезают, поэтому сетка 4x4 клетками поменьше: имя вещи в такую клетку +// не помещается, зато помещается иконка — а что это, теперь говорит подсказка +// под курсором. +constexpr int SQ_BAG_X = 262, SQ_BAG_Y = 100, SQ_BAG_W = 46, SQ_BAG_H = 20; +constexpr int SQ_BAG_GX = 5, SQ_BAG_GY = 4, SQ_BAG_COLS = 4; + +constexpr int SQ_HINT_Y = 250, SQ_HINT_STEP = 10; + +// Схрон: то, что лежит на базе между вылазками. Двумя рядами во всю ширину +// внизу, а не колонкой сбоку: в схроне десятки предметов, и подпись каждого +// должна читаться — иначе выбор снаряжения превращается в перебор иконок. +constexpr int SQ_STASH_X = 22, SQ_STASH_Y = 208, SQ_STASH_W = 52, SQ_STASH_H = 18; +constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 8, SQ_STASH_ROWS = 2; +constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS; + +// Выбор чекпоинта: столбец кнопок «начать с этажа N». +constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; +constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает + +constexpr int SUM_Y0 = 214, SUM_STEP = 20, SUM_SIZE = 11; + +const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD", "UPGRADES", "QUIT"}; +constexpr int MENU_COUNT = 5; + +const char* const PAUSE_ITEMS[] = {"RESUME", "SQUAD", "MENU"}; +constexpr int PAUSE_COUNT = 3; + +const char* const DEFEAT_ITEMS[] = {"TRY AGAIN", "MENU"}; +constexpr int DEFEAT_COUNT = 2; + +const char* const SUMMARY_ITEMS[] = {"CONTINUE"}; +constexpr int SUMMARY_COUNT = 1; + +const char* const EQUIP_LABELS[] = {"HANDS", "ARMOR", "POCKET"}; +constexpr UiPart EQUIP_PARTS[] = {UiPart::HANDS, UiPart::ARMOR, UiPart::POCKET}; +constexpr int EQUIP_COUNT = 3; + +// ----------------------------------------------------------------------------- +// Таблица интерактивных элементов текущего экрана. +// ----------------------------------------------------------------------------- +struct Widget +{ + UiPart part = UiPart::NONE; + int index = 0; + UiRect rect; + const char* text = nullptr; // подпись кнопки; у ячеек её нет + int size = 0; // кегль подписи в пикселях кадра + ItemId item = ItemId::NONE; // что лежит в ячейке + bool enabled = true; +}; + +struct Widgets +{ + // 5 карточек + 3 слота + 16 ячеек общей сумки + 4 строки статов + + // 16 ячеек схрона + кнопки экранов. + // + // Считать это «с запасом» нельзя: при переполнении лишние виджеты молча + // не создаются, и экран теряет ХВОСТ — так после перехода на общую сумку + // из схрона пропали последние ячейки, а выглядело это как ошибка вёрстки. + static constexpr int MAX = 64; + static_assert(MAX >= NUM_AGENTS + 3 + SQUAD_BAG_SLOTS + SQ_STASH_CELLS + 8, + "виджетов экрана отряда больше, чем помещается"); + Widget item[MAX]; + int count = 0; + + Widget& Add(UiPart part, int index, const UiRect& r) + { + Widget& w = item[(count < MAX) ? count : (MAX - 1)]; + if (count < MAX) ++count; + w = Widget{}; + w.part = part; + w.index = index; + w.rect = r; + return w; + } + + const Widget* At(float fx, float fy) const + { + for (int i = 0; i < count; ++i) + if (item[i].rect.Has(fx, fy)) return &item[i]; + return nullptr; + } + + const Widget* Find(UiPart part, int index) const + { + for (int i = 0; i < count; ++i) + if (item[i].part == part && item[i].index == index) return &item[i]; + return nullptr; + } +}; + +// Кнопка по центру кадра: прямоугольник считается по реальной ширине подписи, +// подпись потом рисуется ИЗ него же (rect + поле), а не отдельным центрированием. + +// ----------------------------------------------------------------------------- +// Подсказка под курсором. +// +// Без неё предмет — это имя и цветной квадратик, и что даёт VEST против JACKET, +// узнать было неоткуда: числа лежали в каталоге кода. С общей сумкой на +// шестнадцать мелких ячеек это перестало быть терпимым — в ячейку не влезает +// даже имя. +// +// Тексты латиницей: встроенный шрифт raylib кириллицы не знает и рисует её +// вопросами (CLAUDE.md). Это подпись НА ЭКРАНЕ, а не вывод в консоль. +// ----------------------------------------------------------------------------- +constexpr int TIP_LINES_MAX = 7; + +struct Tip +{ + char lines[TIP_LINES_MAX][40] = {}; + int count = 0; + + void Add(const char* fmt, ...) + { + if (count >= TIP_LINES_MAX) return; + va_list args; + va_start(args, fmt); + vsnprintf(lines[count], sizeof(lines[0]), fmt, args); + va_end(args); + ++count; + } +}; + +const char* KindName(ItemKind k) +{ + switch (k) + { + case ItemKind::FIREARM: return "FIREARM"; + case ItemKind::BLADE: return "BLADE"; + case ItemKind::ARMOR: return "ARMOR"; + case ItemKind::TRINKET: return "TRINKET"; + case ItemKind::CONSUMABLE: return "SUPPLY"; + default: return "-"; + } +} + +// Что вещь делает. Только те строки, которые у неё не нулевые: список из шести +// «+0%» читается как «ничего не даёт», хотя даёт. +Tip ItemTip(ItemId id) +{ + const ItemDef d = ItemDefOf(id); + + Tip t; + t.Add("%s (%s)", d.name, KindName(d.kind)); + t.Add("WEIGHT %.1f", double(d.weight)); + + if (d.armor > 0.0f) t.Add("ARMOR +%d%%", int(d.armor * 100.0f + 0.5f)); + if (d.speedMul != 1.0f) t.Add("SPEED %+d%%", int((d.speedMul - 1.0f) * 100.0f)); + if (d.hpBonus > 0.0f) t.Add("HP +%d", int(d.hpBonus)); + if (d.sightMul != 1.0f) t.Add("SIGHT %+d%%", int((d.sightMul - 1.0f) * 100.0f)); + if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal)); + if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds); + return t; +} + +Tip StatTip(int line, const Agent& a) +{ + Tip t; + switch (line) + { + case 0: + t.Add("HEALTH"); + t.Add("CARRIES DOWN BETWEEN FLOORS."); + t.Add("BANDAGE IS USED WHEN SAFE."); + break; + case 1: + t.Add("ARMOR"); + t.Add("SHARE OF DAMAGE ABSORBED,"); + t.Add("CAPPED AT %d%%.", int(ARMOR_CAP * 100.0f)); + break; + case 2: + t.Add("SPEED AND LOAD"); + t.Add("GEAR AND WEIGHT BOTH SLOW YOU."); + t.Add("FREE UNTIL %.1f, CRAWL AT %.0f.", double(CARRY_LIMIT), double(CARRY_MAX)); + t.Add("SQUAD BAG SPLITS OVER SURVIVORS."); + break; + default: + t.Add("SIGHT"); + t.Add("HOW FAR THIS FIGHTER SEES."); + t.Add("DEPTH SHRINKS IT FOR EVERYONE."); + break; + } + (void)a; + return t; +} + +void DrawTip(const Layout& l, const Tip& t, int mx, int my) +{ + if (t.count <= 0) return; + + const int pad = 4; + const int step = 9; + int wide = 0; + for (int i = 0; i < t.count; ++i) wide = std::max(wide, TextW(l, t.lines[i], 8)); + + UiRect box{mx + 8, my + 8, wide + pad * 2, t.count * step + pad * 2 - 2}; + // Подсказка не должна вылезать за кадр: у края экрана она переезжает на + // другую сторону курсора, а не обрезается. + if (box.x + box.w > INTERNAL_W - 2) box.x = mx - 8 - box.w; + if (box.y + box.h > INTERNAL_H - 2) box.y = INTERNAL_H - 2 - box.h; + if (box.x < 2) box.x = 2; + if (box.y < 2) box.y = 2; + + Box(l, box, Color{10, 10, 13, 245}); + BoxLines(l, box, COL_EDGE); + for (int i = 0; i < t.count; ++i) + Text(l, t.lines[i], box.x + pad, box.y + pad + i * step, 8, + (i == 0) ? COL_TEXT : COL_FAINT); +} + +UiRect Button(const Layout& l, const char* s, int fy, int size) +{ + UiRect r; + r.w = TextW(l, s, size) + BTN_PAD_X * 2; + r.h = size + BTN_PAD_Y * 2; + r.x = (l.fw - r.w) / 2; + r.y = fy - BTN_PAD_Y; + return r; +} + +UiRect BagRect(int slot) +{ + return UiRect{SQ_BAG_X + (slot % SQ_BAG_COLS) * (SQ_BAG_W + SQ_BAG_GX), + SQ_BAG_Y + (slot / SQ_BAG_COLS) * (SQ_BAG_H + SQ_BAG_GY), SQ_BAG_W, SQ_BAG_H}; +} + +UiRect StashRect(int cell) +{ + return UiRect{SQ_STASH_X + (cell % SQ_STASH_COLS) * (SQ_STASH_W + SQ_STASH_GX), + SQ_STASH_Y + (cell / SQ_STASH_COLS) * (SQ_STASH_H + SQ_STASH_GY), SQ_STASH_W, + SQ_STASH_H}; +} + +void AddButtons(Widgets& out, const Layout& l, UiPart part, const char* const* names, int count, + int y0, int step, int size) +{ + for (int i = 0; i < count; ++i) + { + Widget& w = out.Add(part, i, Button(l, names[i], y0 + i * step, size)); + w.text = names[i]; + w.size = size; + } +} + +// Единственное место, где рождаются прямоугольники. И Update, и Draw зовут его +// с одним и тем же Layout, поэтому видят ровно одну разметку. +void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) +{ + out.count = 0; + switch (ui.screen) + { + case Screen::GAME: + return; + + case Screen::MENU: + AddButtons(out, l, UiPart::MENU_ITEM, MENU_ITEMS, MENU_COUNT, MENU_Y0, MENU_STEP, + MENU_SIZE); + // «Продолжить» до начала вылазки ведёт в никуда: пункт лучше + // погасить, чем показывать игроку тупик. + out.item[0].enabled = ui.started; + return; + + case Screen::PAUSE: + AddButtons(out, l, UiPart::PAUSE_ITEM, PAUSE_ITEMS, PAUSE_COUNT, PAUSE_Y0, PAUSE_STEP, + PAUSE_SIZE); + return; + + case Screen::DEFEAT: + AddButtons(out, l, UiPart::DEFEAT_ITEM, DEFEAT_ITEMS, DEFEAT_COUNT, DEFEAT_Y0, + DEFEAT_STEP, DEFEAT_SIZE); + return; + + case Screen::SUMMARY: + AddButtons(out, l, UiPart::SUMMARY_ITEM, SUMMARY_ITEMS, SUMMARY_COUNT, SUM_Y0, SUM_STEP, + SUM_SIZE); + return; + + case Screen::UPGRADE: + for (int i = 0; i < int(Upgrade::COUNT); ++i) + out.Add(UiPart::UPGRADE_ITEM, i, UiRect{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H}); + return; + + case Screen::DEPTH: + { + // Чекпоинты открываются достигнутой глубиной. Показываются самые + // глубокие: начинать сверху при открытом двадцатом этаже никто не + // будет, а место на экране конечно. + const int total = game.profile ? game.profile->CheckpointCount() : 1; + const int shown = (total < DEPTH_MAX_SHOWN) ? total : DEPTH_MAX_SHOWN; + const int first = total - shown; + + for (int i = 0; i < shown; ++i) + { + const int d = Profile::CheckpointDepth(first + i); + static char labels[DEPTH_MAX_SHOWN][16]; + std::snprintf(labels[i], sizeof(labels[i]), "DEPTH %d", d); + Widget& wg = out.Add(UiPart::DEPTH_ITEM, d, + Button(l, labels[i], DEPTH_Y0 + i * DEPTH_STEP, DEPTH_SIZE)); + wg.text = labels[i]; + wg.size = DEPTH_SIZE; + } + return; + } + + case Screen::SQUAD: + { + for (int i = 0; i < NUM_AGENTS; ++i) + { + Widget& w = out.Add(UiPart::AGENT, i, + UiRect{SQ_CARD_X + i * SQ_CARD_STEP, SQ_CARD_Y, SQ_CARD_W, + SQ_CARD_H}); + w.item = game.squad.agents[i].held; + } + + const Agent& a = game.squad.agents[ui.agentIndex]; + const Inventory* inv = game.world.Get(a.self); + if (!inv) return; + + const ItemId worn[EQUIP_COUNT] = {inv->weapon, inv->armor, inv->trinket}; + for (int i = 0; i < EQUIP_COUNT; ++i) + { + Widget& w = out.Add(EQUIP_PARTS[i], 0, + UiRect{SQ_EQUIP_X, SQ_EQUIP_Y + i * SQ_EQUIP_STEP, SQ_EQUIP_W, + SQ_EQUIP_H}); + w.text = EQUIP_LABELS[i]; + w.item = worn[i]; + } + + // Сумка ОБЩАЯ: она не зависит от выбранного бойца, и переложить + // из неё может любой (sim/items.h). + for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) + { + Widget& w = out.Add(UiPart::BAG, s, BagRect(s)); + w.item = game.squad.bag.slots[s]; + } + + // Строки характеристик тоже наводимы. Читать «SPEED 95%» и гадать, + // почему 95, — это не информация, а загадка; подсказка отвечает. + for (int i = 0; i < SQ_STAT_LINES; ++i) + out.Add(UiPart::STAT, i, + UiRect{SQ_STAT_X - 3, SQ_STAT_Y + i * SQ_STAT_STEP - 1, 150, + SQ_STAT_STEP}); + + // Схрон. Ячейки за концом списка тоже интерактивны: именно в них + // предмет КЛАДЁТСЯ, и без них схрон можно было бы только разбирать. + if (game.profile) + { + for (int c = 0; c < SQ_STASH_CELLS; ++c) + { + const int k = ui.stashScroll + c; + Widget& w = out.Add(UiPart::STASH, k, StashRect(c)); + w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)] + : ItemId::NONE; + } + } + return; + } + } +} + +bool Hovered(const Ui& ui, const Widget& w) +{ + return ui.hoverPart == w.part && ui.hoverIndex == w.index; +} + +// ----------------------------------------------------------------------------- +// Правила инвентаря. Класс предмета должен подходить слоту, и это проверяется +// в ОБЕ стороны: предмет уезжает в новую ячейку, а лежавшее там — в старую. +// Без второй проверки перетаскивание карабина на бинт оставило бы бойца +// с бинтом в руках. +// ----------------------------------------------------------------------------- +bool IsSlot(UiPart p) +{ + return p == UiPart::HANDS || p == UiPart::ARMOR || p == UiPart::POCKET || + p == UiPart::BAG || p == UiPart::STASH; +} + +bool SlotAccepts(UiPart part, ItemId id) +{ + switch (part) + { + // Пустых рук не бывает: отряд не носит воздух. ItemId::NONE — не оружие, + // поэтому проверка на класс заодно закрывает и этот случай. + case UiPart::HANDS: return IsWeaponItem(id); + case UiPart::ARMOR: return id == ItemId::NONE || ItemDefOf(id).kind == ItemKind::ARMOR; + case UiPart::POCKET: return id == ItemId::NONE || ItemDefOf(id).kind == ItemKind::TRINKET; + case UiPart::BAG: return true; // сумка берёт что угодно, на то она и сумка + case UiPart::STASH: return true; // схрон тем более + default: return false; + } +} + +bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there) +{ + return SlotAccepts(to, moved) && SlotAccepts(from, there); +} + +// Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на +// ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него +// всё это по-прежнему «слот, в котором лежит вещь». +ItemId* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index) +{ + switch (part) + { + case UiPart::HANDS: return &inv.weapon; + case UiPart::ARMOR: return &inv.armor; + case UiPart::POCKET: return &inv.trinket; + case UiPart::BAG: + return (index >= 0 && index < SQUAD_BAG_SLOTS) ? &bag.slots[index] : nullptr; + default: return nullptr; + } +} + +// Схрон живёт не в Inventory, а в профиле, и ячейка за концом списка — это +// место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно: +// указателя на несуществующую ячейку не бывает. +ItemId StashTake(Profile& pr, int index) +{ + if (index < 0 || index >= pr.StashCount()) return ItemId::NONE; + const ItemId out = pr.stash[size_t(index)]; + pr.StashRemoveAt(index); + return out; +} + +// false — схрон полон и предмет остался на месте. Молча терять его нельзя. +bool StashPut(Profile& pr, int index, ItemId id) +{ + if (id == ItemId::NONE) return true; + if (index >= 0 && index < pr.StashCount()) + { + pr.stash.insert(pr.stash.begin() + long(index), id); + return int(pr.stash.size()) <= STASH_CAP || (pr.stash.pop_back(), false); + } + return pr.StashPush(id); +} + +// Выбросить вещь под ноги бойцу. Без этого решение о нагрузке одностороннее: +// набрал — и тащи, а «что бросить» превращается в «нечего было брать». +// +// Из РУК бросать нельзя: безоружный боец не бежит быстрее, он просто перестаёт +// быть бойцом. Ствол сначала меняют на что-то другое, и только потом бросают. +// Броню и карман — можно: снять плиту, чтобы дойти до лестницы, это ровно то +// решение, ради которого вес и заводился. +void DropToFloor(Ui& ui, Game& game, UiPart part, int index) +{ + if (part == UiPart::HANDS || part == UiPart::STASH) return; + + Agent& a = game.squad.agents[ui.agentIndex]; + Inventory* inv = game.world.Get(a.self); + if (!inv) return; + + ItemId* slot = SlotPtr(*inv, game.squad.bag, part, index); + if (!slot || *slot == ItemId::NONE) return; + + // Задержка подбора обязательна: вещь ложится там же, где стоит боец, и + // без неё тот же шаг поднял бы её обратно. + SpawnFloorItem(game.world, *slot, a.pos, 2.0f); + *slot = ItemId::NONE; +} + +void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) +{ + Agent& a = game.squad.agents[ui.agentIndex]; + Inventory* inv = game.world.Get(a.self); + if (!inv) return; + + const bool fromStash = (ui.dragFrom == UiPart::STASH); + const bool toStash = (to == UiPart::STASH); + + if (fromStash || toStash) + { + if (!game.profile) return; + Profile& pr = *game.profile; + + if (fromStash && toStash) + { + // Перекладывание внутри схрона: порядок ячеек — единственное, чем + // игрок может им управлять, и ломать его незачем. + const ItemId moved = StashTake(pr, ui.dragIndex); + StashPut(pr, toIndex, moved); + return; + } + + ItemId* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom, + fromStash ? toIndex : ui.dragIndex); + if (!slot) return; + + if (fromStash) + { + // Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если + // схрон переполнен, обмен не состоится целиком — иначе вещь из + // слота исчезла бы. + const ItemId taken = pr.stash.empty() ? ItemId::NONE : StashTake(pr, ui.dragIndex); + if (taken == ItemId::NONE) return; + const ItemId back = *slot; + if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; } + *slot = taken; + } + else + { + const ItemId moved = *slot; + if (moved == ItemId::NONE) return; + if (!StashPut(pr, toIndex, moved)) return; // схрон полон + *slot = ItemId::NONE; + } + + // Руки могли опустеть или наполниться — сущность-оружие обязана + // поменяться вместе с ними. + if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS) + EquipWeapon(game.world, a, inv->weapon); + return; + } + + ItemId* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex); + ItemId* dst = SlotPtr(*inv, game.squad.bag, to, toIndex); + if (!src || !dst || src == dst) return; + + const ItemId moved = *src; + *src = *dst; + *dst = moved; + + // Руки поменялись — обязана поменяться и сущность-оружие. Иначе имя новое, + // а статы, класс и правила стрельбы остались от прежнего ствола. + if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS) + EquipWeapon(game.world, a, inv->weapon); +} + +// ----------------------------------------------------------------------------- +// Действия кнопок. Один вход и для клика, и для ENTER: иначе мышь и клавиатура +// начнут делать «почти одно и то же». +// ----------------------------------------------------------------------------- +void Activate(Ui& ui, Game& game, UiPart part, int index) +{ + switch (part) + { + case UiPart::UPGRADE_ITEM: + // Покупка молча не проваливается: не хватило опыта — уровень + // остался прежним, и это видно на той же строке. + if (game.profile) game.profile->Buy(Upgrade(index)); + return; + + case UiPart::MENU_ITEM: + switch (index) + { + case 0: if (ui.started) ui.screen = Screen::GAME; break; + case 1: + // Вылазка начинается с выбора глубины: с открытых + // чекпоинтов. Это и есть ответ на «а если этажей десять + // тысяч» — проходить их заново каждый раз не нужно. + ui.depthIndex = 0; + ui.screen = Screen::DEPTH; + break; + case 2: ui.screen = Screen::SQUAD; break; + default: ui.quit = true; break; + } + return; + + case UiPart::PAUSE_ITEM: + if (index == 0) ui.screen = Screen::GAME; + else if (index == 1) ui.screen = Screen::SQUAD; + else ui.screen = Screen::MENU; + return; + + case UiPart::DEPTH_ITEM: + game.combatMode = true; + game.NewRun(index); // index — глубина чекпоинта, а не номер кнопки + ui.worldReset = true; + ui.started = true; + ui.screen = Screen::GAME; + return; + + case UiPart::SUMMARY_ITEM: + // Забег кончился, мира больше нет: «продолжить» ведёт только в меню. + ui.started = false; + ui.screen = Screen::MENU; + return; + + case UiPart::DEFEAT_ITEM: + if (index == 0) + { + game.Reset(); + ui.worldReset = true; + ui.screen = Screen::GAME; + } + else + ui.screen = Screen::MENU; + return; + + default: + return; + } +} + +bool Pressed(int key) { return IsKeyPressed(key); } + +// Все списки листаются одинаково: стрелки и WS. +int Step() +{ + int d = 0; + if (Pressed(KEY_DOWN) || Pressed(KEY_S)) d += 1; + if (Pressed(KEY_UP) || Pressed(KEY_W)) d -= 1; + return d; +} + +} // namespace + +// ----------------------------------------------------------------------------- +// Ввод +// ----------------------------------------------------------------------------- + +bool Ui::Update(Game& game, const ScreenRect& fit, float dt) +{ + blink += dt; + + const Layout l = Of(fit); + + // Окно -> кадр: обратная ScreenFitRect. Вне кадра курсор висит на чёрных + // полях, где рисовать нечего, — значит и наведения там нет. + const Vector2 m = GetMousePosition(); + mx = (m.x - float(l.x)) / float(l.u); + my = (m.y - float(l.y)) / float(l.u); + mouseIn = mx >= 0.0f && my >= 0.0f && mx < float(l.fw) && my < float(l.fh); + + // Экран сменился на лету (ESC из отряда) — предмет с курсора возвращается + // сам: тащить его больше некуда. + if (screen != Screen::SQUAD) dragging = false; + + Widgets w; + BuildWidgets(*this, game, l, w); + + const Widget* hot = mouseIn ? w.At(mx, my) : nullptr; + hoverPart = hot ? hot->part : UiPart::NONE; + hoverIndex = hot ? hot->index : -1; + + const bool moved = GetMouseDelta().x != 0.0f || GetMouseDelta().y != 0.0f; + const bool lmbDown = IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && mouseIn; + const bool lmbUp = IsMouseButtonReleased(MOUSE_BUTTON_LEFT); + + // Системная стрелка поверх кадра, растянутого в три-четыре раза, выглядит + // чужой вещью, поэтому внутри кадра курсор рисуем свой. На чёрных полях + // системный нужен обратно: там мышь снова принадлежит окну, а не игре. + if (mouseIn != cursorHidden) + { + cursorHidden = mouseIn; + if (mouseIn) HideCursor(); + else ShowCursor(); + } + + switch (screen) + { + case Screen::GAME: + if (Pressed(KEY_ESCAPE)) { screen = Screen::PAUSE; return true; } + // Только I: TAB в бою переключает цель (core/input.h), и отбирать + // его под инвентарь нельзя. + if (Pressed(KEY_I)) { screen = Screen::SQUAD; return true; } + // Отряд полёг — бой закончился, и решать это должен не игрок. + // Забег закрывается ЗДЕСЬ, до показа итога: вынесенного нет, + // снаряжение сбрасывается, чекпоинт остаётся. + if (game.SquadWiped()) + { + if (!game.RunOver()) game.WipeRun(); + screen = Screen::SUMMARY; + return true; + } + return false; + + case Screen::MENU: + { + // Наведение ведёт выбор за собой: иначе после мыши клавиатура + // прыгала бы с пункта, на который игрок уже не смотрит. + if (moved && hot && hot->enabled) menuIndex = hot->index; + + const int d = Step(); + if (d) menuIndex = (menuIndex + d + MENU_COUNT) % MENU_COUNT; + if (menuIndex == 0 && !started && d >= 0) menuIndex = 1; + + if (lmbDown && hot && hot->enabled) + Activate(*this, game, hot->part, hot->index); + else if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_KP_ENTER)) + Activate(*this, game, UiPart::MENU_ITEM, menuIndex); + return true; + } + + case Screen::PAUSE: + if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } + if (Pressed(KEY_ESCAPE)) { screen = Screen::GAME; return true; } + if (Pressed(KEY_I) || Pressed(KEY_TAB)) { screen = Screen::SQUAD; return true; } + if (Pressed(KEY_Q)) { screen = Screen::MENU; return true; } + return true; + + case Screen::DEFEAT: + if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } + if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE)) + Activate(*this, game, UiPart::DEFEAT_ITEM, 0); + if (Pressed(KEY_ESCAPE)) screen = Screen::MENU; + return true; + + case Screen::SUMMARY: + if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } + if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_ESCAPE)) + Activate(*this, game, UiPart::SUMMARY_ITEM, 0); + return true; + + case Screen::UPGRADE: + if (lmbDown && hot) Activate(*this, game, hot->part, hot->index); + if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU; + return true; + + case Screen::DEPTH: + { + const int shown = w.count; + if (shown <= 0) { screen = Screen::MENU; return true; } + + if (moved && hot) depthIndex = -1; // мышь ведёт выбор, а не индекс + const int d = Step(); + if (d) depthIndex = (depthIndex + d + shown) % shown; + if (depthIndex < 0) depthIndex = 0; + + if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } + if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE)) + { + const Widget& pick = w.item[depthIndex]; + Activate(*this, game, pick.part, pick.index); + return true; + } + if (Pressed(KEY_ESCAPE)) screen = Screen::MENU; + return true; + } + + case Screen::SQUAD: + { + // --- мышь: выбор бойца и перетаскивание предметов --- + if (lmbDown && hot) + { + if (hot->part == UiPart::AGENT) + { + agentIndex = hot->index; + } + else if (IsSlot(hot->part)) + { + // Клавиатура и мышь смотрят на одну ячейку: после клика + // ENTER сработает именно по ней. + if (hot->part == UiPart::BAG) slotIndex = hot->index; + if (hot->item != ItemId::NONE) + { + dragging = true; + dragFrom = hot->part; + dragIndex = hot->index; + dragItem = hot->item; + } + } + } + // Прокрутка схрона. Q/E, а не колесо: колесо на экране отряда + // ничего не значит, а руки уже лежат на клавишах выбора бойца. + if (game.profile) + { + const int last = game.profile->StashCount() - SQ_STASH_CELLS; + if (Pressed(KEY_E) && stashScroll < last) ++stashScroll; + if (Pressed(KEY_Q) && stashScroll > 0) --stashScroll; + if (stashScroll > last) stashScroll = (last > 0) ? last : 0; + } + + // Правая кнопка — отказ от переноса: предмет возвращается на место. + if (dragging && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) dragging = false; + // Она же по занятой ячейке, когда ничего не тащат, — выбросить. + else if (!dragging && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && hot && + IsSlot(hot->part) && hot->item != ItemId::NONE) + DropToFloor(*this, game, hot->part, hot->index); + if (dragging && lmbUp) + { + if (dropOk && hot) ApplyDrop(*this, game, hot->part, hot->index); + dragging = false; + } + if (!dragging) + { + dragFrom = UiPart::NONE; + dragIndex = -1; + dragItem = ItemId::NONE; + dropOk = false; + } + + // --- клавиатура --- + if (Pressed(KEY_ESCAPE) || Pressed(KEY_I) || Pressed(KEY_TAB)) + { + screen = started ? Screen::GAME : Screen::MENU; + dragging = false; + return true; + } + + const int d = Step(); + if (d) slotIndex = (slotIndex + d + SQUAD_BAG_SLOTS) % SQUAD_BAG_SLOTS; + // Пока предмет висит на курсоре, отряд не листается: взят он был + // из инвентаря этого бойца, а лёг бы в инвентарь другого. + if (!dragging) + { + if (Pressed(KEY_RIGHT) || Pressed(KEY_D)) + agentIndex = (agentIndex + 1) % NUM_AGENTS; + if (Pressed(KEY_LEFT) || Pressed(KEY_A)) + agentIndex = (agentIndex + NUM_AGENTS - 1) % NUM_AGENTS; + } + + // ENTER — взять из сумки в руки. Обратно уезжает то, что было: + // отряд не носит воздух, у него всегда что-то в руках. + if (Pressed(KEY_ENTER) || Pressed(KEY_KP_ENTER)) + { + // Взять из ОБЩЕЙ сумки в руки выбранному бойцу, а бывшее в + // руках положить на его место. Разрешено только оружие: + // бинтом отбиваться нечем. + Agent& a = game.squad.agents[agentIndex]; + Inventory* inv = game.world.Get(a.self); + ItemId& cell = game.squad.bag.slots[slotIndex]; + if (inv && slotIndex >= 0 && slotIndex < SQUAD_BAG_SLOTS && IsWeaponItem(cell)) + { + std::swap(cell, inv->weapon); + EquipWeapon(game.world, a, inv->weapon); + } + } + return true; + } + } + return true; +} + +// ----------------------------------------------------------------------------- +// Рисование +// ----------------------------------------------------------------------------- + +namespace +{ + +void DrawButton(const Layout& l, const Ui& ui, const Widget& b, bool selected) +{ + const bool hot = Hovered(ui, b); + const Color c = !b.enabled ? COL_DEAD : ((hot || selected) ? COL_PICK : COL_FAINT); + + if (b.enabled && hot) Box(l, b.rect, COL_HOVER); + if (b.enabled && (hot || selected)) + Box(l, UiRect{b.rect.x, b.rect.y + b.rect.h - 1, b.rect.w, 1}, COL_BLOOD); + + Text(l, b.text, b.rect.x + BTN_PAD_X, b.rect.y + BTN_PAD_Y, b.size, c); +} + +// Предмет в ячейке: цветной мазок и имя. Цвет берётся из каталога, чтобы +// вещь узнавалась быстрее, чем читается слово. +void DrawItem(const Layout& l, ItemId id, int x, int y, int h, bool bright) +{ + if (id == ItemId::NONE) + { + Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT); + return; + } + const ItemDef d = ItemDefOf(id); + Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255}); + Text(l, d.name, x + 12, y + h / 2 - 4, 8, bright ? COL_TEXT : COL_FAINT); +} + +// Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же, +// по которому мышь проверяет попадание. +void DrawCell(const Layout& l, const Ui& ui, const Widget& c, bool selected) +{ + const bool hot = Hovered(ui, c); + // Пока предмет висит на курсоре, ячейка честно говорит, примет ли она его. + const bool bad = hot && ui.dragging && !ui.dropOk; + + if (bad) Box(l, c.rect, COL_REJECT); + else if (hot) Box(l, c.rect, COL_HOVER); + BoxLines(l, c.rect, bad ? COL_BLOOD : (hot ? COL_TEXT : (selected ? COL_PICK : COL_EDGE))); + + int tx = c.rect.x + 5; + if (c.text) + { + Text(l, c.text, tx, c.rect.y + c.rect.h / 2 - 3, 7, COL_FAINT); + tx += SQ_LABEL_W; + } + + // То, что сейчас на курсоре, из своей ячейки убрано: иначе предмет + // выглядел бы лежащим в двух местах сразу. + const bool taken = ui.dragging && ui.dragFrom == c.part && ui.dragIndex == c.index; + DrawItem(l, taken ? ItemId::NONE : c.item, tx, c.rect.y, c.rect.h, hot || selected); +} + +void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widgets& w) +{ + Shade(l, {4, 4, 6, 240}); + TextCentered(l, "SQUAD", 12, 16, COL_TEXT); + Rule(l, 32, 60); + + for (int i = 0; i < w.count; ++i) + { + const Widget& c = w.item[i]; + if (c.part == UiPart::AGENT) + { + const Agent& a = game.squad.agents[c.index]; + const bool sel = (c.index == ui.agentIndex); + const bool hot = Hovered(ui, c); + + if (hot) Box(l, c.rect, COL_HOVER); + BoxLines(l, c.rect, sel ? COL_PICK : COL_EDGE); + + char buf[8]; + std::snprintf(buf, sizeof(buf), "%d", c.index + 1); + Text(l, buf, c.rect.x + 6, c.rect.y + 4, 10, sel ? COL_PICK : COL_FAINT); + + // Номер бойца читается по цвету слота и больше ничем — тот же цвет, + // что у силуэта в бою. + const uint32_t sc = SlotColor(c.index); + Box(l, UiRect{c.rect.x + 6, c.rect.y + 18, 12, 3}, + Color{uint8_t(sc & 0xFF), uint8_t((sc >> 8) & 0xFF), uint8_t((sc >> 16) & 0xFF), + uint8_t(a.alive ? 255 : 90)}); + Text(l, a.alive ? ItemName(a.held) : "DOWN", c.rect.x + 22, c.rect.y + 17, 8, + a.alive ? (sel ? COL_TEXT : COL_FAINT) : COL_BLOOD); + } + else if (IsSlot(c.part)) + { + DrawCell(l, ui, c, c.part == UiPart::BAG && c.index == ui.slotIndex); + } + } + + Text(l, "BAG", SQ_BAG_X, SQ_BAG_Y - 14, 10, COL_TEXT); + + if (game.profile) + { + char cap[32]; + std::snprintf(cap, sizeof(cap), "STASH %d/%d", game.profile->StashCount(), STASH_CAP); + Text(l, cap, SQ_STASH_X, SQ_STASH_Y - 12, 10, COL_TEXT); + // Схрон длиннее сетки: без явной подсказки прокрутка не существует. + if (game.profile->StashCount() > SQ_STASH_CELLS) + Text(l, "Q/E SCROLL", SQ_STASH_X + 92, SQ_STASH_Y - 11, 8, COL_FAINT); + } + + // Числа те же, что читает симуляция, — иначе экран отряда врал бы. + const Agent& a = game.squad.agents[ui.agentIndex]; + char line[64]; + std::snprintf(line, sizeof(line), "HP %d/%d", int(a.hp + 0.5f), int(a.maxHp + 0.5f)); + Text(l, line, SQ_STAT_X, SQ_STAT_Y, 8, COL_FAINT); + std::snprintf(line, sizeof(line), "ARMOR %d%%", int(a.armor * 100.0f + 0.5f)); + Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP, 8, COL_FAINT); + // Скорость и нагрузка — ОДНОЙ строкой, потому что это одно решение: + // цифра SPEED уже включает в себя перегруз, и без веса рядом она выглядит + // необъяснимой. Отдельной строкой ниже LOAD не влезала — упиралась в + // заголовок схрона, и это было видно только на снятом кадре. + std::snprintf(line, sizeof(line), "SPEED %d%% LOAD %.1f/%.0f", int(a.speedMul * 100.0f + 0.5f), + double(a.load), double(CARRY_LIMIT)); + Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP * 2, 8, + (a.load > CARRY_LIMIT) ? COL_BLOOD : COL_FAINT); + std::snprintf(line, sizeof(line), "SIGHT %d%%", int(a.sightMul * 100.0f + 0.5f)); + Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP * 3, 8, COL_FAINT); + + Text(l, "DRAG ITEMS WITH MOUSE RMB ON ITEM DROPS IT", SQ_STAT_X, SQ_HINT_Y, 8, + COL_FAINT); + Text(l, "A/D FIGHTER W/S SLOT ENTER TAKE ESC BACK", SQ_STAT_X, + SQ_HINT_Y + SQ_HINT_STEP, 8, COL_FAINT); +} + +// Предмет под курсором: его видно ровно там, где мышь, иначе перенос читается +// как «щёлкнул и вещь телепортировалась». +void DrawDragged(const Layout& l, const Ui& ui) +{ + if (!ui.dragging || ui.dragItem == ItemId::NONE) return; + const ItemDef d = ItemDefOf(ui.dragItem); + const int x = int(ui.mx) + 7; + const int y = int(ui.my) + 7; + const int w = TextW(l, d.name, 8) + 18; + + Box(l, UiRect{x, y, w, 14}, COL_SHADOW); + BoxLines(l, UiRect{x, y, w, 14}, ui.dropOk ? COL_PICK : COL_BLOOD); + Box(l, UiRect{x + 3, y + 3, 8, 8}, Color{d.r, d.g, d.b, 255}); + Text(l, d.name, x + 15, y + 3, 8, COL_TEXT); +} + +// Свой курсор: ряды пикселей КАДРА, а не системная стрелка. Сначала контур +// на пиксель шире, потом светлая заливка — так остриё видно и на светлом фоне. +void DrawPointer(const Layout& l, const Ui& ui) +{ + constexpr int H = 9; // высота стрелки в пикселях кадра + const int px = int(ui.mx); + const int py = int(ui.my); + + for (int r = 0; r <= H; ++r) Box(l, UiRect{px - 1, py - 1 + r, r + 3, 1}, COL_SHADOW); + for (int r = 0; r < H; ++r) Box(l, UiRect{px, py + r, r + 1, 1}, COL_PICK); +} + +} // namespace + +void Ui::Draw(const Game& game, const ScreenRect& fit) const +{ + if (screen == Screen::GAME) return; + + const Layout l = Of(fit); + const bool pulse = (int(blink * 2.0f) & 1) == 0; + + Widgets w; + BuildWidgets(*this, game, l, w); + + switch (screen) + { + case Screen::GAME: + return; + + case Screen::MENU: + Shade(l, COL_DIM); + TextCentered(l, "SQUAD PROTOTYPE", 52, 20, COL_TEXT); + Rule(l, 80, 78); + TextCentered(l, "IT IS DARK AND THEY ARE CLOSE", 88, 8, COL_FAINT); + for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == menuIndex); + TextCentered(l, "MOUSE OR W/S SELECT LMB OR ENTER CONFIRM", 236, 8, COL_FAINT); + break; + + case Screen::PAUSE: + Shade(l, {6, 6, 8, 170}); + TextCentered(l, "PAUSED", 92, 18, COL_TEXT); + Rule(l, 118, 44); + for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], false); + TextCentered(l, "CLICK OR ESC RESUME TAB SQUAD Q MENU", 232, 8, COL_FAINT); + break; + + case Screen::DEFEAT: + { + Shade(l, {12, 2, 2, 226}); + TextCentered(l, "SQUAD LOST", 96, 22, COL_BLOOD); + Rule(l, 126, 60); + char line[64]; + std::snprintf(line, sizeof(line), "KILLS %d SHOTS %d", game.metrics.targetsKilled, + game.metrics.shotsFired); + TextCentered(l, line, 142, 9, COL_FAINT); + // Пульсирует подсветка, а не сама кнопка: мигающую цель мышью + // не поймать. + for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == 0 && pulse); + TextCentered(l, "CLICK OR ENTER TRY AGAIN ESC MENU", 232, 8, COL_FAINT); + break; + } + + case Screen::UPGRADE: + { + Shade(l, COL_DIM); + TextCentered(l, "UPGRADES", 30, 16, COL_TEXT); + Rule(l, 52, 52); + + char line[80]; + const float banked = game.profile ? game.profile->bankedXp : 0.0f; + std::snprintf(line, sizeof(line), "BANKED XP %d", int(banked)); + TextCentered(l, line, 58, 8, COL_FAINT); + + for (int i = 0; i < int(Upgrade::COUNT); ++i) + { + const Upgrade u = Upgrade(i); + const int lvl = game.profile ? game.profile->UpgradeLevel(u) : 0; + const bool maxed = lvl >= UPGRADE_MAX_LEVEL; + const bool afford = game.profile && game.profile->CanBuy(u); + + const UiRect r{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H}; + const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == i); + if (hover && afford) Box(l, r, COL_HOVER); + BoxLines(l, r, afford ? COL_EDGE : COL_DEAD); + + Text(l, UpgradeName(u), r.x + 8, r.y + 4, 10, afford ? COL_TEXT : COL_FAINT); + Text(l, UpgradeEffect(u), r.x + 8, r.y + 16, 8, COL_FAINT); + + // Уровни точками: пять кружков читаются быстрее, чем «3/5». + for (int k = 0; k < UPGRADE_MAX_LEVEL; ++k) + Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5}, + (k < lvl) ? Color{200, 180, 120, 255} : COL_DEAD); + + if (maxed) std::snprintf(line, sizeof(line), "MAX"); + else std::snprintf(line, sizeof(line), "%d XP", int(UpgradeCost(lvl))); + Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, + maxed ? COL_FAINT : (afford ? COL_PICK : COL_DEAD)); + } + + TextCentered(l, "CLICK TO BUY ESC BACK", 232, 8, COL_FAINT); + break; + } + + case Screen::DEPTH: + { + Shade(l, COL_DIM); + TextCentered(l, "DESCEND FROM", 46, 16, COL_TEXT); + Rule(l, 70, 62); + + char line[80]; + const int best = game.profile ? game.profile->maxDepth : 1; + std::snprintf(line, sizeof(line), "DEEPEST REACHED D%d", best); + TextCentered(l, line, 76, 8, COL_FAINT); + + for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == depthIndex); + + // Главное правило экстракшена, и его надо повторять: глубже — + // богаче, но потерянное не возвращается. + TextCentered(l, "DEEPER MEANS RICHER AND FINAL", 226, 8, COL_FAINT); + TextCentered(l, "MOUSE OR W/S SELECT ESC BACK", 240, 8, COL_FAINT); + break; + } + + case Screen::SUMMARY: + { + const RunReport& r = game.report; + const bool won = r.outcome == RunOutcome::EXTRACTED; + + Shade(l, won ? Color{4, 10, 8, 232} : Color{12, 2, 2, 232}); + TextCentered(l, won ? "EXTRACTED" : "SQUAD LOST", 60, 22, + won ? COL_TEXT : COL_BLOOD); + Rule(l, 90, 70); + + char line[80]; + std::snprintf(line, sizeof(line), "REACHED D%d", r.deepest); + TextCentered(l, line, 104, 12, COL_PICK); + + std::snprintf(line, sizeof(line), "SURVIVORS %d / %d", r.survivors, NUM_AGENTS); + TextCentered(l, line, 126, 9, COL_FAINT); + + // Две цифры, ради которых экран и существует: что осталось у тебя + // и что осталось внизу. + std::snprintf(line, sizeof(line), "BROUGHT BACK %d", r.itemsBrought); + TextCentered(l, line, 140, 9, won ? COL_TEXT : COL_DEAD); + std::snprintf(line, sizeof(line), "LOST %d", r.itemsLost); + TextCentered(l, line, 152, 9, COL_BLOOD); + + std::snprintf(line, sizeof(line), "XP %d", int(r.xp)); + TextCentered(l, line, 166, 9, COL_FAINT); + + if (r.newRecord) + { + std::snprintf(line, sizeof(line), "NEW CHECKPOINT D%d", r.checkpoint); + TextCentered(l, line, 184, 10, pulse ? COL_PICK : COL_TEXT); + } + + for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], pulse); + break; + } + + case Screen::SQUAD: + DrawSquadScreen(l, game, *this, w); + break; + } + + // Курсор и то, что на нём висит, — поверх всего экрана. + if (!mouseIn) return; + + // Подсказка — до курсора, чтобы курсор остался сверху, и НЕ во время + // перетаскивания: там под курсором уже висит вещь, и две подписи разом + // читаются хуже, чем одна. + if (screen == Screen::SQUAD && !dragging && hoverPart != UiPart::NONE) + { + const Widget* hov = w.Find(hoverPart, hoverIndex); + if (hov) + { + if (hoverPart == UiPart::STAT) + DrawTip(l, StatTip(hoverIndex, game.squad.agents[agentIndex]), int(mx), int(my)); + else if (hov->item != ItemId::NONE) + DrawTip(l, ItemTip(hov->item), int(mx), int(my)); + } + } + + DrawDragged(l, *this); + DrawPointer(l, *this); +} diff --git a/src/ui/menu.h b/src/ui/menu.h new file mode 100644 index 0000000..21f2271 --- /dev/null +++ b/src/ui/menu.h @@ -0,0 +1,109 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Экраны вне боя: главное меню, пауза, отряд с инвентарём, экран поражения. +// +// Это ВТОРОЕ и последнее место в игре, где разрешены примитивы raylib +// (первое — debug/overlay). Причина та же: здесь нужны слова, а на 480x270 +// собственный шрифт стоил бы дороже, чем весь остальной UI вместе взятый. +// Мир по-прежнему рисуется только в софтверный фреймбуфер — меню ложится +// поверх уже готового кадра и никак на него не влияет. +// +// Ui не владеет игрой: он читает её и просит Game сделать конкретные вещи +// (Reset, смена оружия). Никакой логики боя здесь нет. +// +// Управление мышиное: навёл — подсветилось, кликнул — выбралось, потащил +// предмет — переложился. Клавиатура осталась целиком, это второй путь к тем же +// действиям, а не запасной. +// ----------------------------------------------------------------------------- +#include "engine/framebuffer.h" +#include "sim/items.h" + +struct Game; + +enum class Screen : uint8_t +{ + MENU = 0, // главное меню + GAME, // бой + PAUSE, + SQUAD, // отряд: снаряжение, сумки и схрон + DEPTH, // с какого чекпоинта начать вылазку + UPGRADE, // во что вложить вынесенный опыт + SUMMARY, // итог вылазки: что вынесли и что потеряли + DEFEAT, // отряд полёг (устарел, ведёт на SUMMARY) +}; + +// Роль интерактивного элемента. Она же — адрес ячейки инвентаря: слот руки, +// брони и кармана существует по одному, у сумки роль общая, а номер в index. +enum class UiPart : uint8_t +{ + NONE = 0, + MENU_ITEM, // пункт главного меню + PAUSE_ITEM, + DEFEAT_ITEM, + AGENT, // карточка бойца + HANDS, + ARMOR, + POCKET, + BAG, + STASH, // ячейка схрона: то, что лежит на базе между вылазками + STAT, // строка характеристик: наводишь — читаешь, что это значит + UPGRADE_ITEM, // строка улучшения: клик покупает следующий уровень + DEPTH_ITEM, // кнопка выбора чекпоинта + SUMMARY_ITEM, // кнопка на экране итога +}; + +// Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне +// со сдвигом и целым масштабом, и всё, что считает координаты в окне, съезжает. +struct UiRect +{ + int x = 0, y = 0, w = 0, h = 0; + + bool Has(float fx, float fy) const + { + return fx >= float(x) && fy >= float(y) && fx < float(x + w) && fy < float(y + h); + } +}; + +struct Ui +{ + Screen screen = Screen::MENU; + bool quit = false; + bool started = false; // была ли начата вылазка (появляется «продолжить») + + int menuIndex = 0; + int agentIndex = 0; // выбранный боец на экране отряда + int slotIndex = 0; // выбранная ячейка сумки + int depthIndex = 0; // выбранный чекпоинт на экране глубины + int stashScroll = 0; // прокрутка схрона: он длиннее видимой сетки + + float blink = 0.0f; // общий таймер для мигания подсказок + bool cursorHidden = false; // системный курсор скрыт (рисуем свой) + + // Меню перезапустило мир. Забирает main и сбрасывает View: кровь и туман + // войны прошлой вылазки не должны переезжать в новую. + bool worldReset = false; + + // --- мышь ---------------------------------------------------------------- + // Курсор в координатах КАДРА и то, над чем он висит. Всё это считается + // в Update и только читается в Draw: рисование не имеет права трогать + // состояние, иначе подсветка начнёт зависеть от порядка вызовов. + float mx = 0.0f, my = 0.0f; + bool mouseIn = false; // курсор внутри кадра (не на чёрных полях) + UiPart hoverPart = UiPart::NONE; + int hoverIndex = -1; + + // Перетаскивание предмета: что тащим и откуда взяли. + bool dragging = false; + UiPart dragFrom = UiPart::NONE; + int dragIndex = -1; + ItemId dragItem = ItemId::NONE; + bool dropOk = false; // слот под курсором примет предмет? + + // Ввод меню: мышь и клавиатура в одном месте. fit — куда лёг фреймбуфер + // в окне, по нему считается позиция курсора в координатах кадра. + // Возвращает true, если симуляцию на этом кадре шагать НЕ надо. + bool Update(Game& game, const ScreenRect& fit, float dt); + + // Поверх готового кадра. fit — куда лёг фреймбуфер в окне. + void Draw(const Game& game, const ScreenRect& fit) const; +};