#include "editor_app.h" #include "project_manager.h" #include "level_io.h" #include "../imgui/imgui.h" #include "../imgui/imgui_internal.h" #define STB_IMAGE_IMPLEMENTATION #include "stb_image.h" #define STB_IMAGE_WRITE_IMPLEMENTATION #include "stb_image_write.h" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; // Replace a file only after a complete successful write and close. static bool AtomicWrite(const std::string& path, const std::function& writer) { std::error_code ec; auto parent = fs::path(path).parent_path(); if (!parent.empty()) fs::create_directories(parent, ec); if (ec) return false; static unsigned serial = 0; const std::string temporary = path + ".tmp." + std::to_string(GetCurrentProcessId()) + "." + std::to_string(++serial); std::ofstream out(temporary, std::ios::binary | std::ios::trunc); if (!out) return false; writer(out); out.close(); if (!out || !MoveFileExA(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { DeleteFileA(temporary.c_str()); return false; } return true; } static std::vector MakeDefaultTextureSlot(int slotIndex); static bool ReadTextureSlots(const std::string& path, std::vector& pixels) { pixels.resize(16 * 65536); for (int slot = 0; slot < 16; ++slot) { auto fallback = MakeDefaultTextureSlot(slot); std::copy(fallback.begin(), fallback.end(), pixels.begin() + slot * 65536); } std::error_code ec; if (!fs::exists(path, ec)) return !ec; std::ifstream in(path, std::ios::binary | std::ios::ate); if (!in) return false; auto size = in.tellg(); in.seekg(0); uint32_t magic = 0; uint16_t version = 0, count = 0; in.read(reinterpret_cast(&magic), 4); in.read(reinterpret_cast(&version), 2); in.read(reinterpret_cast(&count), 2); if (!in || magic != 0x58544245 || version != 1 || count == 0 || count > 16 || size != static_cast(8 + count * 65536)) return false; auto candidate = pixels; if (!in.read(reinterpret_cast(candidate.data()), count * 65536)) return false; pixels.swap(candidate); return true; } // Texture slot names (engine: 0 = solid marker, 1..9 walls, 10..12 props, 13..15 reserved) static const char* g_TexSlotNames[16] = { "0: Маркер (черный)", "1: Кирпич", "2: Дерево", "3: Камень", "4: Металл", "5: Дверь", "6: (пусто)", "7: Колонна", "8: Стекло", "9: Мрамор", "10: Фонарь", "11: Бочка", "12: Статуя", "13: (резерв)", "14: (резерв)", "15: (резерв)" }; EditorApp::EditorApp() { project = std::make_unique(); char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); const auto directory = fs::path(module).parent_path(); std::error_code ec; const auto projectAssets = directory.parent_path() / "assets/map.dat"; currentFilePath = (fs::is_regular_file(projectAssets, ec) ? projectAssets : directory / "assets/map.dat").string(); InitDefaultLevel(); } EditorApp::~EditorApp() { StopEnginePreview(); StopCutscenePreview(); StopPlaytest(); ShutdownD3D(); } void EditorApp::InitDefaultLevel() { // Clear map for (int y = 0; y < MAP_GRID_H; ++y) { for (int x = 0; x < MAP_GRID_W; ++x) { map[y][x] = CELL_EMPTY; } } // Outer brick boundary for (int x = 0; x < MAP_GRID_W; ++x) { map[0][x] = CELL_BRICK; map[MAP_GRID_H - 1][x] = CELL_BRICK; } for (int y = 0; y < MAP_GRID_H; ++y) { map[y][0] = CELL_BRICK; map[y][MAP_GRID_W - 1] = CELL_BRICK; } // Room divider with door for (int x = 1; x < 20; ++x) { map[8][x] = (x == 10) ? CELL_DOOR : CELL_STONE; } for (int y = 1; y < 18; ++y) { if (y != 12) map[y][20] = CELL_WOOD; } // Some pillars map[4][4] = CELL_PILLAR; map[4][7] = CELL_PILLAR; map[12][5] = CELL_METAL; map[12][15] = CELL_METAL; playerStartX = 2; playerStartY = 2; playerStartAngle = 0; // East ceilingColor = 0x00383838; floorColor = 0x00222222; sprites.clear(); SpriteEntry demoSprite{}; demoSprite.x = 12 * 64 + 32; // raw world units demoSprite.y = 5 * 64 + 32; demoSprite.textureId = 11; // barrel demoSprite.scale = 65536; // 1.0 sprites.push_back(demoSprite); selectedSpriteIdx = -1; selectedTransitionIdx = -1; draggingSpriteIdx = -1; transitions.clear(); cutscene = {}; selectedCutsceneEvent = -1; StopCutscenePreview(); cutsceneCursor = 0; previewX = playerStartX*64+32; previewY = playerStartY*64+32; previewAngle = playerStartAngle; previewSceneCamera = true; ScrubCutscene(0); statusMessage = "Загружен стартовый уровень. Готов к редактированию."; } bool EditorApp::SaveLevel(const std::string& filepath) { return SaveLevelToFile(filepath, true); } bool EditorApp::SaveLevelToFile(const std::string& filepath, bool updateCurrentPath) { BinaryLevel level; auto& h = level.header; h.magic = MAP_MAGIC; h.version = MAP_FORMAT_VERSION; h.mapWidth = MAP_GRID_W; h.mapHeight = MAP_GRID_H; h.playerStartX = static_cast(playerStartX); h.playerStartY = static_cast(playerStartY); h.playerStartAngle = static_cast(playerStartAngle); h.ceilingColor = ceilingColor; h.floorColor = floorColor; level.sprites = sprites; level.transitions = transitions; level.cutscene = cutscene; std::memcpy(level.cells.data(), map, sizeof(map)); std::string error; if (!ValidateLevel(level, error)) { statusMessage = "Ошибка сохранения: " + error; return false; } h.spriteCount = static_cast(sprites.size()); h.transitionCount = static_cast(transitions.size()); h.cutsceneCount = static_cast(cutscene.events.size()); h.cutsceneStringCount = static_cast(cutscene.strings.size()); h.cutsceneFlags = cutscene.flags; bool success = AtomicWrite(filepath, [&](std::ofstream& out) { out.write(reinterpret_cast(&h), sizeof(h)); out.write(reinterpret_cast(level.cells.data()), level.cells.size()); if (!sprites.empty()) out.write(reinterpret_cast(sprites.data()), sprites.size() * sizeof(SpriteEntry)); if (!transitions.empty()) out.write(reinterpret_cast(transitions.data()), transitions.size() * sizeof(LevelTransition)); if (!cutscene.events.empty()) out.write(reinterpret_cast(cutscene.events.data()), cutscene.events.size() * sizeof(CutsceneEvent)); for (const auto& text : cutscene.strings) { std::string encoded; CutsceneEncode(text, encoded); char bytes[CUTSCENE_STRING_BYTES]{}; std::memcpy(bytes, encoded.data(), encoded.size()); out.write(bytes, sizeof(bytes)); } }); if (!success) { statusMessage = "Не удалось сохранить карту: " + filepath; return false; } if (updateCurrentPath) currentFilePath = filepath; statusMessage = "Уровень сохранён: " + filepath; return true; } bool EditorApp::LoadLevel(const std::string& filepath) { BinaryLevel candidate; std::string error; if (!ReadLevelFile(filepath, candidate, error)) { statusMessage = error; return false; } StopCutscenePreview(); const auto& h = candidate.header; std::memcpy(map, candidate.cells.data(), sizeof(map)); playerStartX = h.playerStartX; playerStartY = h.playerStartY; playerStartAngle = h.playerStartAngle; ceilingColor = h.ceilingColor & 0xFFFFFF; floorColor = h.floorColor & 0xFFFFFF; sprites = std::move(candidate.sprites); transitions = std::move(candidate.transitions); cutscene = std::move(candidate.cutscene); selectedCutsceneEvent = -1; cutsceneCursor = 0; placeCameraPoint = false; ScrubCutscene(0); currentFilePath = filepath; previewTexturesDirty = true; previewSceneCamera = true; LoadAllTexturePreviews(); selectedSpriteIdx = selectedTransitionIdx = draggingSpriteIdx = -1; strokeSnapshotTaken = false; undoStack.clear(); redoStack.clear(); statusMessage = "Уровень загружен: " + filepath; return true; } bool EditorApp::PreparePlaytest(std::string& startMap, std::string& enginePath) { std::error_code ec; enginePath = FindEnginePath(); if (enginePath.empty()) { statusMessage = "Сначала соберите engine.exe (build.bat)."; return false; } const fs::path source = fs::absolute(currentFilePath.empty() ? "../assets/map.dat" : currentFilePath, ec); if (ec) { statusMessage = "Неверный путь текущей карты."; return false; } fs::path sandbox; for (unsigned attempt = 0; attempt < 32; ++attempt) { sandbox = fs::temp_directory_path(ec) / ("EblanStudio-" + std::to_string(GetCurrentProcessId()) + "-" + std::to_string(GetTickCount64()) + "-" + std::to_string(attempt)); if (ec) break; if (fs::create_directory(sandbox, ec)) break; if (ec) break; sandbox.clear(); } if (ec || sandbox.empty()) { statusMessage = "Не удалось создать папку плейтеста."; return false; } playtestDirectory = sandbox.string(); auto copyFailure = [&]() { statusMessage = "Не удалось скопировать ресурсы плейтеста: " + ec.message(); CleanupPlaytest(); return false; }; fs::create_directories(sandbox / "assets", ec); if (ec) return copyFailure(); if (fs::is_directory(source.parent_path(), ec)) { for (const auto& entry : fs::directory_iterator(source.parent_path(), ec)) { if (ec) break; if (entry.is_regular_file(ec) && _stricmp(entry.path().extension().string().c_str(), ".dat") == 0) fs::copy_file(entry.path(), sandbox / "assets" / entry.path().filename(), fs::copy_options::overwrite_existing, ec); if (ec) break; } } if (ec) return copyFailure(); if (!SaveLevelToFile((sandbox / "assets" / source.filename()).string(), false)) { CleanupPlaytest(); return false; } const fs::path pack = fs::absolute(GetTexturePackPath(), ec); if (ec) return copyFailure(); if (fs::exists(pack, ec)) { fs::create_directories(sandbox / "assets/textures", ec); if (!ec) fs::copy_file(pack, sandbox / "assets/textures/texturepack.bin", fs::copy_options::overwrite_existing, ec); } if (ec) return copyFailure(); const fs::path sounds = fs::absolute(GetSoundsDirectory(), ec); if (ec) return copyFailure(); if (fs::is_directory(sounds, ec)) fs::copy(sounds, sandbox / "assets/sounds", fs::copy_options::recursive, ec); if (ec) return copyFailure(); // Validate all reachable maps and destination spawns, including reverse links. std::vector pending{source.filename().string()}; std::set visited; while (!pending.empty()) { std::string name = pending.back(); pending.pop_back(); std::string key = name; for (char& ch : key) ch = static_cast(std::tolower(static_cast(ch))); if (!visited.insert(key).second) continue; BinaryLevel level; std::string error; if (!ReadLevelFile((sandbox / "assets" / name).string(), level, error)) { statusMessage = "Плейтест: " + error; CleanupPlaytest(); return false; } for (const auto& event : level.cutscene.events) { if (event.opCode == OP_PLAY_SOUND && event.paramA >= 0 && !fs::is_regular_file(sandbox / "assets/sounds" / level.cutscene.strings[event.paramA], ec)) { statusMessage = "Плейтест: не найден WAV " + level.cutscene.strings[event.paramA]; CleanupPlaytest(); return false; } } for (const auto& t : level.transitions) { BinaryLevel destination; if (!ReadLevelFile((sandbox / "assets" / t.targetMap).string(), destination, error) || !IsWalkableCell(destination.cells[t.targetSpawnY * MAP_GRID_W + t.targetSpawnX])) { statusMessage = "Плейтест: проверьте файл и спавн перехода " + std::string(t.targetMap); CleanupPlaytest(); return false; } pending.emplace_back(t.targetMap); } } startMap = "assets\\" + source.filename().string(); return true; } void EditorApp::CleanupPlaytest() { if (playtestDirectory.empty()) return; std::error_code ec; const auto directory = fs::weakly_canonical(playtestDirectory, ec); if (ec) return; const auto temporaryRoot = fs::weakly_canonical(fs::temp_directory_path(ec), ec); // Verify the absolute target is a dedicated immediate child of TEMP. if (!ec && directory.parent_path() == temporaryRoot && directory.filename().string().rfind("EblanStudio-", 0) == 0) fs::remove_all(directory, ec); playtestDirectory.clear(); } void EditorApp::LaunchPlaytest() { StopCutscenePreview(); StopPlaytest(); std::string startMap, enginePath; if (!PreparePlaytest(startMap, enginePath)) { showPlaytestError = true; return; } std::string command = "\"" + enginePath + "\" \"" + startMap + "\""; STARTUPINFOA si{}; si.cb = sizeof(si); if (!CreateProcessA(enginePath.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, playtestDirectory.c_str(), &si, &engineProcInfo)) { statusMessage = "Ошибка запуска engine.exe: " + std::to_string(GetLastError()); showPlaytestError = true; CleanupPlaytest(); return; } hEngineProcess = engineProcInfo.hProcess; isEngineRunning = true; statusMessage = "Игра запущена из временной копии. Исходные карты сохранены."; } void EditorApp::StopPlaytest() { if (hEngineProcess) { TerminateProcess(hEngineProcess, 0); WaitForSingleObject(hEngineProcess, 2000); CloseHandle(hEngineProcess); CloseHandle(engineProcInfo.hThread); hEngineProcess = nullptr; engineProcInfo = {}; } isEngineRunning = false; CleanupPlaytest(); } void EditorApp::CheckPlaytestProcess() { if (!hEngineProcess || WaitForSingleObject(hEngineProcess, 0) != WAIT_OBJECT_0) return; DWORD code = 0; GetExitCodeProcess(hEngineProcess, &code); CloseHandle(hEngineProcess); CloseHandle(engineProcInfo.hThread); hEngineProcess = nullptr; engineProcInfo = {}; isEngineRunning = false; CleanupPlaytest(); statusMessage = "Игра завершена (код: " + std::to_string(code) + ")."; } // ----------------------------------------------------------------------------- // Undo / Redo // ----------------------------------------------------------------------------- LevelSnapshot EditorApp::CaptureSnapshot() const { LevelSnapshot s; std::memcpy(s.map, map, sizeof(map)); s.playerStartX = playerStartX; s.playerStartY = playerStartY; s.playerStartAngle = playerStartAngle; s.ceilingColor = ceilingColor; s.floorColor = floorColor; s.sprites = sprites; s.transitions = transitions; s.cutscene = cutscene; return s; } void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { std::memcpy(map, s.map, sizeof(map)); playerStartX = s.playerStartX; playerStartY = s.playerStartY; playerStartAngle = s.playerStartAngle; ceilingColor = s.ceilingColor; floorColor = s.floorColor; sprites = s.sprites; StopCutscenePreview(); transitions = s.transitions; cutscene = s.cutscene; selectedCutsceneEvent = -1; placeCameraPoint = false; ScrubCutscene(cutsceneCursor); selectedSpriteIdx = -1; selectedTransitionIdx = -1; draggingSpriteIdx = -1; } void EditorApp::PushUndo() { undoStack.push_back(CaptureSnapshot()); if (undoStack.size() > MAX_UNDO) undoStack.pop_front(); redoStack.clear(); } void EditorApp::Undo() { if (undoStack.empty()) return; redoStack.push_back(CaptureSnapshot()); RestoreSnapshot(undoStack.back()); undoStack.pop_back(); statusMessage = "Отмена действия."; } void EditorApp::Redo() { if (redoStack.empty()) return; undoStack.push_back(CaptureSnapshot()); RestoreSnapshot(redoStack.back()); redoStack.pop_back(); statusMessage = "Повтор действия."; } // ----------------------------------------------------------------------------- // File dialogs (comdlg32) // ----------------------------------------------------------------------------- std::string EditorApp::BrowseOpenFile() { char szFile[MAX_PATH] = {0}; OPENFILENAMEA ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = GetActiveWindow(); ofn.lpstrFile = szFile; ofn.nMaxFile = MAX_PATH; ofn.lpstrFilter = "EBLAN Level (*.dat)\0*.dat\0All Files (*.*)\0*.*\0"; ofn.nFilterIndex = 1; ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR; if (GetOpenFileNameA(&ofn)) return szFile; return ""; } std::string EditorApp::BrowseSaveFile() { char szFile[MAX_PATH] = {0}; OPENFILENAMEA ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = GetActiveWindow(); ofn.lpstrFile = szFile; ofn.nMaxFile = MAX_PATH; ofn.lpstrFilter = "EBLAN Level (*.dat)\0*.dat\0All Files (*.*)\0*.*\0"; ofn.nFilterIndex = 1; ofn.Flags = OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR; ofn.lpstrDefExt = "dat"; if (GetSaveFileNameA(&ofn)) return szFile; return ""; } // ----------------------------------------------------------------------------- // Asset Manager (Textures, Sprites & Audio Import) // ----------------------------------------------------------------------------- void EditorApp::InitD3D(ID3D11Device* pDevice) { pd3dDevice = pDevice; LoadAllTexturePreviews(); } void EditorApp::ShutdownD3D() { StopEnginePreview(); if (previewSRV) { previewSRV->Release(); previewSRV = nullptr; } if (previewTexture) { previewTexture->Release(); previewTexture = nullptr; } if (previewSampler) { previewSampler->Release(); previewSampler = nullptr; } for (int i = 0; i < 16; ++i) { if (textureSRVs[i]) { textureSRVs[i]->Release(); textureSRVs[i] = nullptr; } } pd3dDevice = nullptr; } std::string EditorApp::GetTexturePackPath() const { const auto local = fs::path(currentFilePath).parent_path() / "textures/texturepack.bin"; std::error_code ec; if (fs::is_regular_file(local, ec)) return local.string(); const char* candidates[] = { "..\\assets\\textures\\texturepack.bin", "assets\\textures\\texturepack.bin", "..\\..\\assets\\textures\\texturepack.bin" }; for (const char* path : candidates) { DWORD attr = GetFileAttributesA(path); if (attr != INVALID_FILE_ATTRIBUTES) return path; } return local.string(); } std::string EditorApp::GetSoundsDirectory() const { auto directory = fs::path(currentFilePath).parent_path() / "sounds"; std::error_code ec; fs::create_directories(directory, ec); return directory.string() + "\\"; } std::string EditorApp::BrowseOpenImageFile() { char szFile[MAX_PATH] = { 0 }; OPENFILENAMEA ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = GetActiveWindow(); ofn.lpstrFile = szFile; ofn.nMaxFile = MAX_PATH; ofn.lpstrFilter = "Изображения (*.png;*.jpg;*.jpeg;*.bmp)\0*.png;*.jpg;*.jpeg;*.bmp\0Все файлы (*.*)\0*.*\0"; ofn.nFilterIndex = 1; ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR; if (GetOpenFileNameA(&ofn)) return szFile; return ""; } std::string EditorApp::BrowseOpenAudioFile() { char szFile[MAX_PATH] = { 0 }; OPENFILENAMEA ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = GetActiveWindow(); ofn.lpstrFile = szFile; ofn.nMaxFile = MAX_PATH; ofn.lpstrFilter = "Звуковые файлы WAV (*.wav)\0*.wav\0Все файлы (*.*)\0*.*\0"; ofn.nFilterIndex = 1; ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR; if (GetOpenFileNameA(&ofn)) return szFile; return ""; } std::string EditorApp::BrowseSavePngFile(int slotIndex) { char szFile[MAX_PATH] = { 0 }; snprintf(szFile, MAX_PATH, "texture_slot_%d.png", slotIndex); OPENFILENAMEA ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = GetActiveWindow(); ofn.lpstrFile = szFile; ofn.nMaxFile = MAX_PATH; ofn.lpstrFilter = "PNG Изображение (*.png)\0*.png\0Все файлы (*.*)\0*.*\0"; ofn.nFilterIndex = 1; ofn.Flags = OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR; ofn.lpstrDefExt = "png"; if (GetSaveFileNameA(&ofn)) return szFile; return ""; } static bool ResampleTo128BGRA(const unsigned char* srcRGBA, int srcW, int srcH, bool isSprite, std::vector& outBGRA) { outBGRA.assign(128 * 128 * 4, 0); if (!srcRGBA || srcW <= 0 || srcH <= 0) return false; if (!isSprite) { // Стены: умный кроп по центру с сохранением квадратной геометрии (без растяжения) float srcAspect = static_cast(srcW) / static_cast(srcH); float cropX0 = 0.0f, cropY0 = 0.0f, cropW = static_cast(srcW), cropH = static_cast(srcH); if (srcAspect > 1.0f) { cropW = static_cast(srcH); cropX0 = (srcW - cropW) * 0.5f; } else if (srcAspect < 1.0f) { cropH = static_cast(srcW); cropY0 = (srcH - cropH) * 0.5f; } for (int dy = 0; dy < 128; ++dy) { float srcY = cropY0 + (dy + 0.5f) * (cropH / 128.0f) - 0.5f; int y0 = static_cast(std::floor(srcY)); int y1 = y0 + 1; float fy = srcY - y0; if (y0 < 0) { y0 = 0; fy = 0.0f; } if (y1 >= srcH) { y1 = srcH - 1; } for (int dx = 0; dx < 128; ++dx) { float srcX = cropX0 + (dx + 0.5f) * (cropW / 128.0f) - 0.5f; int x0 = static_cast(std::floor(srcX)); int x1 = x0 + 1; float fx = srcX - x0; if (x0 < 0) { x0 = 0; fx = 0.0f; } if (x1 >= srcW) { x1 = srcW - 1; } const unsigned char* p00 = &srcRGBA[(y0 * srcW + x0) * 4]; const unsigned char* p10 = &srcRGBA[(y0 * srcW + x1) * 4]; const unsigned char* p01 = &srcRGBA[(y1 * srcW + x0) * 4]; const unsigned char* p11 = &srcRGBA[(y1 * srcW + x1) * 4]; float w00 = (1.0f - fx) * (1.0f - fy); float w10 = fx * (1.0f - fy); float w01 = (1.0f - fx) * fy; float w11 = fx * fy; uint8_t r = static_cast(std::clamp(p00[0]*w00 + p10[0]*w10 + p01[0]*w01 + p11[0]*w11 + 0.5f, 0.0f, 255.0f)); uint8_t g = static_cast(std::clamp(p00[1]*w00 + p10[1]*w10 + p01[1]*w01 + p11[1]*w11 + 0.5f, 0.0f, 255.0f)); uint8_t b = static_cast(std::clamp(p00[2]*w00 + p10[2]*w10 + p01[2]*w01 + p11[2]*w11 + 0.5f, 0.0f, 255.0f)); int dstIdx = (dy * 128 + dx) * 4; outBGRA[dstIdx + 0] = b; outBGRA[dstIdx + 1] = g; outBGRA[dstIdx + 2] = r; outBGRA[dstIdx + 3] = 0x00; } } } else { // Спрайты: пропорциональное вписывание с привязкой к полу (нижней грани) и прозрачным фоном for (int i = 0; i < 128 * 128; ++i) { outBGRA[i * 4 + 0] = 0xFF; // B outBGRA[i * 4 + 1] = 0x00; // G outBGRA[i * 4 + 2] = 0xFF; // R (COLOR_CHROMA_KEY 0x00FF00FF) outBGRA[i * 4 + 3] = 0x00; } float scale = (std::min)(128.0f / srcW, 128.0f / srcH); float targetW = srcW * scale; float targetH = srcH * scale; float offsetX = (128.0f - targetW) * 0.5f; float offsetY = (128.0f - targetH); // привязка к полу int dstX0 = static_cast(offsetX); int dstX1 = static_cast(offsetX + targetW); int dstY0 = static_cast(offsetY); int dstY1 = static_cast(offsetY + targetH); for (int dy = dstY0; dy < dstY1 && dy < 128; ++dy) { if (dy < 0) continue; float srcY = (dy - offsetY + 0.5f) / scale - 0.5f; int y0 = static_cast(std::floor(srcY)); int y1 = y0 + 1; float fy = srcY - y0; if (y0 < 0) { y0 = 0; fy = 0.0f; } if (y1 >= srcH) { y1 = srcH - 1; } for (int dx = dstX0; dx < dstX1 && dx < 128; ++dx) { if (dx < 0) continue; float srcX = (dx - offsetX + 0.5f) / scale - 0.5f; int x0 = static_cast(std::floor(srcX)); int x1 = x0 + 1; float fx = srcX - x0; if (x0 < 0) { x0 = 0; fx = 0.0f; } if (x1 >= srcW) { x1 = srcW - 1; } const unsigned char* p00 = &srcRGBA[(y0 * srcW + x0) * 4]; const unsigned char* p10 = &srcRGBA[(y0 * srcW + x1) * 4]; const unsigned char* p01 = &srcRGBA[(y1 * srcW + x0) * 4]; const unsigned char* p11 = &srcRGBA[(y1 * srcW + x1) * 4]; float w00 = (1.0f - fx) * (1.0f - fy); float w10 = fx * (1.0f - fy); float w01 = (1.0f - fx) * fy; float w11 = fx * fy; float aF = p00[3]*w00 + p10[3]*w10 + p01[3]*w01 + p11[3]*w11; float rF = p00[0]*w00 + p10[0]*w10 + p01[0]*w01 + p11[0]*w11; float gF = p00[1]*w00 + p10[1]*w10 + p01[1]*w01 + p11[1]*w11; float bF = p00[2]*w00 + p10[2]*w10 + p01[2]*w01 + p11[2]*w11; // Прозрачность по альфа-каналу ИЛИ по классическому цвету Chroma Key RGB(255, 0, 255) if (aF < 128.0f || (rF >= 240.0f && gF <= 25.0f && bF >= 240.0f)) continue; int dstIdx = (dy * 128 + dx) * 4; outBGRA[dstIdx + 0] = static_cast(std::clamp(bF + 0.5f, 0.0f, 255.0f)); outBGRA[dstIdx + 1] = static_cast(std::clamp(gF + 0.5f, 0.0f, 255.0f)); outBGRA[dstIdx + 2] = static_cast(std::clamp(rF + 0.5f, 0.0f, 255.0f)); outBGRA[dstIdx + 3] = 0x00; } } } return true; } bool EditorApp::ImportTextureImage(int slotIndex, bool isSprite, const std::string& explicitPath) { if (slotIndex < 1 || slotIndex >= 16) return false; std::string filePath = explicitPath.empty() ? BrowseOpenImageFile() : explicitPath; if (filePath.empty()) return false; int srcW = 0, srcH = 0, channels = 0; unsigned char* data = stbi_load(filePath.c_str(), &srcW, &srcH, &channels, 4); if (!data) { statusMessage = "Ошибка загрузки картинки: " + filePath; return false; } std::vector slotData; if (!ResampleTo128BGRA(data, srcW, srcH, isSprite, slotData)) { stbi_image_free(data); statusMessage = "Ошибка конвертации текстуры!"; return false; } stbi_image_free(data); if (!WriteTextureSlot(slotIndex, slotData)) return false; statusMessage = "Текстура [" + std::string(g_TexSlotNames[slotIndex]) + "] обновлена из: " + filePath; return true; } bool EditorApp::ExportTextureImage(int slotIndex) { if (slotIndex < 0 || slotIndex >= 16) return false; std::string savePath = BrowseSavePngFile(slotIndex); if (savePath.empty()) return false; std::vector allSlots; if (!ReadTextureSlots(GetTexturePackPath(), allSlots)) { statusMessage = "Не удалось прочитать пакет текстур для экспорта."; return false; } std::vector bgra(allSlots.begin() + slotIndex * 65536, allSlots.begin() + (slotIndex + 1) * 65536); // Convert BGRA to RGBA (with Chroma Key -> Alpha 0) std::vector rgba(128 * 128 * 4); for (int i = 0; i < 128 * 128; ++i) { uint8_t b = bgra[i * 4 + 0]; uint8_t g = bgra[i * 4 + 1]; uint8_t r = bgra[i * 4 + 2]; if (b == 0xFF && g == 0x00 && r == 0xFF) { rgba[i * 4 + 0] = 0; rgba[i * 4 + 1] = 0; rgba[i * 4 + 2] = 0; rgba[i * 4 + 3] = 0; } else { rgba[i * 4 + 0] = r; rgba[i * 4 + 1] = g; rgba[i * 4 + 2] = b; rgba[i * 4 + 3] = 255; } } if (stbi_write_png(savePath.c_str(), 128, 128, 4, rgba.data(), 128 * 4)) { statusMessage = "Текстура [" + std::string(g_TexSlotNames[slotIndex]) + "] экспортирована в: " + savePath; return true; } statusMessage = "Ошибка записи PNG файла: " + savePath; return false; } std::vector EditorApp::GetSoundList() const { std::vector sounds; std::string pattern = GetSoundsDirectory() + "*.wav"; WIN32_FIND_DATAA fd{}; HANDLE hFind = FindFirstFileA(pattern.c_str(), &fd); if (hFind != INVALID_HANDLE_VALUE) { do { if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { sounds.push_back(fd.cFileName); } } while (FindNextFileA(hFind, &fd)); FindClose(hFind); } std::sort(sounds.begin(), sounds.end()); return sounds; } void EditorApp::UpdateSoundManifest() { auto sounds = GetSoundList(); std::string sDir = GetSoundsDirectory(); // 1. Write sounds.inc for MASM32 (clean forward-slash relative paths) std::string incPath = sDir + "sounds.inc"; std::ofstream inc(incPath); if (inc.is_open()) { inc << "; ==============================================================================\n"; inc << "; EBLAN ENGINE - Auto-Generated Sound Manifest (winmm.lib PlaySound / MCI)\n"; inc << "; ==============================================================================\n\n"; for (const auto& s : sounds) { std::string base = s; size_t dot = base.find_last_of('.'); if (dot != std::string::npos) base = base.substr(0, dot); for (char& c : base) { if (c == ' ' || c == '-') c = '_'; c = static_cast(std::toupper(c)); } inc << "SND_" << base << " db \"assets/sounds/" << s << "\", 0\n"; } inc.close(); } // 2. Write sounds.txt for editor / MCI std::string txtPath = sDir + "sounds.txt"; std::ofstream txt(txtPath); if (txt.is_open()) { txt << "# EblanStudio Sound Registry & MCI Aliases\n"; for (const auto& s : sounds) { std::string base = s; size_t dot = base.find_last_of('.'); if (dot != std::string::npos) base = base.substr(0, dot); for (char& c : base) { if (c == ' ' || c == '-') c = '_'; c = static_cast(std::tolower(c)); } txt << "alias snd_" << base << " -> assets/sounds/" << s << "\n"; } txt.close(); } } void EditorApp::PlayAudioSound(const std::string& filename) { std::string path = GetSoundsDirectory() + filename; PlaySoundA(path.c_str(), NULL, SND_FILENAME | SND_ASYNC); statusMessage = "Воспроизведение звука: " + filename; } void EditorApp::StopAudioSound() { PlaySoundA(NULL, NULL, 0); statusMessage = "Воспроизведение звука остановлено."; } bool EditorApp::ImportSoundAudio(const std::string& explicitPath) { std::string filePath = explicitPath.empty() ? BrowseOpenAudioFile() : explicitPath; if (filePath.empty()) return false; std::string soundDir = GetSoundsDirectory(); size_t lastSlash = filePath.find_last_of("\\/"); std::string filename = (lastSlash == std::string::npos) ? filePath : filePath.substr(lastSlash + 1); std::string destPath = soundDir + filename; if (!CopyFileA(filePath.c_str(), destPath.c_str(), FALSE)) { statusMessage = "Ошибка копирования звука: " + destPath; return false; } UpdateSoundManifest(); PlayAudioSound(filename); statusMessage = "Аудио импортировано и обновлен манифест MCI: " + destPath; return true; } static std::vector MakeDefaultTextureSlot(int slotIndex) { std::vector slotData(128 * 128 * 4, 0); auto setPx = [&](int x, int y, int r, int g, int b) { int idx = (y * 128 + x) * 4; slotData[idx + 0] = static_cast(b & 0xFF); slotData[idx + 1] = static_cast(g & 0xFF); slotData[idx + 2] = static_cast(r & 0xFF); slotData[idx + 3] = 0; }; switch (slotIndex) { case 0: // Empty / Black break; case 1: // Red brick for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0x85, g = 0x22, b = 0x15; bool mortar = ((y & 15) == 0 || (y & 15) == 1); int vx = x + ((y & 16) ? 16 : 0); if ((vx & 31) == 0 || (vx & 31) == 1) mortar = true; if (mortar) { r = g = b = 0x55; } else { int n = (((x ^ y) & 7) << 2); b = (std::min)(255, b + n); } setPx(x, y, r, g, b); } } break; case 2: // Wood for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0x68, g = 0x3A, b = 0x12; if ((x & 31) == 0) { r = 0x20; g = 0x10; b = 0x05; } else { int n = ((y & 3) << 3); b = (std::min)(255, b + n); } setPx(x, y, r, g, b); } } break; case 3: // Stone for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0x55, g = 0x55, b = 0x55; if ((x & 31) == 0 || (y & 31) == 0) { r = g = b = 0x22; } else { int n = ((((x * 3) + y) & 15) << 2); b = (std::min)(255, b + n); } setPx(x, y, r, g, b); } } break; case 4: // Metal for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0x60, g = 0x65, b = 0x70; if ((x - 10)*(x - 10) + (y - 10)*(y - 10) <= 16 || (x - 118)*(x - 118) + (y - 118)*(y - 118) <= 16) { r = 0xA0; g = 0xB0; b = 0xC0; } setPx(x, y, r, g, b); } } break; case 5: // Door for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r, g, b; if (x < 8 || x >= 120 || y < 8) { r = g = b = 0x33; } else { r = 0x50; g = 0x2D; b = 0x15; if ((x - 100)*(x - 100) + (y - 64)*(y - 64) <= 36) { r = 0xFF; g = 0xD0; b = 0x00; } } setPx(x, y, r, g, b); } } break; case 7: // Pillar for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r, g, b; if ((x & 15) < 8) { r = 0x70; g = 0xB0; b = 0xC0; } else { r = 0x40; g = 0x80; b = 0x90; } setPx(x, y, r, g, b); } } break; case 8: // Glass for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0x40, g = 0x30, b = 0x20; if (x < 6 || x >= 122 || y < 6 || y >= 122) { r = g = b = 0x60; } else { int d = (x + y) & 63; if (d < 4 || (d >= 24 && d < 28)) { r = 0x90; g = 0x70; b = 0x50; } } setPx(x, y, r, g, b); } } break; case 9: // Marble for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0xD8, g = 0xD0, b = 0xC0; int v = (x * 7 + y * 13); v = (v + (v >> 3)) & 63; if (v <= 2 || v == 40) { r = g = b = 0x55; } else { int n = (x ^ y) & 3; b = (std::min)(255, b + n); } setPx(x, y, r, g, b); } } break; case 10: // Lamp (Sprite) for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0xFF, g = 0x00, b = 0xFF; // Chroma key if (x >= 60 && x < 68 && y >= 40 && y < 120) { r = g = b = 0x40; } if (x >= 48 && x < 80 && y >= 16 && y < 44) { r = 0xFF; g = 0xC0; b = 0x30; } if (x >= 52 && x < 76 && y >= 120 && y < 126) { r = g = b = 0x30; } setPx(x, y, r, g, b); } } break; case 11: // Barrel (Sprite) for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0xFF, g = 0x00, b = 0xFF; // Chroma key int dx = x - 64, dy = y - 70; if (dx * dx * 2704 + dy * dy * 1156 <= 3125824) { r = 0x6A; g = 0x40; b = 0x1E; if (std::abs(dy) >= 29 && std::abs(dy) <= 35) { r = g = b = 0x55; } if (x <= 52 && std::abs(dy) < 26) { r = 0x8A; g = 0x5A; b = 0x2E; } } setPx(x, y, r, g, b); } } break; case 12: // Statue / Prop (Sprite) for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { int r = 0xFF, g = 0x00, b = 0xFF; if (x >= 44 && x < 84 && y >= 112 && y < 126) { r = g = b = 0x60; } if (x >= 54 && x < 74 && y >= 40 && y < 112) { r = g = b = 0x8A + ((x ^ y) & 7); } int dx = x - 64, dy = y - 28; if (dx * dx + dy * dy <= 100) { r = g = b = 0x9A; } setPx(x, y, r, g, b); } } break; default: for (int y = 0; y < 128; ++y) { for (int x = 0; x < 128; ++x) { if (slotIndex >= 10) setPx(x, y, 0xFF, 0x00, 0xFF); else setPx(x, y, 0x00, 0x00, 0x00); } } break; } return slotData; } bool EditorApp::WriteTextureSlot(int slotIndex, const std::vector& pixels) { if (slotIndex < 1 || slotIndex >= 16 || pixels.size() != 65536) return false; const std::string path = GetTexturePackPath(); std::vector slots; if (!ReadTextureSlots(path, slots)) { statusMessage = "Повреждён texturepack.bin. Восстановите пакет перед импортом."; return false; } std::copy(pixels.begin(), pixels.end(), slots.begin() + slotIndex * 65536); if (!AtomicWrite(path, [&](std::ofstream& out) { uint32_t magic = 0x58544245; uint16_t version = 1, count = 16; out.write(reinterpret_cast(&magic), 4); out.write(reinterpret_cast(&version), 2); out.write(reinterpret_cast(&count), 2); out.write(reinterpret_cast(slots.data()), slots.size()); })) { statusMessage = "Не удалось записать пакет текстур: " + path; return false; } ReloadTextureSlotSRV(slotIndex); return true; } bool EditorApp::ResetTextureSlotToDefault(int slotIndex) { if (slotIndex < 1 || slotIndex >= 16) return false; if (!WriteTextureSlot(slotIndex, MakeDefaultTextureSlot(slotIndex))) return false; statusMessage = "Восстановлена стандартная текстура слота " + std::to_string(slotIndex); return true; } void EditorApp::HandleDropFile(const std::string& droppedPath) { std::string lower = droppedPath; for (char& c : lower) c = static_cast(std::tolower(c)); if (lower.length() >= 4 && lower.rfind(".wav") == lower.length() - 4) { ImportSoundAudio(droppedPath); showAssetManagerModal = true; } else if (lower.length() >= 4 && ( lower.rfind(".png") == lower.length() - 4 || lower.rfind(".jpg") == lower.length() - 4 || lower.rfind(".bmp") == lower.length() - 4 || (lower.length() >= 5 && lower.rfind(".jpeg") == lower.length() - 5))) { if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { int slot = sprites[selectedSpriteIdx].textureId & 15; if (slot >= 10 && slot <= 15) { ImportTextureImage(slot, true, droppedPath); return; } } if (selectedPaletteCell >= 1 && selectedPaletteCell <= 9 && selectedPaletteCell != 6) { ImportTextureImage(selectedPaletteCell, false, droppedPath); return; } showAssetManagerModal = true; statusMessage = "Изображение получено. Выберите целевой слот для импорта: " + droppedPath; } else if (lower.length() >= 4 && lower.rfind(".dat") == lower.length() - 4) { LoadLevel(droppedPath); } } void EditorApp::ReloadTextureSlotSRV(int slotIndex) { previewTexturesDirty = true; if (!pd3dDevice || slotIndex < 0 || slotIndex >= 16) return; if (textureSRVs[slotIndex]) { textureSRVs[slotIndex]->Release(); textureSRVs[slotIndex] = nullptr; } std::vector allSlots; ReadTextureSlots(GetTexturePackPath(), allSlots); // fallback previews also work without a pack std::vector bgra(allSlots.begin() + slotIndex * 65536, allSlots.begin() + (slotIndex + 1) * 65536); std::vector displayRGBA(128 * 128 * 4); for (int i = 0; i < 128 * 128; ++i) { uint8_t b = bgra[i * 4 + 0]; uint8_t g = bgra[i * 4 + 1]; uint8_t r = bgra[i * 4 + 2]; if (b == 0xFF && g == 0x00 && r == 0xFF) { displayRGBA[i * 4 + 0] = 0; displayRGBA[i * 4 + 1] = 0; displayRGBA[i * 4 + 2] = 0; displayRGBA[i * 4 + 3] = 0; // transparent } else { displayRGBA[i * 4 + 0] = r; displayRGBA[i * 4 + 1] = g; displayRGBA[i * 4 + 2] = b; displayRGBA[i * 4 + 3] = 255; // opaque } } D3D11_TEXTURE2D_DESC desc{}; desc.Width = 128; desc.Height = 128; desc.MipLevels = 1; desc.ArraySize = 1; desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DEFAULT; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; D3D11_SUBRESOURCE_DATA sub{}; sub.pSysMem = displayRGBA.data(); sub.SysMemPitch = 128 * 4; ID3D11Texture2D* pTex = nullptr; if (SUCCEEDED(pd3dDevice->CreateTexture2D(&desc, &sub, &pTex))) { pd3dDevice->CreateShaderResourceView(pTex, nullptr, &textureSRVs[slotIndex]); pTex->Release(); } } void EditorApp::LoadAllTexturePreviews() { for (int i = 0; i < 16; ++i) { ReloadTextureSlotSRV(i); } } void EditorApp::RenderAssetManagerModal() { if (!showAssetManagerModal) return; ImGui::SetNextWindowSize(ImVec2(820, 580), ImGuiCond_FirstUseEver); if (ImGui::Begin("Менеджер ассетов (Texture & Audio Manager)", &showAssetManagerModal)) { if (ImGui::BeginTabBar("AssetManagerTabs")) { if (ImGui::BeginTabItem("Текстурный пакет (16 слотов)")) { ImGui::Text("Файл пакета: %s", GetTexturePackPath().c_str()); ImGui::SameLine(); if (ImGui::Button("Перезагрузить все")) { LoadAllTexturePreviews(); statusMessage = "Все слоты перезагружены из texturepack.bin"; } ImGui::Separator(); // 4x4 Grid of texture slots if (ImGui::BeginTable("TextureSlotGrid", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { for (int slot = 0; slot < 16; ++slot) { if (slot % 4 == 0) ImGui::TableNextRow(); ImGui::TableSetColumnIndex(slot % 4); ImGui::PushID(slot); ImGui::BeginGroup(); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Слот %d", slot); ImGui::Text("%s", g_TexSlotNames[slot]); if (textureSRVs[slot]) { ImGui::Image(reinterpret_cast(textureSRVs[slot]), ImVec2(64, 64)); } else { ImGui::Button("(нет превью)", ImVec2(64, 64)); } if (slot > 0) { bool isSpr = (slot >= 10); if (ImGui::Button("Заменить")) { ImportTextureImage(slot, isSpr); } ImGui::SameLine(); if (ImGui::Button("Экспорт")) { ExportTextureImage(slot); } ImGui::SameLine(); if (ImGui::Button("Сброс")) { ResetTextureSlotToDefault(slot); } } else { ImGui::TextDisabled("Служебный"); } ImGui::EndGroup(); ImGui::PopID(); } ImGui::EndTable(); } ImGui::EndTabItem(); } if (ImGui::BeginTabItem("Аудио и звуки (WAV / MCI)")) { ImGui::Text("Папка звуков: %s", GetSoundsDirectory().c_str()); ImGui::SameLine(); if (ImGui::Button("+ Импортировать WAV...")) { ImportSoundAudio(); } ImGui::SameLine(); if (ImGui::Button("■ Стоп аудио")) { StopAudioSound(); } ImGui::Separator(); auto soundList = GetSoundList(); if (soundList.empty()) { ImGui::TextDisabled("В папке assets/sounds пока нет аудиофайлов WAV."); ImGui::Text("Нажмите '+ Импортировать WAV...', чтобы добавить звуки."); } else { if (ImGui::BeginTable("SoundsTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Файл", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("MCI Алиас", ImGuiTableColumnFlags_WidthFixed, 140.0f); ImGui::TableSetupColumn("Воспроизведение", ImGuiTableColumnFlags_WidthFixed, 140.0f); ImGui::TableSetupColumn("Управление", ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableHeadersRow(); for (size_t i = 0; i < soundList.size(); ++i) { const auto& snd = soundList[i]; ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::Text("%s", snd.c_str()); ImGui::TableSetColumnIndex(1); std::string baseName = snd; size_t dot = baseName.find_last_of('.'); if (dot != std::string::npos) baseName = baseName.substr(0, dot); std::string alias = "snd_" + baseName; ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", alias.c_str()); ImGui::TableSetColumnIndex(2); ImGui::PushID(static_cast(i)); if (ImGui::Button("▶ Старт")) { PlayAudioSound(snd); } ImGui::SameLine(); if (ImGui::Button("■ Стоп")) { StopAudioSound(); } ImGui::TableSetColumnIndex(3); if (ImGui::Button("Удалить")) { std::string full = GetSoundsDirectory() + snd; DeleteFileA(full.c_str()); UpdateSoundManifest(); statusMessage = "Звук удален: " + snd; } ImGui::PopID(); } ImGui::EndTable(); } } ImGui::EndTabItem(); } ImGui::EndTabBar(); } } ImGui::End(); } // ----------------------------------------------------------------------------- // Tools // ----------------------------------------------------------------------------- bool EditorApp::SetCell(int gx, int gy, uint8_t value) { if (gx < 0 || gx >= MAP_GRID_W || gy < 0 || gy >= MAP_GRID_H || value > 15) return false; auto found = std::find_if(transitions.begin(), transitions.end(), [&](const LevelTransition& t) { return t.triggerCellX == gx && t.triggerCellY == gy; }); if (value == CELL_PORTAL) { if (found == transitions.end()) { if (transitions.size() >= MAX_LEVEL_TRANSITIONS) { statusMessage = "Лимит: не более 16 переходов на карту."; return false; } LevelTransition transition{}; transition.triggerCellX = static_cast(gx); transition.triggerCellY = static_cast(gy); strcpy_s(transition.targetMap, "sublevel_02.dat"); transition.targetSpawnX = transition.targetSpawnY = 2; transition.fadeEffect = 1; transitions.push_back(transition); selectedTransitionIdx = static_cast(transitions.size()) - 1; } else selectedTransitionIdx = static_cast(found - transitions.begin()); selectedSpriteIdx = -1; } else if (found != transitions.end()) { RemoveCutsceneTransition(static_cast(found - transitions.begin())); transitions.erase(found); selectedTransitionIdx = -1; } map[gy][gx] = value; return true; } void EditorApp::ApplyTool(int gx, int gy) { if (gx < 0 || gx >= MAP_GRID_W || gy < 0 || gy >= MAP_GRID_H) return; switch (currentTool) { case TOOL_BRUSH: SetCell(gx, gy, selectedPaletteCell); break; case TOOL_ERASER: SetCell(gx, gy, CELL_EMPTY); break; case TOOL_FILL: FloodFill(gx, gy, map[gy][gx], selectedPaletteCell); break; case TOOL_PLAYER_SPAWN: if (SetCell(gx, gy, CELL_EMPTY)) { playerStartX = gx; playerStartY = gy; } break; case TOOL_SPRITE_SPAWN: if (sprites.size() < MAX_LEVEL_SPRITES) { SpriteEntry sprite{}; sprite.x = gx * 64 + 32; sprite.y = gy * 64 + 32; sprite.textureId = 11; sprite.scale = 65536; sprites.push_back(sprite); selectedSpriteIdx = static_cast(sprites.size()) - 1; selectedTransitionIdx = -1; } break; } } void EditorApp::FloodFill(int startX, int startY, uint8_t targetType, uint8_t fillType) { if (targetType == fillType || startX < 0 || startX >= MAP_GRID_W || startY < 0 || startY >= MAP_GRID_H) return; std::array affected{}; std::vector> cells; std::queue> pending; pending.push({startX, startY}); while (!pending.empty()) { auto [x, y] = pending.front(); pending.pop(); if (x < 0 || x >= MAP_GRID_W || y < 0 || y >= MAP_GRID_H) continue; int index = y * MAP_GRID_W + x; if (affected[index] || map[y][x] != targetType) continue; affected[index] = true; cells.push_back({x, y}); pending.push({x - 1, y}); pending.push({x + 1, y}); pending.push({x, y - 1}); pending.push({x, y + 1}); } // Preflight the entire operation; a limit failure must not half-fill a room. if (fillType == CELL_PORTAL) { size_t retained = 0; for (const auto& t : transitions) if (!affected[t.triggerCellY * MAP_GRID_W + t.triggerCellX]) ++retained; if (retained + cells.size() > MAX_LEVEL_TRANSITIONS) { statusMessage = "Заливка отменена: на карте может быть не более 16 переходов."; return; } } for (const auto& [x, y] : cells) SetCell(x, y, fillType); } // ----------------------------------------------------------------------------- // Hotkeys // ----------------------------------------------------------------------------- void EditorApp::HandleHotkeys() { ImGuiIO& io = ImGui::GetIO(); // Function keys remain available while ImGui owns navigation/text focus. if (ImGui::IsKeyPressed(ImGuiKey_F6, false)) showCutsceneTimeline = !showCutsceneTimeline; if (ImGui::IsKeyPressed(ImGuiKey_F7, false)) showCodeEditorModal = !showCodeEditorModal; if (ImGui::IsKeyPressed(ImGuiKey_F4, false)) showAssetManagerModal = !showAssetManagerModal; if (ImGui::IsKeyPressed(ImGuiKey_F5, false)) { if (!isEngineRunning) LaunchPlaytest(); else StopPlaytest(); } if (io.WantTextInput || ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return; if (io.KeyCtrl) { if (ImGui::IsKeyPressed(ImGuiKey_N, false)) { PushUndo(); InitDefaultLevel(); } if (ImGui::IsKeyPressed(ImGuiKey_O, false)) { std::string p = BrowseOpenFile(); if (!p.empty()) LoadLevel(p); } if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { if (io.KeyShift) { std::string p = BrowseSaveFile(); if (!p.empty()) SaveLevel(p); } else { SaveLevel(currentFilePath); } } if (ImGui::IsKeyPressed(ImGuiKey_Z, false)) Undo(); if (ImGui::IsKeyPressed(ImGuiKey_Y, false)) Redo(); return; // no single-letter tool hotkeys while Ctrl held } if (ImGui::IsKeyPressed(ImGuiKey_F4, false)) { showAssetManagerModal = !showAssetManagerModal; } if (ImGui::IsKeyPressed(ImGuiKey_B, false)) currentTool = TOOL_BRUSH; if (ImGui::IsKeyPressed(ImGuiKey_E, false)) currentTool = TOOL_ERASER; if (ImGui::IsKeyPressed(ImGuiKey_F, false)) currentTool = TOOL_FILL; if (ImGui::IsKeyPressed(ImGuiKey_P, false)) currentTool = TOOL_PLAYER_SPAWN; if (ImGui::IsKeyPressed(ImGuiKey_S, false)) currentTool = TOOL_SPRITE_SPAWN; } // ----------------------------------------------------------------------------- // UI Rendering // ----------------------------------------------------------------------------- void EditorApp::RenderUI() { AdvanceCutscenePreview(ImGui::GetIO().DeltaTime); CheckPlaytestProcess(); HandleHotkeys(); RenderMenuBar(); RenderToolbar(); RenderAssetManagerModal(); RenderCodeEditorWindow(); RenderProjectSettingsModal(); RenderExportGameModal(); if (showPlaytestError) { ImGui::OpenPopup("Не удалось запустить игру"); showPlaytestError = false; } if (ImGui::BeginPopupModal("Не удалось запустить игру", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::PushTextWrapPos(ImGui::GetCursorPosX()+520); ImGui::TextUnformatted(statusMessage.c_str()); ImGui::PopTextWrapPos(); if (ImGui::Button("Понятно")) ImGui::CloseCurrentPopup(); ImGui::EndPopup(); } // Main layout ImGuiViewport* vp = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 60)); ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 85)); ImGui::Begin("Workspace", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus); // Columns: Left = Palette & Hierarchy, Middle = Grid Canvas, Right = Inspector float leftWidth = 220.0f; float rightWidth = 280.0f; float canvasWidth = ImGui::GetContentRegionAvail().x - leftWidth - rightWidth - 20.0f; // Left Panel ImGui::BeginChild("LeftPanel", ImVec2(leftWidth, 0), true); RenderTilePalette(); ImGui::Separator(); RenderHierarchy(); ImGui::EndChild(); ImGui::SameLine(); // Center Panel (Grid Canvas) ImGui::BeginChild("CenterPanel", ImVec2(canvasWidth, 0), false); float timelineHeight = showCutsceneTimeline ? (std::min)(340.0f, ImGui::GetContentRegionAvail().y * 0.48f) : 0.0f; ImGui::BeginChild("CanvasPanel", ImVec2(0, showCutsceneTimeline ? -timelineHeight - 6 : 0), true); if (ImGui::RadioButton("Рядом", viewportMode == 0)) viewportMode = 0; ImGui::SameLine(); if (ImGui::RadioButton("Игровой вид", viewportMode == 1)) viewportMode = 1; ImGui::SameLine(); if (ImGui::RadioButton("Карта", viewportMode == 2)) viewportMode = 2; float sceneWidth = viewportMode == 0 ? ImGui::GetContentRegionAvail().x * 0.5f : 0; if (viewportMode != 2) { ImGui::BeginChild("SceneViewport", ImVec2(sceneWidth,0), true); RenderSceneViewport(); ImGui::EndChild(); if (viewportMode == 0) ImGui::SameLine(); } if (viewportMode != 1) { ImGui::BeginChild("MapViewport", ImVec2(0,0), false); RenderGridCanvas(); ImGui::EndChild(); } ImGui::EndChild(); if (showCutsceneTimeline) { ImGui::BeginChild("CutsceneTimeline", ImVec2(0, 0), true); RenderCutsceneTimeline(); ImGui::EndChild(); } ImGui::EndChild(); ImGui::SameLine(); // Right Panel (Inspector) ImGui::BeginChild("RightPanel", ImVec2(rightWidth, 0), true); RenderInspector(); ImGui::EndChild(); ImGui::End(); // Status Bar ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + vp->Size.y - 25)); ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 25)); ImGui::Begin("StatusBar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "● %s", statusMessage.c_str()); ImGui::SameLine(vp->Size.x - 280); ImGui::Text("FPS: %.1f | Zoom: %.0f%% | Undo: %zu", ImGui::GetIO().Framerate, (canvasZoom / 20.0f) * 100.0f, undoStack.size()); ImGui::End(); } void EditorApp::RenderMenuBar() { if (ImGui::BeginMainMenuBar()) { if (ImGui::BeginMenu("Файл")) { if (ImGui::MenuItem("Новый уровень", "Ctrl+N")) { PushUndo(); InitDefaultLevel(); } if (ImGui::MenuItem("Открыть уровень...", "Ctrl+O")) { std::string p = BrowseOpenFile(); if (!p.empty()) LoadLevel(p); } if (ImGui::MenuItem("Сохранить уровень", "Ctrl+S")) { SaveLevel(currentFilePath); } if (ImGui::MenuItem("Сохранить как...", "Ctrl+Shift+S")) { std::string p = BrowseSaveFile(); if (!p.empty()) SaveLevel(p); } ImGui::Separator(); if (ImGui::MenuItem("Настройки проекта...", nullptr, showProjectSettingsModal)) { showProjectSettingsModal = true; } if (ImGui::MenuItem("Экспорт автономной игры (.exe)...", nullptr, showExportGameModal)) { showExportGameModal = true; } ImGui::Separator(); if (ImGui::MenuItem("Выход", "Alt+F4")) { PostQuitMessage(0); } ImGui::EndMenu(); } if (ImGui::BeginMenu("Правка")) { if (ImGui::MenuItem("Отмена", "Ctrl+Z", false, !undoStack.empty())) Undo(); if (ImGui::MenuItem("Повтор", "Ctrl+Y", false, !redoStack.empty())) Redo(); ImGui::EndMenu(); } if (ImGui::BeginMenu("Код / IDE")) { if (ImGui::MenuItem("Редактор скриптов MASM32...", "F7", showCodeEditorModal)) { showCodeEditorModal = !showCodeEditorModal; } ImGui::EndMenu(); } if (ImGui::BeginMenu("Инструменты")) { if (ImGui::MenuItem("Кисть (Стена)", "B", currentTool == TOOL_BRUSH)) currentTool = TOOL_BRUSH; if (ImGui::MenuItem("Ластик (Очистить)", "E", currentTool == TOOL_ERASER)) currentTool = TOOL_ERASER; if (ImGui::MenuItem("Заливка области", "F", currentTool == TOOL_FILL)) currentTool = TOOL_FILL; if (ImGui::MenuItem("Спавн игрока", "P", currentTool == TOOL_PLAYER_SPAWN)) currentTool = TOOL_PLAYER_SPAWN; if (ImGui::MenuItem("Добавить спрайт", "S", currentTool == TOOL_SPRITE_SPAWN)) currentTool = TOOL_SPRITE_SPAWN; ImGui::EndMenu(); } if (ImGui::BeginMenu("Ассеты")) { if (ImGui::MenuItem("Менеджер ассетов...", "F4", showAssetManagerModal)) { showAssetManagerModal = !showAssetManagerModal; } ImGui::Separator(); if (ImGui::BeginMenu("Импорт текстуры стены...")) { for (int slot = 1; slot <= 9; ++slot) { if (slot == 6) continue; // slot 6 is open door / empty char label[64]; snprintf(label, sizeof(label), "%s (Слот %d)", g_TexSlotNames[slot], slot); if (ImGui::MenuItem(label)) { ImportTextureImage(slot, false); } } ImGui::EndMenu(); } if (ImGui::BeginMenu("Импорт спрайта...")) { for (int slot = 10; slot <= 15; ++slot) { char label[64]; snprintf(label, sizeof(label), "%s (Слот %d)", g_TexSlotNames[slot], slot); if (ImGui::MenuItem(label)) { ImportTextureImage(slot, true); } } ImGui::EndMenu(); } ImGui::Separator(); if (ImGui::MenuItem("Импорт аудио (WAV)...")) { ImportSoundAudio(); } ImGui::Separator(); if (ImGui::MenuItem("Перезагрузить текстуры")) { LoadAllTexturePreviews(); statusMessage = "Все текстурные слоты перезагружены из texturepack.bin"; } ImGui::EndMenu(); } if (ImGui::BeginMenu("Движок")) { if (ImGui::MenuItem("Запустить игру (Playtest)", "F5", isEngineRunning)) { if (!isEngineRunning) LaunchPlaytest(); else StopPlaytest(); } if (ImGui::MenuItem("Редактор скриптов MASM32", "F7", showCodeEditorModal)) { showCodeEditorModal = !showCodeEditorModal; } if (ImGui::MenuItem("Кат-сцены", "F6", showCutsceneTimeline)) { showCutsceneTimeline = !showCutsceneTimeline; } ImGui::Separator(); if (ImGui::MenuItem("Экспорт игры (.exe)...", nullptr, showExportGameModal)) { showExportGameModal = true; } ImGui::EndMenu(); } ImGui::EndMainMenuBar(); } } void EditorApp::RenderToolbar() { ImGuiViewport* vp = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 20)); ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 38)); ImGui::Begin("Toolbar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); // Play / Stop Button if (!isEngineRunning) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.65f, 0.25f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.80f, 0.30f, 1.0f)); if (ImGui::Button(" ▶ ЗАПУСК (F5) ", ImVec2(130, 24))) { LaunchPlaytest(); } ImGui::PopStyleColor(2); } else { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.20f, 0.20f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.25f, 0.25f, 1.0f)); if (ImGui::Button(" ■ СТОП (F5) ", ImVec2(130, 24))) { StopPlaytest(); } ImGui::PopStyleColor(2); } ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); // Tools auto ToolButton = [&](const char* label, ToolMode mode) { bool active = (currentTool == mode); if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.45f, 0.75f, 1.0f)); if (ImGui::Button(label)) currentTool = mode; if (active) ImGui::PopStyleColor(); ImGui::SameLine(); }; ToolButton(" [B] Кисть ", TOOL_BRUSH); ToolButton(" [E] Ластик ", TOOL_ERASER); ToolButton(" [F] Заливка ", TOOL_FILL); ToolButton(" [P] Игрок ", TOOL_PLAYER_SPAWN); ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN); ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); // Undo / Redo buttons if (ImGui::Button(" Отмена ")) Undo(); ImGui::SameLine(); if (ImGui::Button(" Повтор ")) Redo(); ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); if (ImGui::Button("Кат-сцена (F6)")) showCutsceneTimeline = !showCutsceneTimeline; ImGui::SameLine(); // Asset Manager button if (ImGui::Button(" 🖼 Ассеты (F4) ")) { showAssetManagerModal = !showAssetManagerModal; } ImGui::SameLine(); // Code Editor button if (ImGui::Button(" 💻 Код (F7) ")) { showCodeEditorModal = !showCodeEditorModal; } ImGui::SameLine(); // Standalone Exporter button if (ImGui::Button(" 📦 Экспорт (.exe) ")) { showExportGameModal = true; } ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); if (ImGui::Button(" Сохранить (Ctrl+S) ")) { SaveLevel(currentFilePath); } ImGui::End(); } void EditorApp::RenderGridCanvas() { ImVec2 canvasP0 = ImGui::GetCursorScreenPos(); ImVec2 canvasSz = ImGui::GetContentRegionAvail(); if (canvasSz.x < 50.0f) canvasSz.x = 50.0f; if (canvasSz.y < 50.0f) canvasSz.y = 50.0f; ImVec2 canvasP1 = ImVec2(canvasP0.x + canvasSz.x, canvasP0.y + canvasSz.y); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->PushClipRect(canvasP0, canvasP1, true); // Canvas Background drawList->AddRectFilled(canvasP0, canvasP1, IM_COL32(28, 28, 30, 255)); // Handle Pan & Zoom ImGuiIO& io = ImGui::GetIO(); if (ImGui::IsWindowHovered()) { // Zoom if (io.MouseWheel != 0.0f) { canvasZoom += io.MouseWheel * 2.0f; if (canvasZoom < 8.0f) canvasZoom = 8.0f; if (canvasZoom > 48.0f) canvasZoom = 48.0f; } // Pan with Middle Mouse Button if (ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) { canvasPanX += io.MouseDelta.x; canvasPanY += io.MouseDelta.y; } } float tileSize = canvasZoom; ImVec2 gridOrigin = ImVec2(canvasP0.x + canvasPanX, canvasP0.y + canvasPanY); // Tile Colors auto GetCellColor = [](uint8_t type) -> ImU32 { switch (type) { case CELL_EMPTY: return IM_COL32(35, 35, 38, 255); case CELL_BRICK: return IM_COL32(165, 45, 45, 255); // Red case CELL_WOOD: return IM_COL32(135, 90, 45, 255); // Brown case CELL_STONE: return IM_COL32(100, 100, 105, 255); // Gray case CELL_METAL: return IM_COL32(85, 115, 140, 255); // Steel blue case CELL_DOOR: return IM_COL32(215, 140, 30, 255); // Orange case CELL_DOOR_OPEN: return IM_COL32(65, 175, 75, 255); // Green case CELL_PILLAR: return IM_COL32(65, 150, 160, 255); // Turquoise case CELL_GLASS: return IM_COL32(70, 130, 160, 255); // Glass blue case CELL_MARBLE: return IM_COL32(200, 195, 185, 255); // Cream marble case CELL_PORTAL: return IM_COL32(160, 50, 225, 255); // Neon purple portal default: return IM_COL32(50, 50, 50, 255); } }; // 1. Draw Map Tiles for (int y = 0; y < MAP_GRID_H; ++y) { for (int x = 0; x < MAP_GRID_W; ++x) { ImVec2 tileP0 = ImVec2(gridOrigin.x + x * tileSize, gridOrigin.y + y * tileSize); ImVec2 tileP1 = ImVec2(tileP0.x + tileSize, tileP0.y + tileSize); // Skip offscreen tiles if (tileP1.x < canvasP0.x || tileP0.x > canvasP1.x || tileP1.y < canvasP0.y || tileP0.y > canvasP1.y) { continue; } ImU32 col = GetCellColor(map[y][x]); drawList->AddRectFilled(tileP0, tileP1, col); drawList->AddRect(tileP0, tileP1, IM_COL32(50, 50, 55, 120), 0.0f, 0, 1.0f); if (map[y][x] == CELL_PORTAL) { float cx = tileP0.x + tileSize * 0.5f; float cy = tileP0.y + tileSize * 0.5f; drawList->AddCircle(ImVec2(cx, cy), tileSize * 0.35f, IM_COL32(255, 255, 255, 230), 0, 1.5f); drawList->AddCircleFilled(ImVec2(cx, cy), tileSize * 0.15f, IM_COL32(255, 220, 100, 255)); } } } // Grid outer border ImVec2 mapEnd = ImVec2(gridOrigin.x + MAP_GRID_W * tileSize, gridOrigin.y + MAP_GRID_H * tileSize); drawList->AddRect(gridOrigin, mapEnd, IM_COL32(100, 200, 255, 200), 0.0f, 0, 2.0f); // 2. Draw Sprites (Entities) - raw world units -> cell coords ImVec2 mousePos = io.MousePos; int hoveredSprite = -1; for (size_t i = 0; i < sprites.size(); ++i) { float sx = sprites[i].x / 64.0f; float sy = sprites[i].y / 64.0f; ImVec2 scPos = ImVec2(gridOrigin.x + sx * tileSize, gridOrigin.y + sy * tileSize); // Check hover (within diamond radius) float dx = mousePos.x - scPos.x; float dy = mousePos.y - scPos.y; float r = tileSize * 0.4f; if (std::fabs(dx) + std::fabs(dy) < r) { hoveredSprite = static_cast(i); } // Color diamond based on Entity Archetype uint8_t aType = GetSpriteEntityType(sprites[i]); ImU32 fillCol = IM_COL32(255, 215, 0, 240); // Prop (Gold) if (aType == ENTITY_ARCHETYPE_GUARD) fillCol = IM_COL32(235, 45, 45, 240); // Red (Enemy Guard) else if (aType == ENTITY_ARCHETYPE_MEDKIT) fillCol = IM_COL32(40, 220, 80, 240); // Green (Medkit) else if (aType == ENTITY_ARCHETYPE_AMMO) fillCol = IM_COL32(245, 190, 25, 240); // Yellow (Ammo) else if (aType == ENTITY_ARCHETYPE_KEY) fillCol = IM_COL32(30, 190, 255, 240); // Cyan (Key) else if (aType == ENTITY_ARCHETYPE_SWITCH) fillCol = IM_COL32(255, 130, 20, 240); // Orange (Switch) ImU32 borderCol = IM_COL32(255, 255, 255, 255); if (static_cast(i) == selectedSpriteIdx) { borderCol = IM_COL32(255, 255, 0, 255); fillCol = IM_COL32(255, 255, 255, 240); } else if (static_cast(i) == hoveredSprite) { borderCol = IM_COL32(255, 255, 120, 255); } drawList->AddQuadFilled( ImVec2(scPos.x, scPos.y - r), ImVec2(scPos.x + r, scPos.y), ImVec2(scPos.x, scPos.y + r), ImVec2(scPos.x - r, scPos.y), fillCol ); drawList->AddQuad( ImVec2(scPos.x, scPos.y - r), ImVec2(scPos.x + r, scPos.y), ImVec2(scPos.x, scPos.y + r), ImVec2(scPos.x - r, scPos.y), borderCol ); } // 3. Draw Player Spawn Point & Direction Cone { ImVec2 pPos = ImVec2(gridOrigin.x + (playerStartX + 0.5f) * tileSize, gridOrigin.y + (playerStartY + 0.5f) * tileSize); float pRadius = tileSize * 0.45f; // Player Circle (Bright Green) drawList->AddCircleFilled(pPos, pRadius, IM_COL32(0, 255, 120, 220)); drawList->AddCircle(pPos, pRadius, IM_COL32(255, 255, 255, 255), 0, 1.5f); // Direction Arrow (rad = angle * 2 * PI / 512) float rad = (playerStartAngle * 3.14159265f) / 256.0f; ImVec2 dirTip = ImVec2(pPos.x + std::cos(rad) * pRadius * 1.8f, pPos.y + std::sin(rad) * pRadius * 1.8f); drawList->AddLine(pPos, dirTip, IM_COL32(255, 40, 40, 255), 2.5f); drawList->AddCircleFilled(dirTip, 3.5f, IM_COL32(255, 40, 40, 255)); } DrawCutscenePath(drawList, gridOrigin.x, gridOrigin.y, tileSize); // 4. Mouse Input Handling on Canvas if (ImGui::IsWindowHovered() && !cutscenePreviewPlaying) { int hoverGx = static_cast(std::floor((mousePos.x - gridOrigin.x) / tileSize)); int hoverGy = static_cast(std::floor((mousePos.y - gridOrigin.y) / tileSize)); bool inGrid = (hoverGx >= 0 && hoverGx < MAP_GRID_W && hoverGy >= 0 && hoverGy < MAP_GRID_H); if (inGrid) { // Highlight hovered tile ImVec2 hP0 = ImVec2(gridOrigin.x + hoverGx * tileSize, gridOrigin.y + hoverGy * tileSize); ImVec2 hP1 = ImVec2(hP0.x + tileSize, hP0.y + tileSize); drawList->AddRect(hP0, hP1, IM_COL32(255, 255, 255, 200), 0.0f, 0, 2.0f); } if (placeCameraPoint && inGrid && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && selectedCutsceneEvent >= 0 && selectedCutsceneEvent < static_cast(cutscene.events.size())) { if (!IsWalkableCell(map[hoverGy][hoverGx])) { statusMessage = "Выберите точку камеры вне стены."; drawList->PopClipRect(); return; } PushUndo(); auto& event = cutscene.events[selectedCutsceneEvent]; event.paramA = hoverGx * 64 + 32; event.paramB = hoverGy * 64 + 32; placeCameraPoint = false; NormalizeCutscene(); ScrubCutscene(cutsceneCursor); drawList->PopClipRect(); return; } if (placeCameraPoint) { drawList->PopClipRect(); return; } // Left Click: start action (drag sprite OR paint) if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (currentTool == TOOL_SPRITE_SPAWN && hoveredSprite >= 0) { // Start dragging existing sprite draggingSpriteIdx = hoveredSprite; selectedSpriteIdx = hoveredSprite; selectedTransitionIdx = -1; PushUndo(); } else if (inGrid) { // If clicking an existing portal tile, select it for inspection! if (map[hoverGy][hoverGx] == CELL_PORTAL) { for (size_t i = 0; i < transitions.size(); ++i) { if (transitions[i].triggerCellX == hoverGx && transitions[i].triggerCellY == hoverGy) { selectedTransitionIdx = static_cast(i); selectedSpriteIdx = -1; break; } } } PushUndo(); // snapshot before paint stroke strokeSnapshotTaken = true; ApplyTool(hoverGx, hoverGy); } } // Left Drag: continue painting OR move sprite if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { if (draggingSpriteIdx >= 0) { float wx = (mousePos.x - gridOrigin.x) / tileSize; float wy = (mousePos.y - gridOrigin.y) / tileSize; sprites[draggingSpriteIdx].x = static_cast(wx * 64.0f); sprites[draggingSpriteIdx].y = static_cast(wy * 64.0f); } else if (strokeSnapshotTaken && inGrid) { ApplyTool(hoverGx, hoverGy); // no extra snapshot during stroke } } // Left Release: end drag / stroke if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { draggingSpriteIdx = -1; strokeSnapshotTaken = false; } // Right-click erase follows the same portal lifecycle as every other tool. if (inGrid && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { PushUndo(); SetCell(hoverGx, hoverGy, CELL_EMPTY); } else if (inGrid && ImGui::IsMouseDown(ImGuiMouseButton_Right)) { SetCell(hoverGx, hoverGy, CELL_EMPTY); } } else { // Mouse left canvas: end drag / stroke if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { draggingSpriteIdx = -1; strokeSnapshotTaken = false; } } drawList->PopClipRect(); } void EditorApp::RenderTilePalette() { ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "ПАЛИТРА ТАЙЛОВ"); ImGui::Separator(); auto PaletteButton = [&](const char* name, CellType type, ImVec4 col, int slot) { bool selected = (selectedPaletteCell == type && currentTool == TOOL_BRUSH); ImGui::PushStyleColor(ImGuiCol_Button, col); if (selected) { ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 0.85f, 0.2f, 1.0f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 2.0f); } ImGui::PushID(slot); if (type != CELL_PORTAL && slot > 0 && slot < 16 && textureSRVs[slot]) { ImGui::Image(reinterpret_cast(textureSRVs[slot]), ImVec2(24, 24)); ImGui::SameLine(); } if (ImGui::Button(name, ImVec2(-1, 28))) { selectedPaletteCell = type; currentTool = TOOL_BRUSH; } if (type != CELL_PORTAL && slot > 0 && slot < 16) { if (ImGui::BeginPopupContextItem()) { ImGui::Text("Текстура: %s (Слот %d)", g_TexSlotNames[slot], slot); ImGui::Separator(); if (ImGui::MenuItem("Заменить текстуру (PNG/JPG)...")) { ImportTextureImage(slot, false); } if (ImGui::MenuItem("Экспорт текстуры в PNG...")) { ExportTextureImage(slot); } if (ImGui::MenuItem("Сбросить к процедурной")) { ResetTextureSlotToDefault(slot); } ImGui::Separator(); if (ImGui::MenuItem("Открыть в Менеджере ассетов (F4)")) { showAssetManagerModal = true; } ImGui::EndPopup(); } } ImGui::PopID(); if (selected) { ImGui::PopStyleVar(); ImGui::PopStyleColor(); } ImGui::PopStyleColor(); }; PaletteButton("1. Кирпичная стена", CELL_BRICK, ImVec4(0.65f, 0.20f, 0.20f, 1.0f), 1); PaletteButton("2. Деревянная стена", CELL_WOOD, ImVec4(0.55f, 0.35f, 0.20f, 1.0f), 2); PaletteButton("3. Каменная стена", CELL_STONE, ImVec4(0.40f, 0.40f, 0.45f, 1.0f), 3); PaletteButton("4. Металлическая стена", CELL_METAL, ImVec4(0.35f, 0.45f, 0.55f, 1.0f), 4); PaletteButton("5. Дверь (E: открыть)", CELL_DOOR, ImVec4(0.85f, 0.55f, 0.15f, 1.0f), 5); PaletteButton("7. Колонна / Мозаика", CELL_PILLAR, ImVec4(0.25f, 0.60f, 0.65f, 1.0f), 7); PaletteButton("8. Стекло", CELL_GLASS, ImVec4(0.28f, 0.50f, 0.65f, 1.0f), 8); PaletteButton("9. Мрамор", CELL_MARBLE, ImVec4(0.80f, 0.77f, 0.73f, 1.0f), 9); PaletteButton("10. Портал перехода", CELL_PORTAL, ImVec4(0.60f, 0.20f, 0.85f, 1.0f), 10); PaletteButton("0. Пустота (Пол)", CELL_EMPTY, ImVec4(0.15f, 0.15f, 0.15f, 1.0f), 0); } void EditorApp::RenderHierarchy() { ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "СУЩНОСТИ (ИЕРАРХИЯ)"); ImGui::Separator(); ImGui::Text("Игрок: Spawn (%d, %d)", playerStartX, playerStartY); ImGui::Separator(); ImGui::Text("Спрайты на карте (%zu / %d):", sprites.size(), MAX_LEVEL_SPRITES); for (size_t i = 0; i < sprites.size(); ++i) { int gx = sprites[i].x / 64; int gy = sprites[i].y / 64; uint8_t aType = GetSpriteEntityType(sprites[i]); const char* typeTag = "Проп"; if (aType == ENTITY_ARCHETYPE_GUARD) typeTag = "Враг"; else if (aType == ENTITY_ARCHETYPE_MEDKIT) typeTag = "+HP"; else if (aType == ENTITY_ARCHETYPE_AMMO) typeTag = "+Ammo"; else if (aType == ENTITY_ARCHETYPE_KEY) typeTag = "Ключ"; else if (aType == ENTITY_ARCHETYPE_SWITCH) typeTag = "Рычаг"; char label[64]; snprintf(label, sizeof(label), "[%s] #%zu (%d, %d)", typeTag, i + 1, gx, gy); if (ImGui::Selectable(label, selectedSpriteIdx == static_cast(i))) { selectedSpriteIdx = static_cast(i); selectedTransitionIdx = -1; } } ImGui::Separator(); ImGui::Text("Порталы перехода (%zu / %d):", transitions.size(), MAX_LEVEL_TRANSITIONS); for (size_t i = 0; i < transitions.size(); ++i) { char label[96]; snprintf(label, sizeof(label), "Портал #%zu (%d, %d) -> %s", i + 1, transitions[i].triggerCellX, transitions[i].triggerCellY, transitions[i].targetMap[0] ? transitions[i].targetMap : ""); if (ImGui::Selectable(label, selectedTransitionIdx == static_cast(i))) { selectedTransitionIdx = static_cast(i); selectedSpriteIdx = -1; } } } void EditorApp::RenderInspector() { if (showCutsceneTimeline && selectedCutsceneEvent >= 0) { RenderCutsceneInspector(); ImGui::Separator(); } ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "ИНСПЕКТОР СВОЙСТВ"); ImGui::Separator(); if (ImGui::CollapsingHeader("Спавн игрока", ImGuiTreeNodeFlags_DefaultOpen)) { ImGui::SliderInt("Spawn X", &playerStartX, 0, MAP_GRID_W - 1); ImGui::SliderInt("Spawn Y", &playerStartY, 0, MAP_GRID_H - 1); float degrees = (playerStartAngle * 360.0f) / 512.0f; if (ImGui::SliderFloat("Угол обзора (°)", °rees, 0.0f, 360.0f, "%.0f°")) { playerStartAngle = static_cast((degrees * 512.0f) / 360.0f) & 511; } const char* dirName = "Восток (East)"; if (playerStartAngle >= 64 && playerStartAngle < 192) dirName = "Юг (South)"; else if (playerStartAngle >= 192 && playerStartAngle < 320) dirName = "Запад (West)"; else if (playerStartAngle >= 320 && playerStartAngle < 448) dirName = "Север (North)"; ImGui::Text("Направление: %s", dirName); } if (ImGui::CollapsingHeader("Окружение сцены", ImGuiTreeNodeFlags_DefaultOpen)) { // Floor & Ceiling Color Pickers float ceilCol[3] = { ((ceilingColor >> 16) & 0xFF) / 255.0f, ((ceilingColor >> 8) & 0xFF) / 255.0f, (ceilingColor & 0xFF) / 255.0f }; if (ImGui::ColorEdit3("Цвет потолка", ceilCol)) { ceilingColor = (static_cast(ceilCol[0] * 255.0f) << 16) | (static_cast(ceilCol[1] * 255.0f) << 8) | static_cast(ceilCol[2] * 255.0f); } float floorCol[3] = { ((floorColor >> 16) & 0xFF) / 255.0f, ((floorColor >> 8) & 0xFF) / 255.0f, (floorColor & 0xFF) / 255.0f }; if (ImGui::ColorEdit3("Цвет пола", floorCol)) { floorColor = (static_cast(floorCol[0] * 255.0f) << 16) | (static_cast(floorCol[1] * 255.0f) << 8) | static_cast(floorCol[2] * 255.0f); } } if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { if (ImGui::CollapsingHeader("Выбранный спрайт", ImGuiTreeNodeFlags_DefaultOpen)) { auto& spr = sprites[selectedSpriteIdx]; // Position (raw world units -> cell coords for UI) float sx = spr.x / 64.0f; float sy = spr.y / 64.0f; if (ImGui::SliderFloat("Sprite X", &sx, 0.0f, MAP_GRID_W - 0.01f)) { spr.x = static_cast(sx * 64.0f); } if (ImGui::SliderFloat("Sprite Y", &sy, 0.0f, MAP_GRID_H - 0.01f)) { spr.y = static_cast(sy * 64.0f); } // Texture slot int tid = static_cast(spr.textureId & 15); if (ImGui::Combo("Текстура", &tid, g_TexSlotNames, 16)) { spr.textureId = static_cast(tid); } if (tid >= 0 && tid < 16 && textureSRVs[tid]) { ImGui::Text("Превью спрайта:"); ImGui::Image(reinterpret_cast(textureSRVs[tid]), ImVec2(64, 64)); ImGui::SameLine(); ImGui::BeginGroup(); if (ImGui::Button("Заменить PNG...")) { ImportTextureImage(tid, true); } if (ImGui::Button("Экспорт PNG")) { ExportTextureImage(tid); } if (ImGui::Button("Сброс")) { ResetTextureSlotToDefault(tid); } ImGui::EndGroup(); } // Scale (16.16 fixed -> float for UI) float scaleF = spr.scale / 65536.0f; if (ImGui::SliderFloat("Масштаб", &scaleF, 0.25f, 4.0f, "%.2f")) { spr.scale = static_cast(scaleF * 65536.0f); } // Entity Archetype & Logic ImGui::Separator(); ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Геймплей / Архетип сущности:"); static const char* s_ArchetypeNames[6] = { "0: Декорация / Проп (Static)", "1: Враг (Enemy Guard AI)", "2: Аптечка (+25 HP)", "3: Патроны (+20 Ammo)", "4: Ключ от двери (Key Pickup)", "5: Переключатель / Рычаг (Switch)" }; int archType = static_cast(GetSpriteEntityType(spr)); int hp = static_cast(GetSpriteHealth(spr)); int spd = static_cast(GetSpriteSpeed(spr)); int agg = static_cast(GetSpriteAggro(spr)); if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) { PushUndo(); SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); } if (archType == ENTITY_ARCHETYPE_GUARD) { if (ImGui::SliderInt("Здоровье (HP)", &hp, 10, 500)) { SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); } if (ImGui::SliderInt("Скорость", &spd, 1, 8)) { SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); } if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) { SetSpriteEntityData(spr, static_cast(archType), static_cast(hp), static_cast(spd), static_cast(agg)); } } else if (archType == ENTITY_ARCHETYPE_MEDKIT) { ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе"); } else if (archType == ENTITY_ARCHETYPE_AMMO) { ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f), "Пополняет +20 патронов игроку при подборе"); } else if (archType == ENTITY_ARCHETYPE_KEY) { ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Ключ для открытия запертых дверей"); } else if (archType == ENTITY_ARCHETYPE_SWITCH) { ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.2f, 1.0f), "Активируется нажатием клавиши [E] рядом с объектом"); } if (ImGui::Button("Редактировать логику в Редакторе кода (F7)")) { showCodeEditorModal = true; } ImGui::Separator(); if (ImGui::Button("Удалить спрайт")) { PushUndo(); sprites.erase(sprites.begin() + selectedSpriteIdx); selectedSpriteIdx = -1; } } } if (selectedTransitionIdx >= 0 && selectedTransitionIdx < static_cast(transitions.size())) { if (ImGui::CollapsingHeader("Свойства портала перехода", ImGuiTreeNodeFlags_DefaultOpen)) { auto& trans = transitions[selectedTransitionIdx]; ImGui::TextColored(ImVec4(0.7f, 0.4f, 1.0f, 1.0f), "Триггер-ячейка: (%d, %d)", trans.triggerCellX, trans.triggerCellY); ImGui::TextWrapped("Файл подуровня должен лежать рядом с текущей картой."); // Target map file char mapBuf[32]; strncpy_s(mapBuf, trans.targetMap, sizeof(mapBuf)); if (ImGui::InputText("Файл карты (.dat)", mapBuf, sizeof(mapBuf))) { strncpy_s(trans.targetMap, mapBuf, sizeof(trans.targetMap)); } ImGui::SameLine(); if (ImGui::Button("Обзор...")) { std::string path = BrowseOpenFile(); if (!path.empty()) { size_t lastSlash = path.find_last_of("\\/"); std::string fname = (lastSlash != std::string::npos) ? path.substr(lastSlash + 1) : path; if (IsMapFilename(fname.c_str())) { strcpy_s(trans.targetMap, fname.c_str()); } else { statusMessage = "Имя подуровня: ASCII, до 31 символа, расширение .dat."; } } } // Target spawn coords int spX = trans.targetSpawnX; int spY = trans.targetSpawnY; if (ImGui::SliderInt("Целевой Spawn X", &spX, 0, MAP_GRID_W - 1)) trans.targetSpawnX = static_cast(spX); if (ImGui::SliderInt("Целевой Spawn Y", &spY, 0, MAP_GRID_H - 1)) trans.targetSpawnY = static_cast(spY); float targetDeg = (trans.targetAngle * 360.0f) / 512.0f; if (ImGui::SliderFloat("Целевой угол (°)", &targetDeg, 0.0f, 360.0f, "%.0f°")) { trans.targetAngle = static_cast(((int)((targetDeg * 512.0f) / 360.0f)) & 511); } bool fadeB = (trans.fadeEffect != 0); if (ImGui::Checkbox("Плавное затемнение экрана (Fade to Black)", &fadeB)) { trans.fadeEffect = fadeB ? 1 : 0; } ImGui::Separator(); if (ImGui::Button("Перейти к подуровню в редакторе")) { const auto directory = fs::path(currentFilePath).parent_path(); std::string targetPath = (directory / trans.targetMap).string(); if (SaveLevel(currentFilePath)) LoadLevel(targetPath); } ImGui::SameLine(); if (ImGui::Button("Удалить портал")) { PushUndo(); if (trans.triggerCellX < MAP_GRID_W && trans.triggerCellY < MAP_GRID_H) { if (map[trans.triggerCellY][trans.triggerCellX] == CELL_PORTAL) { map[trans.triggerCellY][trans.triggerCellX] = CELL_EMPTY; } } RemoveCutsceneTransition(selectedTransitionIdx); transitions.erase(transitions.begin() + selectedTransitionIdx); selectedTransitionIdx = -1; } } } } #include "editor_cutscene.inl" #include "editor_viewport.inl" #include "editor_code.inl" void EditorApp::RenderProjectSettingsModal() { if (!showProjectSettingsModal) return; if (!ImGui::IsPopupOpen("Настройки проекта")) { ImGui::OpenPopup("Настройки проекта"); } ImGui::SetNextWindowSize(ImVec2(520, 380), ImGuiCond_FirstUseEver); if (ImGui::BeginPopupModal("Настройки проекта", &showProjectSettingsModal, ImGuiWindowFlags_AlwaysAutoResize)) { static char titleBuf[128] = ""; static char authorBuf[128] = ""; static char versionBuf[32] = ""; static char windowTitleBuf[128] = ""; static bool init = true; if (init) { strncpy_s(titleBuf, project->title.c_str(), sizeof(titleBuf) - 1); strncpy_s(authorBuf, project->author.c_str(), sizeof(authorBuf) - 1); strncpy_s(versionBuf, project->version.c_str(), sizeof(versionBuf) - 1); strncpy_s(windowTitleBuf, project->windowTitle.c_str(), sizeof(windowTitleBuf) - 1); init = false; } ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Параметры игры (.eblanproj):"); ImGui::Separator(); ImGui::InputText("Название игры", titleBuf, sizeof(titleBuf)); ImGui::InputText("Автор", authorBuf, sizeof(authorBuf)); ImGui::InputText("Версия", versionBuf, sizeof(versionBuf)); ImGui::InputText("Заголовок окна", windowTitleBuf, sizeof(windowTitleBuf)); ImGui::Spacing(); ImGui::Text("Стартовая карта: %s", project->startupMap.c_str()); ImGui::Separator(); if (ImGui::Button("Сохранить настройки")) { project->title = titleBuf; project->author = authorBuf; project->version = versionBuf; project->windowTitle = windowTitleBuf; char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); std::string projPath = (fs::path(module).parent_path() / "project.eblanproj").string(); project->Save(projPath); statusMessage = "Настройки проекта сохранены в project.eblanproj"; init = true; ImGui::CloseCurrentPopup(); showProjectSettingsModal = false; } ImGui::SameLine(); if (ImGui::Button("Отмена")) { init = true; ImGui::CloseCurrentPopup(); showProjectSettingsModal = false; } ImGui::EndPopup(); } } void EditorApp::RenderExportGameModal() { if (!showExportGameModal) return; if (!ImGui::IsPopupOpen("Экспорт автономной игры (.exe)")) { ImGui::OpenPopup("Экспорт автономной игры (.exe)"); } ImGui::SetNextWindowSize(ImVec2(600, 360), ImGuiCond_FirstUseEver); if (ImGui::BeginPopupModal("Экспорт автономной игры (.exe)", &showExportGameModal, ImGuiWindowFlags_AlwaysAutoResize)) { static char destPathBuf[MAX_PATH] = "dist\\MyGame"; static bool exported = false; ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Сборка релизного дистрибутива игры"); ImGui::TextWrapped("Экспортер упакует автономный исполняемый файл игры (.exe), все карты, текстуры и звуки в готовую для публикации папку."); ImGui::Separator(); ImGui::Text("Название игры: %s", project->title.c_str()); ImGui::Text("Версия: %s | Автор: %s", project->version.c_str(), project->author.c_str()); ImGui::InputText("Папка экспорта", destPathBuf, sizeof(destPathBuf)); ImGui::Spacing(); if (ImGui::Button("Экспортировать игру!")) { char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); std::string root = fs::path(module).parent_path().string(); std::string fullDest = (fs::path(root) / destPathBuf).string(); if (StandaloneExporter::ExportGame(*project, fullDest, root, exportStatusMessage)) { exported = true; statusMessage = "Игра успешно экспортирована в: " + fullDest; } else { exported = false; statusMessage = "Ошибка экспорта: " + exportStatusMessage; } } ImGui::SameLine(); if (ImGui::Button("Закрыть")) { ImGui::CloseCurrentPopup(); showExportGameModal = false; } if (!exportStatusMessage.empty()) { ImGui::Separator(); if (exported) { ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.3f, 1.0f), "%s", exportStatusMessage.c_str()); if (ImGui::Button("Открыть папку с игрой в Проводнике")) { char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); std::string root = fs::path(module).parent_path().string(); std::string fullDest = (fs::path(root) / destPathBuf).string(); ShellExecuteA(nullptr, "open", fullDest.c_str(), nullptr, nullptr, SW_SHOW); } } else { ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", exportStatusMessage.c_str()); } } ImGui::EndPopup(); } }