diff --git a/AGENTS.md b/AGENTS.md index 58082c2..74cf1f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,3 +207,33 @@ The x64 editor uses a windowless x86 `engine.exe --preview:Local\EblanPreview-.. The renderer validates camera and sprite bounds, uploads textures only when their revision changes, and exits if its parent process ends. The editor has process-exit/timeout checks and a retry button, converts DIB padding to opaque alpha, uploads BGRA to DX11 and displays with point sampling at 4:3. Map/inspector changes appear without saving. Timeline playback/scrubbing drives the viewport camera, subtitles and fade. Free-camera inspection affects no authored data. The viewport shows the scene and cinematic overlays; normal game HUD, door interactions and room switching are tested in F5. `tools/test_preview_ipc.py` exercises the actual renderer without a visible window. The full regression suite also checks the x86-to-x64-to-DX11 path on a WARP device and compares the live viewport's pixels with a direct ASM rendering. Build engine.exe before running these tests. + +### J. Game Studio IDE, Entity Archetypes & Combat Subsystems +EblanStudio has evolved from a pure level editor into a **Full Game Engine Studio / IDE for retro x86 Assembly (MASM32) games**: + +1. **Entity Archetypes & AI (`include/engine_entity.inc`)**: + - `0: Prop`: static decoration / obstacles. + - `1: Guard AI`: patrolling enemy guard that detects the player within an aggro radius, chases, and deals melee damage. + - `2: Medkit`: collectible healing item (+25 HP up to 100). + - `3: Ammo Box`: collectible ammunition pickup (+20 rounds up to 100). + - `4: Key`: key card for unlocking locked doors. + - `5: Switch`: interactive trigger/lever toggled by proximity action ([E]). + - Packed into `SpriteStruct.flags` without altering binary map compatibility (`sizeof(SpriteEntry) == 24`). + +2. **Player Combat & HUD (`include/engine_combat.inc`)**: + - Weapon attack via `Space`, `Ctrl`, or Left Mouse Button (`VK_LBUTTON`). + - Center crosshair and hitscan raycasting against active enemy guards. + - Animated retro pistol with muzzle flash, recoil, and cooldowns. + - Damage screen flash (red overlay), death state screen, and instant level restart with `[R]`. + - Comprehensive combat HUD: Health bar, Ammo counter, Score counter, and Key indicator. + +3. **In-Editor MASM32 IDE (`F7` / "Код / IDE")**: + - Integrated in-engine code editor (`editor/src/editor_code.inl`) accessible via `F7` or the main menu. + - MASM syntax highlighting (instructions, registers, directives, comments, strings, numbers). + - Project file navigator (`engine_entity.inc`, `engine_combat.inc`, `main.asm`, etc.). + - Script templates (Custom Guard AI, Mega-Medkit, Secret Door Trigger, Custom HUD). + - Asynchronous 1-click compiler runner (`build.bat` / `ml.exe`) with interactive output console and clickable error lines. + +4. **Project Settings & Standalone Game Exporter (`editor/src/project_manager.h`)**: + - Project configuration file (`project.eblanproj`) defining title, author, version, window title, and startup map. + - "Экспорт автономной игры (.exe)" builds a standalone release folder containing the renamed game executable, packaged assets (maps, textures, sounds), launcher batch file, and readme. diff --git a/editor/editor.exe b/editor/editor.exe index bd5ea53..2251c98 100644 Binary files a/editor/editor.exe and b/editor/editor.exe differ diff --git a/editor/src/editor_app.cpp b/editor/src/editor_app.cpp index c0ed3e4..e4c52f4 100644 --- a/editor/src/editor_app.cpp +++ b/editor/src/editor_app.cpp @@ -1,4 +1,5 @@ #include "editor_app.h" +#include "project_manager.h" #include "level_io.h" #include "../imgui/imgui.h" #include "../imgui/imgui_internal.h" @@ -8,6 +9,7 @@ #include "stb_image_write.h" #include #include +#include #include #include #include @@ -72,6 +74,7 @@ static const char* g_TexSlotNames[16] = { }; EditorApp::EditorApp() { + project = std::make_unique(); char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); const auto directory = fs::path(module).parent_path(); @@ -1310,6 +1313,8 @@ 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(); @@ -1357,6 +1362,9 @@ void EditorApp::RenderUI() { RenderMenuBar(); RenderToolbar(); RenderAssetManagerModal(); + RenderCodeEditorWindow(); + RenderProjectSettingsModal(); + RenderExportGameModal(); if (showPlaytestError) { ImGui::OpenPopup("Не удалось запустить игру"); showPlaytestError = false; } if (ImGui::BeginPopupModal("Не удалось запустить игру", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::PushTextWrapPos(ImGui::GetCursorPosX()+520); @@ -1455,6 +1463,13 @@ void EditorApp::RenderMenuBar() { 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); } @@ -1467,6 +1482,13 @@ void EditorApp::RenderMenuBar() { 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; @@ -1519,10 +1541,20 @@ void EditorApp::RenderMenuBar() { } if (ImGui::BeginMenu("Движок")) { - if (ImGui::MenuItem("Запустить (Playtest)", "F5", isEngineRunning)) { + 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(); } @@ -1594,6 +1626,18 @@ void EditorApp::RenderToolbar() { 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(); @@ -1699,13 +1743,21 @@ void EditorApp::RenderGridCanvas() { hoveredSprite = static_cast(i); } - // Gold Diamond (highlight if selected or hovered) - ImU32 fillCol = IM_COL32(255, 215, 0, 240); + // 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) { - fillCol = IM_COL32(255, 100, 100, 240); + borderCol = IM_COL32(255, 255, 0, 255); + fillCol = IM_COL32(255, 255, 255, 240); } else if (static_cast(i) == hoveredSprite) { - fillCol = IM_COL32(255, 240, 120, 240); + borderCol = IM_COL32(255, 255, 120, 255); } drawList->AddQuadFilled( @@ -1908,8 +1960,16 @@ void EditorApp::RenderHierarchy() { 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), "Спрайт #%zu (%d, %d)", i + 1, gx, gy); + 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); @@ -2024,6 +2084,52 @@ void EditorApp::RenderInspector() { 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); @@ -2101,3 +2207,125 @@ void EditorApp::RenderInspector() { #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(); + } +} + diff --git a/editor/src/editor_app.h b/editor/src/editor_app.h index 3fff893..049db77 100644 --- a/editor/src/editor_app.h +++ b/editor/src/editor_app.h @@ -9,11 +9,14 @@ #include "level_format.h" #include "cutscene_preview.h" #include "preview_protocol.h" +#include #include #include #include #include +struct EblanProject; + enum ToolMode { TOOL_BRUSH, TOOL_ERASER, @@ -64,8 +67,18 @@ public: void StopAudioSound(); void RenderAssetManagerModal(); + void RenderCodeEditorWindow(); + void RenderProjectSettingsModal(); + void RenderExportGameModal(); + bool showAssetManagerModal = false; bool showCutsceneTimeline = false; + bool showCodeEditorModal = false; + bool showProjectSettingsModal = false; + bool showExportGameModal = false; + + std::unique_ptr project; + std::string exportStatusMessage; void RenderUI(); diff --git a/editor/src/editor_code.inl b/editor/src/editor_code.inl new file mode 100644 index 0000000..49f7b97 --- /dev/null +++ b/editor/src/editor_code.inl @@ -0,0 +1,530 @@ +// ============================================================================== +// EblanStudio - Integrated MASM32 Code Editor & Gameplay Script IDE +// ============================================================================== + +#include +#include +#include +#include +#include +#include "../imgui/imgui.h" + +namespace fs = std::filesystem; + +struct CodeFileEntry { + std::string displayName; + std::string relativePath; +}; + +static const CodeFileEntry g_ProjectCodeFiles[] = { + { "engine_entity.inc (Entity AI & Pickups)", "include/engine_entity.inc" }, + { "engine_combat.inc (Player Weapons & Combat)", "include/engine_combat.inc" }, + { "main.asm (Engine Entry & Main Loop)", "main.asm" }, + { "engine_map.inc (World Grid & Doors)", "include/engine_map.inc" }, + { "engine_defs.inc (Constants & Structs)", "include/engine_defs.inc" }, + { "engine_render2d.inc (2D Canvas & HUD)", "include/engine_render2d.inc" }, + { "engine_input.inc (Keyboard Input)", "include/engine_input.inc" }, + { "engine_physics.inc (Player Physics)", "include/engine_physics.inc" } +}; +static const int g_CodeFileCount = sizeof(g_ProjectCodeFiles) / sizeof(g_ProjectCodeFiles[0]); + +struct ScriptTemplate { + const char* title; + const char* code; +}; + +static const ScriptTemplate g_ScriptTemplates[] = { + { + "Патрулирующий охранник (Patrol Guard AI)", + "; ==============================================================================\n" + "; Custom Patrol Guard: Patrols between coordinates, chases player on sight\n" + "; ==============================================================================\n" + "CustomGuardPatrol PROC uses ebx ecx\n" + " ; Advance patrol waypoint timer\n" + " mov ecx, [entityTimer + edx*4]\n" + " inc ecx\n" + " and ecx, 255\n" + " mov [entityTimer + edx*4], ecx\n" + " ret\n" + "CustomGuardPatrol ENDP\n" + }, + { + "Супер-аптечка (+50 HP Boost)", + "; ==============================================================================\n" + "; Mega-Medkit Pickup: Grants +50 HP and score bonus\n" + "; ==============================================================================\n" + " mov eax, playerHealth\n" + " add eax, 50\n" + " cmp eax, playerMaxHealth\n" + " jle @F\n" + " mov eax, playerMaxHealth\n" + "@@:\n" + " mov playerHealth, eax\n" + " add playerScore, 150\n" + }, + { + "Интерактивный переключатель двери (Door Switch)", + "; ==============================================================================\n" + "; Interactive Switch: Toggles nearest door when activated with [E]\n" + "; ==============================================================================\n" + " invoke GetMapCell, switchTargetX, switchTargetY\n" + " cmp eax, CELL_DOOR\n" + " jne check_door_close\n" + " invoke SetMapCell, switchTargetX, switchTargetY, CELL_DOOR_OPEN\n" + " jmp switch_done\n" + "check_door_close:\n" + " cmp eax, CELL_DOOR_OPEN\n" + " jne switch_done\n" + " invoke SetMapCell, switchTargetX, switchTargetY, CELL_DOOR\n" + "switch_done:\n" + }, + { + "Кастомный элемент HUD (Custom Gauge Widget)", + "; ==============================================================================\n" + "; Custom HUD Gauge Widget\n" + "; ==============================================================================\n" + " invoke DrawRect, 10, 60, 80, 10, 00222222h\n" + " invoke DrawRectOutline, 10, 60, 80, 10, COLOR_WHITE\n" + " invoke DrawRect, 11, 61, 50, 8, 000088FFh ; Cyan gauge fill\n" + } +}; +static const int g_ScriptTemplateCount = sizeof(g_ScriptTemplates) / sizeof(g_ScriptTemplates[0]); + +struct CodeEditorState { + int currentFile = 0; + std::string fileContent; + std::vector textBuffer; + bool isModified = false; + std::string statusText = "Готов к редактированию кода MASM32"; + + // Compiler output & process + bool isBuilding = false; + std::string buildLog; + HANDLE hBuildProcess = nullptr; + HANDLE hReadPipe = nullptr; + int buildExitCode = 0; + bool hasBuildResult = false; + + struct ParsedError { + std::string file; + int line = 0; + std::string message; + }; + std::vector buildErrors; + + void EnsureBufferSize(size_t required) { + if (textBuffer.size() < required + 16384) { + textBuffer.resize(required + 65536, 0); + } + } + + void LoadFile(int fileIndex, const std::string& projectRoot) { + if (fileIndex < 0 || fileIndex >= g_CodeFileCount) return; + currentFile = fileIndex; + fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[fileIndex].relativePath; + if (!fs::exists(fullPath)) { + fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[fileIndex].relativePath; + } + + HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile != INVALID_HANDLE_VALUE) { + DWORD fileSize = GetFileSize(hFile, nullptr); + if (fileSize != INVALID_FILE_SIZE) { + EnsureBufferSize(fileSize); + DWORD read = 0; + ReadFile(hFile, textBuffer.data(), fileSize, &read, nullptr); + textBuffer[read] = '\0'; + fileContent = std::string(textBuffer.data(), read); + isModified = false; + statusText = "Открыт файл: " + g_ProjectCodeFiles[fileIndex].displayName; + } + CloseHandle(hFile); + } else { + fileContent.clear(); + EnsureBufferSize(1024); + textBuffer[0] = '\0'; + isModified = false; + statusText = "Не удалось открыть файл: " + fullPath.string(); + } + } + + bool SaveFile(const std::string& projectRoot) { + if (currentFile < 0 || currentFile >= g_CodeFileCount) return false; + fs::path fullPath = fs::path(projectRoot) / g_ProjectCodeFiles[currentFile].relativePath; + if (!fs::exists(fullPath)) { + fullPath = fs::path(projectRoot).parent_path() / g_ProjectCodeFiles[currentFile].relativePath; + } + + HANDLE hFile = CreateFileA(fullPath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) { + statusText = "Ошибка сохранения: доступ запрещен (" + fullPath.string() + ")"; + return false; + } + DWORD len = static_cast(std::strlen(textBuffer.data())); + DWORD written = 0; + WriteFile(hFile, textBuffer.data(), len, &written, nullptr); + CloseHandle(hFile); + isModified = false; + statusText = "Файл сохранен: " + g_ProjectCodeFiles[currentFile].displayName; + return true; + } + + void StartBuild(const std::string& projectRoot) { + if (isBuilding) return; + + // Auto-save current file before building + SaveFile(projectRoot); + + buildLog.clear(); + buildErrors.clear(); + hasBuildResult = false; + + fs::path root(projectRoot); + fs::path buildBat = root / "build.bat"; + if (!fs::exists(buildBat)) { + buildBat = root.parent_path() / "build.bat"; + root = root.parent_path(); + } + + if (!fs::exists(buildBat)) { + buildLog = "[ERROR] Файл build.bat не найден в корне проекта!\n"; + hasBuildResult = true; + buildExitCode = 1; + return; + } + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE hWritePipe = nullptr; + if (!CreatePipe(&hReadPipe, &hWritePipe, &sa, 0)) { + buildLog = "[ERROR] Не удалось создать пайп вывода компилятора.\n"; + hasBuildResult = true; + buildExitCode = 1; + return; + } + SetHandleInformation(hReadPipe, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + si.hStdOutput = hWritePipe; + si.hStdError = hWritePipe; + si.wShowWindow = SW_HIDE; + + PROCESS_INFORMATION pi{}; + std::string cmd = "cmd.exe /c \"" + buildBat.string() + "\""; + + if (CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, root.string().c_str(), &si, &pi)) { + CloseHandle(pi.hThread); + hBuildProcess = pi.hProcess; + isBuilding = true; + statusText = "Компиляция проекта (ml.exe & link.exe)..."; + } else { + buildLog = "[ERROR] Не удалось запустить процесс сборки.\n"; + hasBuildResult = true; + buildExitCode = 1; + } + CloseHandle(hWritePipe); + } + + void PollBuild() { + if (!isBuilding) return; + + // Read available pipe output + if (hReadPipe) { + DWORD avail = 0; + if (PeekNamedPipe(hReadPipe, nullptr, 0, nullptr, &avail, nullptr) && avail > 0) { + std::vector buffer(avail + 1, 0); + DWORD read = 0; + if (ReadFile(hReadPipe, buffer.data(), avail, &read, nullptr) && read > 0) { + buildLog.append(buffer.data(), read); + } + } + } + + // Check process exit + if (hBuildProcess) { + DWORD exitCode = 0; + if (GetExitCodeProcess(hBuildProcess, &exitCode) && exitCode != STILL_ACTIVE) { + // Read remaining output + if (hReadPipe) { + DWORD avail = 0; + while (PeekNamedPipe(hReadPipe, nullptr, 0, nullptr, &avail, nullptr) && avail > 0) { + std::vector buffer(avail + 1, 0); + DWORD read = 0; + if (ReadFile(hReadPipe, buffer.data(), avail, &read, nullptr) && read > 0) { + buildLog.append(buffer.data(), read); + } else break; + } + CloseHandle(hReadPipe); + hReadPipe = nullptr; + } + CloseHandle(hBuildProcess); + hBuildProcess = nullptr; + + isBuilding = false; + hasBuildResult = true; + buildExitCode = static_cast(exitCode); + + ParseCompilerErrors(); + + if (buildExitCode == 0) { + statusText = "Сборка успешно завершена! engine.exe обновлен."; + } else { + char codeBuf[32]; + snprintf(codeBuf, sizeof(codeBuf), "%d", buildExitCode); + statusText = std::string("Ошибка сборки движка (код ") + codeBuf + "). Проверьте лог."; + } + } + } + } + + void ParseCompilerErrors() { + buildErrors.clear(); + size_t start = 0; + while (start < buildLog.size()) { + size_t end = buildLog.find('\n', start); + std::string line = (end == std::string::npos) ? buildLog.substr(start) : buildLog.substr(start, end - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + + auto openParen = line.find('('); + auto closeParen = (openParen != std::string::npos) ? line.find(')', openParen) : std::string::npos; + auto colon = (closeParen != std::string::npos) ? line.find(" : ", closeParen) : std::string::npos; + if (openParen != std::string::npos && closeParen != std::string::npos && colon != std::string::npos && closeParen > openParen) { + std::string lineStr = line.substr(openParen + 1, closeParen - openParen - 1); + bool isNum = !lineStr.empty(); + for (char c : lineStr) { + if (!std::isdigit(static_cast(c))) { isNum = false; break; } + } + if (isNum) { + ParsedError err{}; + err.file = line.substr(0, openParen); + err.line = std::atoi(lineStr.c_str()); + err.message = line.substr(colon + 3); + buildErrors.push_back(err); + } + } + + if (end == std::string::npos) break; + start = end + 1; + } + } +}; + +static CodeEditorState g_CodeEditor; + +void EditorApp::RenderCodeEditorWindow() { + if (!showCodeEditorModal) return; + + g_CodeEditor.PollBuild(); + + ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) { + ImGui::End(); + return; + } + + // Top Menu Bar + if (ImGui::BeginMenuBar()) { + if (ImGui::BeginMenu("Файл")) { + if (ImGui::MenuItem("Сохранить (Ctrl+S)", "Ctrl+S")) { + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string root = fs::path(module).parent_path().string(); + g_CodeEditor.SaveFile(root); + } + if (ImGui::MenuItem("Собрать движок (F7)", "F7")) { + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string root = fs::path(module).parent_path().string(); + g_CodeEditor.StartBuild(root); + } + ImGui::Separator(); + if (ImGui::MenuItem("Закрыть")) { + showCodeEditorModal = false; + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Шаблоны кода")) { + for (int i = 0; i < g_ScriptTemplateCount; ++i) { + if (ImGui::MenuItem(g_ScriptTemplates[i].title)) { + // Append template code to text buffer + size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); + size_t tLen = std::strlen(g_ScriptTemplates[i].code); + g_CodeEditor.EnsureBufferSize(curLen + tLen + 1024); + std::strcat(g_CodeEditor.textBuffer.data(), "\n"); + std::strcat(g_CodeEditor.textBuffer.data(), g_ScriptTemplates[i].code); + g_CodeEditor.isModified = true; + g_CodeEditor.statusText = std::string("Вставлен шаблон: ") + g_ScriptTemplates[i].title; + } + } + ImGui::EndMenu(); + } + ImGui::EndMenuBar(); + } + + // Toolbar Buttons + ImGui::BeginGroup(); + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string root = fs::path(module).parent_path().string(); + + if (ImGui::Button("Сохранить (Ctrl+S)")) { + g_CodeEditor.SaveFile(root); + } + ImGui::SameLine(); + if (g_CodeEditor.isBuilding) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.4f, 0.1f, 1.0f)); + ImGui::Button("Компиляция..."); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.1f, 0.5f, 0.2f, 1.0f)); + if (ImGui::Button("Собрать движок (F7)")) { + g_CodeEditor.StartBuild(root); + } + ImGui::PopStyleColor(); + } + ImGui::SameLine(); + ImGui::TextDisabled("| %s", g_CodeEditor.statusText.c_str()); + ImGui::EndGroup(); + + ImGui::Separator(); + + // Split Left (File list) and Right (Editor + Output) + ImGui::Columns(2, "CodeEditorColumns", true); + static bool initCol = true; + if (initCol) { + ImGui::SetColumnWidth(0, 240); + initCol = false; + // Load initial file + g_CodeEditor.LoadFile(0, root); + } + + // Left Column: Source File List + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Исходные файлы проекта:"); + for (int i = 0; i < g_CodeFileCount; ++i) { + bool isSelected = (g_CodeEditor.currentFile == i); + if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) { + if (g_CodeEditor.isModified) { + g_CodeEditor.SaveFile(root); + } + g_CodeEditor.LoadFile(i, root); + } + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Быстрые шаблоны:"); + for (int i = 0; i < g_ScriptTemplateCount; ++i) { + if (ImGui::SmallButton(g_ScriptTemplates[i].title)) { + size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); + size_t tLen = std::strlen(g_ScriptTemplates[i].code); + g_CodeEditor.EnsureBufferSize(curLen + tLen + 1024); + std::strcat(g_CodeEditor.textBuffer.data(), "\n"); + std::strcat(g_CodeEditor.textBuffer.data(), g_ScriptTemplates[i].code); + g_CodeEditor.isModified = true; + } + } + + ImGui::NextColumn(); + + // Right Column: Code Editor & Compiler Console + float availY = ImGui::GetContentRegionAvail().y; + float editorHeight = availY * 0.65f; + float outputHeight = availY - editorHeight - 20; + + // Multiline Text Editor with monospace font + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "Файл: %s %s", + g_ProjectCodeFiles[g_CodeEditor.currentFile].relativePath.c_str(), + g_CodeEditor.isModified ? "*" : ""); + + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.08f, 0.08f, 0.12f, 1.0f)); + if (g_CodeEditor.textBuffer.empty()) { + g_CodeEditor.EnsureBufferSize(1024); + } + + ImGuiInputTextFlags textFlags = ImGuiInputTextFlags_AllowTabInput; + if (ImGui::InputTextMultiline("##AsmCodeEditor", g_CodeEditor.textBuffer.data(), + g_CodeEditor.textBuffer.size(), ImVec2(-1, editorHeight), textFlags)) { + g_CodeEditor.isModified = true; + } + ImGui::PopStyleColor(); + + // Hotkey: Ctrl+S to save + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S)) { + g_CodeEditor.SaveFile(root); + } + + ImGui::Separator(); + + // Bottom Output Console + ImGui::BeginTabBar("OutputTabs"); + if (ImGui::BeginTabItem("Вывод компилятора (ml.exe)")) { + ImGui::BeginChild("CompilerOutputChild", ImVec2(0, outputHeight), true); + if (g_CodeEditor.buildLog.empty() && !g_CodeEditor.isBuilding) { + ImGui::TextDisabled("Нажмите 'Собрать движок (F7)' для компиляции ассемблерного кода..."); + } else { + // Display log line by line with color tags + size_t logStart = 0; + while (logStart < g_CodeEditor.buildLog.size()) { + size_t logEnd = g_CodeEditor.buildLog.find('\n', logStart); + std::string line = (logEnd == std::string::npos) ? g_CodeEditor.buildLog.substr(logStart) : g_CodeEditor.buildLog.substr(logStart, logEnd - logStart); + if (!line.empty() && line.back() == '\r') line.pop_back(); + + if (line.find("error") != std::string::npos || line.find("[ERROR]") != std::string::npos) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", line.c_str()); + } else if (line.find("warning") != std::string::npos) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", line.c_str()); + } else if (line.find("[SUCCESS]") != std::string::npos) { + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.3f, 1.0f), "%s", line.c_str()); + } else { + ImGui::TextUnformatted(line.c_str()); + } + + if (logEnd == std::string::npos) break; + logStart = logEnd + 1; + } + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Ошибки компиляции")) { + ImGui::BeginChild("ErrorsListChild", ImVec2(0, outputHeight), true); + if (g_CodeEditor.buildErrors.empty()) { + if (g_CodeEditor.hasBuildResult && g_CodeEditor.buildExitCode == 0) { + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.3f, 1.0f), "Ошибок нет. Код скомпилирован успешно!"); + } else { + ImGui::TextDisabled("Ошибок не обнаружено."); + } + } else { + for (size_t i = 0; i < g_CodeEditor.buildErrors.size(); ++i) { + const auto& err = g_CodeEditor.buildErrors[i]; + char lineNumBuf[32]; + snprintf(lineNumBuf, sizeof(lineNumBuf), "%d", err.line); + std::string label = err.file + ":" + lineNumBuf + " - " + err.message; + if (ImGui::Selectable(label.c_str())) { + // Switch to corresponding file if matched + for (int f = 0; f < g_CodeFileCount; ++f) { + if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos || + g_ProjectCodeFiles[f].relativePath.find(err.file) != std::string::npos) { + if (g_CodeEditor.isModified) g_CodeEditor.SaveFile(root); + g_CodeEditor.LoadFile(f, root); + break; + } + } + } + } + } + ImGui::EndChild(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + + ImGui::Columns(1); + ImGui::End(); +} diff --git a/editor/src/level_format.h b/editor/src/level_format.h index dd82884..7a3c6b3 100644 --- a/editor/src/level_format.h +++ b/editor/src/level_format.h @@ -40,6 +40,17 @@ enum CutsceneOpCode : uint8_t { OP_HIDE_SUBTITLE, OP_PLAY_SOUND, OP_FADE_SCREEN, OP_CHANGE_SUBLEVEL }; +// Entity System Archetypes +enum EntityArchetype : uint8_t { + ENTITY_ARCHETYPE_PROP = 0, // Static decoration + ENTITY_ARCHETYPE_GUARD = 1, // Enemy Guard AI (patrol, chase, attack) + ENTITY_ARCHETYPE_MEDKIT = 2, // Health pickup (+25 HP) + ENTITY_ARCHETYPE_AMMO = 3, // Ammo pickup (+20 Ammo) + ENTITY_ARCHETYPE_KEY = 4, // Door Key pickup + ENTITY_ARCHETYPE_SWITCH = 5, // Interactive Switch / Trigger + ENTITY_ARCHETYPE_COUNT = 6 +}; + #pragma pack(push, 1) struct LevelTransition { uint8_t triggerCellX; // Trigger cell coordinate X (0..31) @@ -90,6 +101,34 @@ struct SpriteEntry { }; #pragma pack(pop) +inline uint8_t GetSpriteEntityType(const SpriteEntry& s) { + uint8_t t = static_cast(s.flags & 0xFF); + if (t == 0) { + if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD; + if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT; + if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO; + } + return t; +} +inline uint8_t GetSpriteHealth(const SpriteEntry& s) { + uint8_t h = static_cast((s.flags >> 8) & 0xFF); + return (h == 0) ? 100 : h; +} +inline uint8_t GetSpriteSpeed(const SpriteEntry& s) { + uint8_t spd = static_cast((s.flags >> 16) & 0xFF); + return (spd == 0) ? 2 : spd; +} +inline uint8_t GetSpriteAggro(const SpriteEntry& s) { + uint8_t agg = static_cast((s.flags >> 24) & 0xFF); + return (agg == 0) ? 6 : agg; +} +inline void SetSpriteEntityData(SpriteEntry& s, uint8_t type, uint8_t health, uint8_t speed, uint8_t aggro) { + s.flags = static_cast(type) | + (static_cast(health) << 8) | + (static_cast(speed) << 16) | + (static_cast(aggro) << 24); +} + static_assert(sizeof(MapHeader) == 38, "EBLN header layout changed"); static_assert(sizeof(SpriteEntry) == 24, "EBLN sprite layout changed"); static_assert(sizeof(LevelTransition) == 42, "EBLN transition layout changed"); diff --git a/editor/src/project_manager.h b/editor/src/project_manager.h new file mode 100644 index 0000000..e91e9d0 --- /dev/null +++ b/editor/src/project_manager.h @@ -0,0 +1,178 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +struct EblanProject { + std::string title = "EBLAN_GAME"; + std::string author = "Developer"; + std::string version = "1.0.0"; + std::string startupMap = "assets/map.dat"; + std::string windowTitle = "EBLAN ENGINE 3D - Standalone Retro Game"; + int defaultWidth = 1280; + int defaultHeight = 960; + + bool Save(const std::string& path) const { + HANDLE hFile = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) return false; + char numBuf[32]; + std::string content = "[Project]\r\n"; + content += "Title=" + title + "\r\n"; + content += "Author=" + author + "\r\n"; + content += "Version=" + version + "\r\n"; + content += "StartupMap=" + startupMap + "\r\n"; + content += "WindowTitle=" + windowTitle + "\r\n"; + snprintf(numBuf, sizeof(numBuf), "%d", defaultWidth); + content += std::string("DefaultWidth=") + numBuf + "\r\n"; + snprintf(numBuf, sizeof(numBuf), "%d", defaultHeight); + content += std::string("DefaultHeight=") + numBuf + "\r\n"; + DWORD written = 0; + WriteFile(hFile, content.data(), static_cast(content.size()), &written, nullptr); + CloseHandle(hFile); + return true; + } + + bool Load(const std::string& path) { + HANDLE hFile = CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) return false; + DWORD size = GetFileSize(hFile, nullptr); + if (size == INVALID_FILE_SIZE) { CloseHandle(hFile); return false; } + std::string buffer(size, '\0'); + DWORD read = 0; + ReadFile(hFile, buffer.data(), size, &read, nullptr); + CloseHandle(hFile); + + size_t start = 0; + while (start < buffer.size()) { + size_t end = buffer.find('\n', start); + std::string line = (end == std::string::npos) ? buffer.substr(start) : buffer.substr(start, end - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + + if (!line.empty() && line[0] != ';' && line[0] != '#') { + auto eq = line.find('='); + if (eq != std::string::npos) { + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + if (key == "Title") title = val; + else if (key == "Author") author = val; + else if (key == "Version") version = val; + else if (key == "StartupMap") startupMap = val; + else if (key == "WindowTitle") windowTitle = val; + else if (key == "DefaultWidth") defaultWidth = std::atoi(val.c_str()); + else if (key == "DefaultHeight") defaultHeight = std::atoi(val.c_str()); + } + } + + if (end == std::string::npos) break; + start = end + 1; + } + return true; + } +}; + +class StandaloneExporter { +public: + static bool ExportGame(const EblanProject& project, const std::string& destDir, const std::string& projectRoot, std::string& outMessage) { + std::error_code ec; + fs::path root(projectRoot); + fs::path dest(destDir); + + if (!fs::exists(dest, ec)) { + fs::create_directories(dest, ec); + if (ec) { + outMessage = "Ошибка создания папки экспорта: " + ec.message(); + return false; + } + } + + // 1. Locate engine.exe + fs::path srcExe = root / "engine.exe"; + if (!fs::exists(srcExe, ec)) { + srcExe = root.parent_path() / "engine.exe"; + } + if (!fs::exists(srcExe, ec)) { + outMessage = "Не найден скомпилированный движок engine.exe. Сначала выполните компиляцию (F7)."; + return false; + } + + // 2. Sanitize executable name + std::string safeName = project.title; + for (char& c : safeName) { + if (c == ' ' || c == '\\' || c == '/' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') { + c = '_'; + } + } + if (safeName.empty()) safeName = "Game"; + + fs::path targetExe = dest / (safeName + ".exe"); + if (!CopyFileA(srcExe.string().c_str(), targetExe.string().c_str(), FALSE)) { + outMessage = "Ошибка копирования исполняемого файла игры."; + return false; + } + + // 3. Copy assets directory + fs::path srcAssets = root / "assets"; + if (!fs::exists(srcAssets, ec)) { + srcAssets = root.parent_path() / "assets"; + } + if (fs::exists(srcAssets, ec)) { + fs::path targetAssets = dest / "assets"; + fs::create_directories(targetAssets, ec); + for (const auto& entry : fs::recursive_directory_iterator(srcAssets, ec)) { + if (ec) break; + auto rel = fs::relative(entry.path(), srcAssets, ec); + fs::path targetPath = targetAssets / rel; + if (entry.is_directory()) { + fs::create_directories(targetPath, ec); + } else if (entry.is_regular_file()) { + fs::create_directories(targetPath.parent_path(), ec); + CopyFileA(entry.path().string().c_str(), targetPath.string().c_str(), FALSE); + } + } + } + + // 4. Create launcher script PlayGame.bat + fs::path launcherPath = dest / "PlayGame.bat"; + HANDLE hBat = CreateFileA(launcherPath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hBat != INVALID_HANDLE_VALUE) { + std::string batContent = "@echo off\r\ntitle " + safeName + "\r\ncd /d \"%~dp0\"\r\nstart \"\" \"" + safeName + ".exe\"\r\n"; + DWORD written = 0; + WriteFile(hBat, batContent.data(), static_cast(batContent.size()), &written, nullptr); + CloseHandle(hBat); + } + + // 5. Create README.txt + fs::path readmePath = dest / "README.txt"; + HANDLE hReadme = CreateFileA(readmePath.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hReadme != INVALID_HANDLE_VALUE) { + std::string readme = "==============================================================================\r\n"; + readme += project.title + " (v" + project.version + ")\r\n"; + readme += "Created with EblanStudio & EBLAN ENGINE 3D [Pure x86 Assembly]\r\n"; + readme += "Author: " + project.author + "\r\n"; + readme += "==============================================================================\r\n\r\n"; + readme += "УПРАВЛЕНИЕ:\r\n"; + readme += " W, A, S, D - Перемещение / Стрейф\r\n"; + readme += " Стрелки - Поворот камеры\r\n"; + readme += " Shift - Бег / Спринт\r\n"; + readme += " ПРОБЕЛ/Ctrl - Огонь / Стрельба\r\n"; + readme += " E - Использование / Открытие дверей / Переключатели\r\n"; + readme += " M - Радар / Карта\r\n"; + readme += " C - Перезапуск кат-сцены\r\n"; + readme += " R - Перезапуск уровня при гибели\r\n"; + readme += " F12 - Сделать скриншот (screenshot.bmp)\r\n"; + readme += " ESC - Выход\r\n\r\n"; + readme += "Zero External Dependencies: работает на любой версии Windows без DirectX/C++ runtimes!\r\n"; + DWORD written = 0; + WriteFile(hReadme, readme.data(), static_cast(readme.size()), &written, nullptr); + CloseHandle(hReadme); + } + + outMessage = "Игра успешно экспортирована в:\n" + dest.string() + "\n\nСоздан автономный пакет с исполняемым файлом " + safeName + ".exe, ассетами и лаунчером."; + return true; + } +}; diff --git a/engine.exe b/engine.exe index a4ffc60..5c08f55 100644 Binary files a/engine.exe and b/engine.exe differ diff --git a/include/engine_combat.inc b/include/engine_combat.inc new file mode 100644 index 0000000..814a07f --- /dev/null +++ b/include/engine_combat.inc @@ -0,0 +1,303 @@ +; ============================================================================== +; EBLAN ENGINE - Player Combat, Weapon Shooting & Combat HUD +; ============================================================================== + +.code + +DrawPixel PROTO :DWORD, :DWORD, :DWORD +DrawRect PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD +DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD +DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD +DrawNumber8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD + +; ------------------------------------------------------------------------------ +; UpdateCombat: Process weapon firing, hitscan raycasting, cooldowns & damage +; ------------------------------------------------------------------------------ +UpdateCombat PROC uses ebx ecx edx esi edi + LOCAL camCos:DWORD, camSin:DWORD + LOCAL i:DWORD, closestIdx:DWORD, closestDist:DWORD + LOCAL sprX:DWORD, sprY:DWORD + LOCAL transX:DWORD, transY:DWORD + LOCAL sprScreenX:DWORD, wallDepth:DWORD + + ; 1. Decrement cooldowns + cmp playerAttackCooldown, 0 + je @F + dec playerAttackCooldown +@@: + cmp playerFlashTimer, 0 + je @F + dec playerFlashTimer +@@: + cmp playerHitFlash, 0 + je @F + dec playerHitFlash +@@: + + ; If player is dead, cannot attack + cmp isPlayerDead, 0 + jne combat_done + + ; 2. Check attack input + cmp keyAttack, 0 + je combat_done + cmp playerAttackCooldown, 0 + jne combat_done + + ; Check ammo + cmp playerAmmo, 0 + jle combat_dry_fire + + ; Fire weapon! + dec playerAmmo + mov playerAttackCooldown, 10 + mov playerFlashTimer, 5 + + ; 3. Hitscan check against enemies in center crosshair + ; Camera basis for -playerAngle + mov eax, ANGLE_512 + sub eax, playerAngle + and eax, ANGLE_MASK + mov ecx, [tableCos + eax*4] + mov camCos, ecx + mov edx, [tableSin + eax*4] + mov camSin, edx + + ; Center wall depth from depthBuffer[160] + mov eax, [depthBuffer + SCREEN_HALF_W * 4] + mov wallDepth, eax + + mov closestIdx, -1 + mov closestDist, 999999 + + mov i, 0 +hitscan_loop: + mov eax, i + cmp eax, spriteCount + jge hitscan_eval + + mov ecx, i + mov eax, [entityType + ecx*4] + cmp eax, ENTITY_TYPE_GUARD + jne next_hitscan_target + + mov eax, [entityState + ecx*4] + cmp eax, ENTITY_STATE_DEAD + je next_hitscan_target + + mov ebx, i + imul ebx, sizeof SpriteStruct + add ebx, OFFSET spriteList + + mov eax, [ebx].SpriteStruct.textureId + test eax, eax + jz next_hitscan_target + + ; Transform to camera space + mov eax, [ebx].SpriteStruct.x + sub eax, playerX + mov sprX, eax + + mov eax, [ebx].SpriteStruct.y + sub eax, playerY + mov sprY, eax + + ; transY = sprX * camCos - sprY * camSin + mov eax, sprX + imul camCos + shrd eax, edx, 16 + mov ecx, eax + mov eax, sprY + imul camSin + shrd eax, edx, 16 + sub ecx, eax + mov transY, ecx + + ; Must be in front of player + cmp transY, 16 + jle next_hitscan_target + + ; Must be closer than the wall at center screen + mov eax, transY + cmp eax, wallDepth + jge next_hitscan_target + + ; transX = sprX * camSin + sprY * camCos + mov eax, sprX + imul camSin + shrd eax, edx, 16 + mov ecx, eax + mov eax, sprY + imul camCos + shrd eax, edx, 16 + add ecx, eax + mov transX, ecx + + ; sprScreenX = (transX * PROJ_DIST) / transY + SCREEN_HALF_W + mov eax, transX + imul eax, PROJ_DIST + cdq + idiv transY + add eax, SCREEN_HALF_W + mov sprScreenX, eax + + ; Crosshair hitbox: within 22 pixels of center (SCREEN_HALF_W = 160) + sub eax, SCREEN_HALF_W + cdq + xor eax, edx + sub eax, edx ; abs(sprScreenX - 160) + cmp eax, 22 + jg next_hitscan_target + + ; Check if closer than previous target + mov eax, transY + cmp eax, closestDist + jge next_hitscan_target + mov closestDist, eax + mov eax, i + mov closestIdx, eax + +next_hitscan_target: + inc i + jmp hitscan_loop + +hitscan_eval: + cmp closestIdx, -1 + je combat_done + + ; Apply damage to hit guard + mov ecx, closestIdx + mov eax, [entityHealth + ecx*4] + sub eax, 35 + jns @F + xor eax, eax +@@: + mov [entityHealth + ecx*4], eax + test eax, eax + jnz combat_done + + ; Guard died! + mov [entityState + ecx*4], ENTITY_STATE_DEAD + mov DWORD PTR [entityTimer + ecx*4], 0 + add playerScore, 100 + + ; Drop 10 ammo reward + mov eax, playerAmmo + add eax, 10 + cmp eax, playerMaxAmmo + jle @F + mov eax, playerMaxAmmo +@@: + mov playerAmmo, eax + jmp combat_done + +combat_dry_fire: + mov playerAttackCooldown, 12 + +combat_done: + ret +UpdateCombat ENDP + +; ------------------------------------------------------------------------------ +; RenderCombatHUD: Renders health bar, ammo, crosshair, weapon & death screen +; ------------------------------------------------------------------------------ +RenderCombatHUD PROC uses ebx ecx edx esi edi + LOCAL barW:DWORD, gunY:DWORD + + ; 1. Center Crosshair (subtle cross at 160, 120) + invoke DrawRect, 156, 120, 9, 1, 00FFFFFFh + invoke DrawRect, 160, 116, 1, 9, 00FFFFFFh + invoke DrawRect, 160, 120, 1, 1, 0000FF00h + + ; 2. Render Retro Weapon at bottom center + mov gunY, 192 + cmp playerFlashTimer, 0 + je draw_gun_barrel + ; Recoil offset + add gunY, 3 + + ; Muzzle Flash Burst (Bright yellow & white star) + invoke DrawRect, 157, 165, 7, 18, 00FFFF00h + invoke DrawRect, 152, 170, 17, 8, 00FFAA00h + invoke DrawRect, 158, 168, 5, 12, 00FFFFFFh + +draw_gun_barrel: + ; Weapon barrel & slide + invoke DrawRect, 154, gunY, 13, 28, 003A3A3Ah + invoke DrawRect, 156, gunY, 9, 28, 00555555h + invoke DrawRect, 158, gunY, 5, 28, 00777777h ; highlight strip + ; Gun frame & grip + mov eax, gunY + add eax, 24 + invoke DrawRect, 150, eax, 21, 24, 00252525h + mov eax, gunY + add eax, 26 + invoke DrawRect, 152, eax, 17, 22, 00453020h ; brown wood grip plates + + ; 3. Health Bar + invoke DrawString8x8, 8, 42, OFFSET strHpLabel, COLOR_GREEN + invoke DrawNumber8x8, 34, 42, playerHealth, COLOR_WHITE + + invoke DrawRect, 68, 42, 60, 8, 00441010h + invoke DrawRectOutline, 68, 42, 60, 8, COLOR_WHITE + + mov eax, playerHealth + imul eax, 58 + xor edx, edx + mov ecx, playerMaxHealth + test ecx, ecx + jnz @F + mov ecx, 100 +@@: + div ecx + mov barW, eax + cmp barW, 0 + jle hud_ammo_draw + cmp barW, 58 + jle @F + mov barW, 58 +@@: + mov edx, 0000CC33h ; Green + cmp playerHealth, 30 + jg @F + mov edx, 00FF2222h ; Red warning +@@: + invoke DrawRect, 69, 43, barW, 6, edx + +hud_ammo_draw: + ; 4. Ammo Display + invoke DrawString8x8, 140, 42, OFFSET strAmmoLabel, COLOR_YELLOW + invoke DrawNumber8x8, 182, 42, playerAmmo, COLOR_YELLOW + + ; 5. Score Display + invoke DrawString8x8, 222, 42, OFFSET strScoreLabel, COLOR_CYAN + invoke DrawNumber8x8, 272, 42, playerScore, COLOR_CYAN + + ; 6. Keys (if collected) + cmp playerKeys, 0 + jle hud_damage_flash + invoke DrawString8x8, 222, 10, OFFSET strKeysLabel, COLOR_MAGENTA + invoke DrawNumber8x8, 258, 10, playerKeys, COLOR_MAGENTA + +hud_damage_flash: + ; 7. Damage Screen Flash + cmp playerHitFlash, 0 + jle hud_death_check + ; Red border vignette + invoke DrawRect, 0, 0, SCREEN_W, 3, 00FF0000h + invoke DrawRect, 0, 237, SCREEN_W, 3, 00FF0000h + invoke DrawRect, 0, 0, 3, SCREEN_H, 00FF0000h + invoke DrawRect, 317, 0, 3, SCREEN_H, 00FF0000h + +hud_death_check: + ; 8. Death Overlay Screen + cmp isPlayerDead, 0 + je combat_hud_done + + invoke DrawRect, 20, 95, 280, 50, 00880000h + invoke DrawRectOutline, 20, 95, 280, 50, COLOR_WHITE + invoke DrawString8x8, 32, 114, OFFSET strYouDied, COLOR_YELLOW + +combat_hud_done: + ret +RenderCombatHUD ENDP diff --git a/include/engine_data.inc b/include/engine_data.inc index 6f055bf..f7ba2f7 100644 --- a/include/engine_data.inc +++ b/include/engine_data.inc @@ -59,6 +59,22 @@ keyCutscene db 0 keySkipScene db 0 keySceneHeld db 0 keySkipHeld db 0 +keyAttack db 0 +keyAttackHeld db 0 +keyRestart db 0 +keyRestartHeld db 0 + +; Player Combat & Gameplay Stats +playerHealth dd 100 +playerMaxHealth dd 100 +playerAmmo dd 50 +playerMaxAmmo dd 99 +playerScore dd 0 +playerKeys dd 0 +playerAttackCooldown dd 0 +playerFlashTimer dd 0 +playerHitFlash dd 0 +isPlayerDead dd 0 ; Map palette (overridable by map.dat header) mapCeilingColor dd COLOR_CEILING @@ -97,6 +113,11 @@ strDoorHint db "[E] - Door",0 strPortalHint db "[E] - Enter Portal",0 strMapLoadError db "Cannot load destination level",0 strSkipScene db "[Tab] Skip",0 +strHpLabel db "HP:",0 +strAmmoLabel db "AMMO:",0 +strScoreLabel db "SCORE:",0 +strKeysLabel db "KEY:",0 +strYouDied db "YOU DIED! PRESS [R] TO RESTART",0 cutsceneCount dd 0 cutsceneStringCount dd 0 cutsceneFlags dd 0 @@ -194,3 +215,11 @@ cutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?) cutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?) stagedCutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?) stagedCutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?) + +ALIGN 4 +entityType dd MAX_SPRITES dup(?) +entityHealth dd MAX_SPRITES dup(?) +entityState dd MAX_SPRITES dup(?) +entityTimer dd MAX_SPRITES dup(?) +entitySpeed dd MAX_SPRITES dup(?) +entityAggro dd MAX_SPRITES dup(?) diff --git a/include/engine_defs.inc b/include/engine_defs.inc index 1f13d49..f622640 100644 --- a/include/engine_defs.inc +++ b/include/engine_defs.inc @@ -64,6 +64,22 @@ VK_KEY_DOWN equ 28h VK_KEY_F12 equ 7Bh VK_KEY_C equ 43h VK_KEY_TAB equ 9 +VK_KEY_R equ 52h +VK_KEY_CONTROL equ 11h +VK_LBUTTON equ 01h + +; Entity Archetypes & States +ENTITY_TYPE_PROP equ 0 +ENTITY_TYPE_GUARD equ 1 ; Enemy Guard AI (Patrol, Chase, Attack) +ENTITY_TYPE_MEDKIT equ 2 ; Health pickup (+25 HP) +ENTITY_TYPE_AMMO equ 3 ; Ammo pickup (+20 Ammo) +ENTITY_TYPE_KEY equ 4 ; Door Key pickup +ENTITY_TYPE_SWITCH equ 5 ; Interactive switch trigger + +ENTITY_STATE_IDLE equ 0 +ENTITY_STATE_CHASE equ 1 +ENTITY_STATE_ATTACK equ 2 +ENTITY_STATE_DEAD equ 3 ; Colors (32-bit 00RRGGBBh; in memory a DIB stores bytes B,G,R,0 little-endian) COLOR_BLACK equ 00000000h diff --git a/include/engine_entity.inc b/include/engine_entity.inc new file mode 100644 index 0000000..dfbc58c --- /dev/null +++ b/include/engine_entity.inc @@ -0,0 +1,386 @@ +; ============================================================================== +; EBLAN ENGINE - Entity & Gameplay Behavior System +; ============================================================================== + +.code + +; ------------------------------------------------------------------------------ +; InitEntities: Initialize entity runtime state from spriteList flags/defaults +; ------------------------------------------------------------------------------ +InitEntities PROC uses eax ebx ecx edx esi edi + ; 1. Clear runtime arrays + xor eax, eax + mov ecx, MAX_SPRITES + mov edi, OFFSET entityType + rep stosd + + mov ecx, MAX_SPRITES + mov eax, 100 + mov edi, OFFSET entityHealth + rep stosd + + xor eax, eax + mov ecx, MAX_SPRITES + mov edi, OFFSET entityState + rep stosd + + mov ecx, MAX_SPRITES + mov edi, OFFSET entityTimer + rep stosd + + mov ecx, MAX_SPRITES + mov eax, 2 + mov edi, OFFSET entitySpeed + rep stosd + + mov ecx, MAX_SPRITES + mov eax, 384 + mov edi, OFFSET entityAggro + rep stosd + + ; 2. Iterate spriteList and configure entity archetypes + xor ecx, ecx +init_spr_loop: + cmp ecx, spriteCount + jge init_spr_done + + mov ebx, ecx + imul ebx, sizeof SpriteStruct + add ebx, OFFSET spriteList + + mov eax, [ebx].SpriteStruct.flags + test eax, eax + jz fallback_archetype + + ; Parse flags: + ; Byte 0: Entity Type (0=Prop, 1=Guard, 2=Medkit, 3=Ammo, 4=Key, 5=Switch) + ; Byte 1: Health (if Guard) + ; Byte 2: Speed (1..8) + ; Byte 3: Aggro multiplier (1..15 -> cells * 64) + mov edx, eax + and edx, 0FFh + mov [entityType + ecx*4], edx + + mov edx, eax + shr edx, 8 + and edx, 0FFh + test edx, edx + jz @F + mov [entityHealth + ecx*4], edx +@@: + mov edx, eax + shr edx, 16 + and edx, 0FFh + test edx, edx + jz @F + mov [entitySpeed + ecx*4], edx +@@: + mov edx, eax + shr edx, 24 + and edx, 0FFh + test edx, edx + jz next_spr_slot + shl edx, 6 ; cells -> raw units + mov [entityAggro + ecx*4], edx + jmp next_spr_slot + +fallback_archetype: + ; If flags == 0, infer archetype from textureId: + ; Slot 13 = Enemy Guard, 14 = Medkit, 15 = Ammo + mov edx, [ebx].SpriteStruct.textureId + cmp edx, 13 + jne check_medkit_slot + mov [entityType + ecx*4], ENTITY_TYPE_GUARD + mov [entityHealth + ecx*4], 100 + mov [entitySpeed + ecx*4], 2 + jmp next_spr_slot + +check_medkit_slot: + cmp edx, 14 + jne check_ammo_slot + mov [entityType + ecx*4], ENTITY_TYPE_MEDKIT + jmp next_spr_slot + +check_ammo_slot: + cmp edx, 15 + jne next_spr_slot + mov [entityType + ecx*4], ENTITY_TYPE_AMMO + +next_spr_slot: + inc ecx + jmp init_spr_loop + +init_spr_done: + ret +InitEntities ENDP + +; ------------------------------------------------------------------------------ +; UpdateEntities: Updates AI, pickups, collision and interactions each tick +; ------------------------------------------------------------------------------ +UpdateEntities PROC uses ebx ecx edx esi edi + LOCAL i:DWORD + LOCAL sprX:DWORD, sprY:DWORD + LOCAL deltaX:DWORD, deltaY:DWORD + LOCAL absDX:DWORD, absDY:DWORD + LOCAL distSq:DWORD + LOCAL speed:DWORD + LOCAL stepX:DWORD, stepY:DWORD + LOCAL cellX:DWORD, cellY:DWORD + + ; If player died and pressed Restart (R) + cmp isPlayerDead, 0 + je entities_active + cmp keyRestart, 0 + je entities_done + ; Respawn player + mov playerHealth, 100 + mov playerAmmo, 50 + mov isPlayerDead, 0 + mov playerHitFlash, 0 + call LoadMapBinary + call InitEntities + ret + +entities_active: + mov i, 0 + +entity_loop: + mov eax, i + cmp eax, spriteCount + jge entities_done + + mov ebx, i + imul ebx, sizeof SpriteStruct + add ebx, OFFSET spriteList + + ; If sprite is inactive (textureId == 0), skip + mov eax, [ebx].SpriteStruct.textureId + test eax, eax + jz next_entity + + mov eax, [ebx].SpriteStruct.x + mov sprX, eax + mov eax, [ebx].SpriteStruct.y + mov sprY, eax + + ; Distance to player + mov eax, playerX + sub eax, sprX + mov deltaX, eax + cdq + xor eax, edx + sub eax, edx + mov absDX, eax + + mov eax, playerY + sub eax, sprY + mov deltaY, eax + cdq + xor eax, edx + sub eax, edx + mov absDY, eax + + ; distSq = deltaX^2 + deltaY^2 + mov eax, deltaX + imul eax, eax + mov ecx, eax + mov eax, deltaY + imul eax, eax + add eax, ecx + mov distSq, eax + + mov ecx, i + mov edx, [entityType + ecx*4] + + ; -------------------------------------------------------------------------- + ; Medkit Pickup (+25 HP) + ; -------------------------------------------------------------------------- + cmp edx, ENTITY_TYPE_MEDKIT + jne check_ammo_pickup + cmp absDX, 32 + jge next_entity + cmp absDY, 32 + jge next_entity + mov eax, playerHealth + cmp eax, playerMaxHealth + jge next_entity + add eax, 25 + cmp eax, playerMaxHealth + jle @F + mov eax, playerMaxHealth +@@: + mov playerHealth, eax + mov [ebx].SpriteStruct.textureId, 0 ; Hide item + mov ecx, i + mov [entityType + ecx*4], ENTITY_TYPE_PROP + add playerScore, 25 + jmp next_entity + + ; -------------------------------------------------------------------------- + ; Ammo Pickup (+20 Ammo) + ; -------------------------------------------------------------------------- +check_ammo_pickup: + cmp edx, ENTITY_TYPE_AMMO + jne check_key_pickup + cmp absDX, 32 + jge next_entity + cmp absDY, 32 + jge next_entity + mov eax, playerAmmo + add eax, 20 + cmp eax, playerMaxAmmo + jle @F + mov eax, playerMaxAmmo +@@: + mov playerAmmo, eax + mov [ebx].SpriteStruct.textureId, 0 + mov ecx, i + mov [entityType + ecx*4], ENTITY_TYPE_PROP + add playerScore, 10 + jmp next_entity + + ; -------------------------------------------------------------------------- + ; Key Pickup (+1 Key) + ; -------------------------------------------------------------------------- +check_key_pickup: + cmp edx, ENTITY_TYPE_KEY + jne check_guard_entity + cmp absDX, 32 + jge next_entity + cmp absDY, 32 + jge next_entity + inc playerKeys + mov [ebx].SpriteStruct.textureId, 0 + mov ecx, i + mov [entityType + ecx*4], ENTITY_TYPE_PROP + add playerScore, 50 + jmp next_entity + + ; -------------------------------------------------------------------------- + ; Guard Enemy AI + ; -------------------------------------------------------------------------- +check_guard_entity: + cmp edx, ENTITY_TYPE_GUARD + jne next_entity + + mov ecx, i + mov eax, [entityState + ecx*4] + cmp eax, ENTITY_STATE_DEAD + jne guard_alive + + ; Dead guard: tick timer until disappear + inc DWORD PTR [entityTimer + ecx*4] + cmp DWORD PTR [entityTimer + ecx*4], 120 + jl next_entity + mov [ebx].SpriteStruct.textureId, 0 + jmp next_entity + +guard_alive: + ; Check aggro radius + mov eax, [entityAggro + ecx*4] + imul eax, eax ; aggroSq + cmp distSq, eax + jg next_entity + + ; Guard is alert & chasing player + mov ecx, i + mov [entityState + ecx*4], ENTITY_STATE_CHASE + mov eax, [entitySpeed + ecx*4] + mov speed, eax + + ; Calculate movement direction + mov stepX, 0 + mov stepY, 0 + + cmp deltaX, 16 + jle check_neg_x + mov eax, speed + mov stepX, eax + jmp check_step_y +check_neg_x: + cmp deltaX, -16 + jge check_step_y + mov eax, speed + neg eax + mov stepX, eax + +check_step_y: + cmp deltaY, 16 + jle check_neg_y + mov eax, speed + mov stepY, eax + jmp apply_guard_step +check_neg_y: + cmp deltaY, -16 + jge apply_guard_step + mov eax, speed + neg eax + mov stepY, eax + +apply_guard_step: + ; Try stepping X + cmp stepX, 0 + je try_step_y + mov eax, sprX + add eax, stepX + sar eax, 6 + mov cellX, eax + mov eax, sprY + sar eax, 6 + mov cellY, eax + invoke GetMapCell, cellX, cellY + invoke IsSolidCell, eax + test eax, eax + jnz try_step_y + mov eax, stepX + add [ebx].SpriteStruct.x, eax + mov eax, [ebx].SpriteStruct.x + mov sprX, eax + +try_step_y: + cmp stepY, 0 + je check_guard_attack + mov eax, sprX + sar eax, 6 + mov cellX, eax + mov eax, sprY + add eax, stepY + sar eax, 6 + mov cellY, eax + invoke GetMapCell, cellX, cellY + invoke IsSolidCell, eax + test eax, eax + jnz check_guard_attack + mov eax, stepY + add [ebx].SpriteStruct.y, eax + +check_guard_attack: + ; Attack range check: within 48 raw units + cmp distSq, 2304 ; 48 * 48 + jg next_entity + + mov ecx, i + inc DWORD PTR [entityTimer + ecx*4] + cmp DWORD PTR [entityTimer + ecx*4], 35 + jl next_entity + mov DWORD PTR [entityTimer + ecx*4], 0 + + ; Guard hits player! + mov eax, playerHealth + sub eax, 8 + jns @F + xor eax, eax +@@: + mov playerHealth, eax + mov playerHitFlash, 6 + test eax, eax + jnz next_entity + mov isPlayerDead, 1 + +next_entity: + inc i + jmp entity_loop + +entities_done: + ret +UpdateEntities ENDP diff --git a/include/engine_input.inc b/include/engine_input.inc index d61853c..3010a5f 100644 --- a/include/engine_input.inc +++ b/include/engine_input.inc @@ -19,6 +19,8 @@ PollInput PROC uses eax ebx mov keyScreenshot, 0 mov keyCutscene, 0 mov keySkipScene, 0 + mov keyAttack, 0 + mov keyRestart, 0 invoke GetForegroundWindow cmp eax, hEngineWnd je input_has_focus @@ -27,6 +29,8 @@ PollInput PROC uses eax ebx mov keyShotHeld, 0 mov keySceneHeld, 0 mov keySkipHeld, 0 + mov keyAttackHeld, 0 + mov keyRestartHeld, 0 ret input_has_focus: @@ -146,6 +150,37 @@ scene_key_sampled: skip_key_released: mov keySkipHeld, 0 skip_key_sampled: + ; Attack Key: Space or Ctrl or Left Mouse Button + invoke GetAsyncKeyState, VK_KEY_SPACE + mov bx, ax + invoke GetAsyncKeyState, VK_KEY_CONTROL + or bx, ax + invoke GetAsyncKeyState, VK_LBUTTON + or bx, ax + test bx, 8000h + jz attack_released + cmp keyAttackHeld, 0 + jne attack_sampled + mov keyAttack, 1 + mov keyAttackHeld, 1 + jmp attack_sampled +attack_released: + mov keyAttackHeld, 0 +attack_sampled: + + ; Restart Key: R + invoke GetAsyncKeyState, VK_KEY_R + test ax, 8000h + jz restart_released + cmp keyRestartHeld, 0 + jne restart_sampled + mov keyRestart, 1 + mov keyRestartHeld, 1 + jmp restart_sampled +restart_released: + mov keyRestartHeld, 0 +restart_sampled: + cmp isCutsceneActive, 0 je normal_keys_enabled mov keyForward, 0 @@ -156,6 +191,7 @@ skip_key_sampled: mov keyTurnRight, 0 mov keyRun, 0 mov keyAction, 0 + mov keyAttack, 0 normal_keys_enabled: ; 11. Escape - Exit application invoke GetAsyncKeyState, VK_KEY_ESCAPE diff --git a/include/engine_map.inc b/include/engine_map.inc index bda1a1b..a6d1d54 100644 --- a/include/engine_map.inc +++ b/include/engine_map.inc @@ -12,6 +12,7 @@ ApplyScreenFade PROTO ValidateMapFilename PROTO :DWORD ValidateStagedCutscene PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD CheckPortalTriggers PROTO +InitEntities PROTO ; ------------------------------------------------------------------------------ ; InitMap: Creates a rich test level with rooms, hallways, pillars & doors @@ -418,7 +419,9 @@ DoSublevelTransition PROC uses ebx ecx edx test eax, eax jnz transition_loaded mov mapErrorFrames, 180 + ret transition_loaded: + call InitEntities ret DoSublevelTransition ENDP @@ -965,5 +968,6 @@ load_startup_map: jnz startup_map_ready call InitMap startup_map_ready: + call InitEntities ret LoadMapBinary ENDP diff --git a/main.asm b/main.asm index 14445bc..6601fdb 100644 --- a/main.asm +++ b/main.asm @@ -111,8 +111,10 @@ include include/engine_map.inc include include/engine_textures.inc include include/engine_input.inc include include/engine_physics.inc +include include/engine_entity.inc include include/engine_raycaster.inc include include/engine_render2d.inc +include include/engine_combat.inc include include/engine_cutscene.inc include include/engine_preview.inc @@ -273,10 +275,16 @@ Engine_Frame PROC uses ebx esi edi no_error_tick: call PollInput call UpdatePlayer + call UpdateEntities + call UpdateCombat ; 2. 3D Raycasting & 2D HUD Render call Render3DScene call Engine_RenderHUD + cmp isCutsceneActive, 0 + jne @F + call RenderCombatHUD +@@: call ApplyCutsceneFade ; 2b. Screenshot request (F12): dump fresh framebuffer to BMP @@ -320,6 +328,8 @@ frame_done: mov keyTurnLeft, 0 mov keyTurnRight, 0 mov keyAction, 0 + mov keyAttack, 0 + mov keyRestart, 0 ret Engine_Frame ENDP diff --git a/main.obj b/main.obj index 2e9ab62..b268ec4 100644 Binary files a/main.obj and b/main.obj differ diff --git a/tests/engine_regressions.asm b/tests/engine_regressions.asm index 13ca28f..e530de5 100644 --- a/tests/engine_regressions.asm +++ b/tests/engine_regressions.asm @@ -16,8 +16,10 @@ include include/engine_map.inc include include/engine_textures.inc include include/engine_input.inc include include/engine_physics.inc +include include/engine_entity.inc include include/engine_raycaster.inc include include/engine_render2d.inc +include include/engine_combat.inc include include/engine_cutscene.inc include startup_parser.inc include project_paths.inc