#include "Game.h" #include "AnimatedModel.h" #include "BoneAnimatedModel.h" #include "utils/Utils.h" #include "render/OpenGlExtensions.h" #include #include "render/TextureManager.h" #include "TextModel.h" #include #include #include #include #ifdef __ANDROID__ #include #endif #ifdef EMSCRIPTEN #include #endif #include "GameConstants.h" namespace ZL { void set_Texture(Character& npc, const TextureDataStruct& texture) { auto tt = std::make_shared(texture); npc.setTexture(tt); } void set_Texture(Character& npc, const std::string& meshName, const TextureDataStruct& texture) { auto tt = std::make_shared(texture); npc.setTexture(meshName, tt); } #ifdef EMSCRIPTEN const char* CONST_ZIP_FILE = "resources.zip"; #else const char* CONST_ZIP_FILE = ""; #endif float x = 0; float y = 0; float z = 0; #ifdef EMSCRIPTEN Game* Game::s_instance = nullptr; void Game::onResourcesZipLoaded(const char* /*filename*/) { std::cout << "Resources.zip loaded successfully" << std::endl; if (s_instance) { s_instance->mainThreadHandler.EnqueueMainThreadTask([&]() { s_instance->setupPart2(); }); } } void Game::onResourcesZipError(const char* /*filename*/) { std::cout << "Failed to download resources.zip" << std::endl; } #endif Game::Game() : newTickCount(0) , lastTickCount(0) , menuManager(renderer) , audioPlayer(std::make_unique()) { } Game::~Game() { #ifndef EMSCRIPTEN // In Emscripten, SDL must stay alive across context loss/restore cycles // so the window remains valid when the game object is re-created. SDL_Quit(); #endif } void Game::setup() { Environment::width = Environment::CONST_DEFAULT_WIDTH; Environment::height = Environment::CONST_DEFAULT_HEIGHT; Environment::computeProjectionDimensions(); ZL::BindOpenGlFunctions(); ZL::CheckGlError(__FILE__, __LINE__); renderer.InitOpenGL(); #ifdef EMSCRIPTEN // These shaders and loading.png are preloaded separately (not from zip), // so they are available immediately without waiting for resources.zip. renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_web.fragment", ""); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_web.fragment", ""); #else renderer.shaderManager.AddShaderFromFiles("defaultColor", "resources/shaders/defaultColor.vertex", "resources/shaders/defaultColor_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default", "resources/shaders/default.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); #endif loadingTexture = std::make_unique(CreateTextureDataFromPng("resources/loading.png", "")); float minDimension; float width = Environment::projectionWidth; float height = Environment::projectionHeight; if (width >= height) { minDimension = height; } else { minDimension = width; } loadingMesh.data = CreateRect2D({ 0.0f, 0.0f }, { minDimension*0.5f, minDimension*0.5f }, 3); loadingMesh.RefreshVBO(); #ifdef EMSCRIPTEN // Asynchronously download resources.zip; setupPart2() is called on completion. // The loading screen stays visible until the download finishes. s_instance = this; std::cout << "Load resurces step 1" << std::endl; emscripten_async_wget("resources.zip", "resources.zip", onResourcesZipLoaded, onResourcesZipError); #else mainThreadHandler.EnqueueMainThreadTask([this]() { std::cout << "Load resurces step 2" << std::endl; this->setupPart2(); std::cout << "Load resurces step 3" << std::endl; }); #endif } void Game::setupPart2() { #ifdef EMSCRIPTEN renderer.shaderManager.AddShaderFromFiles("env_sky", "resources/shaders/env_sky.vertex", "resources/shaders/env_sky_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("defaultAtmosphere", "resources/shaders/defaultAtmosphere.vertex", "resources/shaders/defaultAtmosphere_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetBake", "resources/shaders/planet_bake.vertex", "resources/shaders/planet_bake_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetStone", "resources/shaders/planet_stone.vertex", "resources/shaders/planet_stone_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetLand", "resources/shaders/planet_land.vertex", "resources/shaders/planet_land_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("spark", "resources/shaders/spark.vertex", "resources/shaders/spark_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning", "resources/shaders/skinning.vertex", "resources/shaders/default_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog", "resources/shaders/fog.vertex", "resources/shaders/fog_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning", "resources/shaders/fog_skinning.vertex", "resources/shaders/fog_web.fragment", CONST_ZIP_FILE); #else renderer.shaderManager.AddShaderFromFiles("env_sky", "resources/shaders/env_sky.vertex", "resources/shaders/env_sky_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("defaultAtmosphere", "resources/shaders/defaultAtmosphere.vertex", "resources/shaders/defaultAtmosphere_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetBake", "resources/shaders/planet_bake.vertex", "resources/shaders/planet_bake_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetStone", "resources/shaders/planet_stone.vertex", "resources/shaders/planet_stone_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("planetLand", "resources/shaders/planet_land.vertex", "resources/shaders/planet_land_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("spark", "resources/shaders/spark.vertex", "resources/shaders/spark_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning", "resources/shaders/skinning.vertex", "resources/shaders/default_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog", "resources/shaders/fog.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning", "resources/shaders/fog_skinning.vertex", "resources/shaders/fog_desktop.fragment", CONST_ZIP_FILE); #endif std::cout << "Load resurces step 4" << std::endl; LocationSetup params1; params1.roomMeshPath = "resources/w/room003.txt"; params1.roomTexturePath = "resources/w/room005.png"; params1.gameObjectsJsonPath = "resources/config2/gameobjects.json"; params1.npcsJsonPath = "resources/config2/npcs.json"; params1.dialoguesJsonPath = "resources/dialogue/sample_dialogues.json"; params1.navigationJsonPath = "resources/config2/navigation.json"; params1.scriptPath = "resources/start.lua"; params1.playerPosition = Eigen::Vector3f::Zero(); params1.teleportPosition = Eigen::Vector3f(-2.03001f, 0.f, -4.95618f); params1.teleportRadius = 1.5f; location1 = std::make_shared(renderer, inventory); location1->setup(params1); LocationSetup params2 = params1; params2.roomMeshPath = "resources/w/exterior/Segmented_Plane002.txt"; params2.roomTexturePath = "resources/w/exterior/Segmented_Plane002.png"; params2.gameObjectsJsonPath = "resources/config2/gameobjects2.json"; params2.navigationJsonPath = "resources/config2/navigation2.json"; params2.scriptPath = "resources/start2.lua"; params2.playerPosition = Eigen::Vector3f(7.f, 0.f, 27.f); params2.npcsJsonPath = "resources/config2/npcs2.json"; params2.teleportPosition = Eigen::Vector3f(-6.45, 0.0, 7.82); params2.teleportRadius = 1.5f; location2 = std::make_shared(renderer, inventory); location2->setup(params2); // Teleport callbacks: stepping into a location's zone hands the player // to the other location at *its* teleport position. We pre-arm the // destination's playerInTeleportZone so the player must walk out and // back in before they can teleport again. location1->onTeleport = [this]() { std::cout << "[TELEPORT] location 1 -> location 2" << std::endl; currentLocation = location2; if (currentLocation->player) { currentLocation->player->position = currentLocation->teleportPosition; currentLocation->player->setTarget(currentLocation->teleportPosition); } currentLocation->playerInTeleportZone = true; }; location2->onTeleport = [this]() { std::cout << "[TELEPORT] location 2 -> location 1" << std::endl; currentLocation = location1; if (currentLocation->player) { currentLocation->player->position = currentLocation->teleportPosition; currentLocation->player->setTarget(currentLocation->teleportPosition); } currentLocation->playerInTeleportZone = true; }; currentLocation = location2; std::cout << "Load resurces step 5" << std::endl; std::cout << "Load resurces step 12" << std::endl; // Shadow mapping shaders #ifdef EMSCRIPTEN renderer.shaderManager.AddShaderFromFiles("shadow_depth", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth_skinning", "resources/shaders/shadow_depth_skinning.vertex", "resources/shaders/shadow_depth_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default_shadow", "resources/shaders/default_shadow.vertex", "resources/shaders/default_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning_shadow", "resources/shaders/skinning_shadow.vertex", "resources/shaders/default_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_shadow", "resources/shaders/fog_shadow.vertex", "resources/shaders/fog_shadow_web.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning_shadow", "resources/shaders/fog_skinning_shadow.vertex", "resources/shaders/fog_shadow_web.fragment", CONST_ZIP_FILE); #else renderer.shaderManager.AddShaderFromFiles("shadow_depth", "resources/shaders/shadow_depth.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("shadow_depth_skinning", "resources/shaders/shadow_depth_skinning.vertex", "resources/shaders/shadow_depth_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("default_shadow", "resources/shaders/default_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("skinning_shadow", "resources/shaders/skinning_shadow.vertex", "resources/shaders/default_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_shadow", "resources/shaders/fog_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); renderer.shaderManager.AddShaderFromFiles("fog_skinning_shadow", "resources/shaders/fog_skinning_shadow.vertex", "resources/shaders/fog_shadow_desktop.fragment", CONST_ZIP_FILE); #endif std::cout << "Load resurces step 13" << std::endl; // Load UI with inventory button try { menuManager.uiManager.loadFromFile("resources/config2/ui_inventory.json", renderer, CONST_ZIP_FILE); std::cout << "UI loaded successfully" << std::endl; menuManager.uiManager.setNodeVisible("inventory_items_panel", false); menuManager.uiManager.setNodeVisible("close_inventory_button", false); menuManager.uiManager.setTextButtonCallback("inventory_button", [this](const std::string& name) { std::cout << "[UI] Inventory button clicked" << std::endl; this->menuManager.uiManager.setNodeVisible("inventory_items_panel", true); this->menuManager.uiManager.setNodeVisible("close_inventory_button", true); this->inventoryOpen = true; // Update UI with current items const auto& items = this->inventory.getItems(); std::string itemText; if (items.empty()) { itemText = "Inventory (Empty)"; } else { itemText = "Inventory (" + std::to_string(items.size()) + " items)\n\n"; for (size_t i = 0; i < items.size(); ++i) { itemText += std::to_string(i + 1) + ". " + items[i].name + "\n"; } } this->menuManager.uiManager.setText("inventory_items_text", itemText); }); menuManager.uiManager.setTextButtonCallback("close_inventory_button", [this](const std::string& name) { std::cout << "[UI] Close button clicked" << std::endl; menuManager.uiManager.setNodeVisible("inventory_items_panel", false); menuManager.uiManager.setNodeVisible("close_inventory_button", false); inventoryOpen = false; }); } catch (const std::exception& e) { std::cerr << "Failed to load UI: " << e.what() << std::endl; } loadingCompleted = true; if (audioPlayer->init()) { audioPlayer->setMusicVolume(100); audioPlayer->setSoundVolume(80); std::cout << "Audio initialized successfully" << std::endl; } else { std::cout << "Audio initialization failed" << std::endl; } } void Game::drawUI() { glClear(GL_DEPTH_BUFFER_BIT); glDisable(GL_DEPTH_TEST); glDepthMask(GL_FALSE); renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); glEnable(GL_BLEND); menuManager.uiManager.draw(renderer); if (currentLocation) { currentLocation->dialogueSystem.draw(renderer); } glDisable(GL_BLEND); renderer.shaderManager.PopShader(); glDepthMask(GL_TRUE); glEnable(GL_DEPTH_TEST); CheckGlError(__FILE__, __LINE__); } void Game::drawScene() { glViewport(0, 0, Environment::width, Environment::height); if (!loadingCompleted) { drawLoading(); } else { if (currentLocation) { if (currentLocation->shadowMap) { CheckGlError(__FILE__, __LINE__); currentLocation->drawShadowDepthPass(); CheckGlError(__FILE__, __LINE__); currentLocation->drawGameWithShadows(); CheckGlError(__FILE__, __LINE__); } else { currentLocation->drawGame(); CheckGlError(__FILE__, __LINE__); } } else { // ??? Main menu??? } drawUI(); } CheckGlError(__FILE__, __LINE__); } void Game::drawLoading() { glClear(GL_DEPTH_BUFFER_BIT); renderer.shaderManager.PushShader(defaultShaderName); renderer.RenderUniform1i(textureUniformName, 0); float width = Environment::projectionWidth; float height = Environment::projectionHeight; renderer.PushProjectionMatrix( -width * 0.5f, width*0.5f, -height * 0.5f, height * 0.5f, -10, 10); renderer.PushMatrix(); renderer.LoadIdentity(); glBindTexture(GL_TEXTURE_2D, loadingTexture->getTexID()); renderer.DrawVertexRenderStruct(loadingMesh); renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); CheckGlError(__FILE__, __LINE__); } int64_t Game::getSyncTimeMs() { int64_t localNow = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); return localNow; } void Game::processTickCount() { if (lastTickCount == 0) { lastTickCount = getSyncTimeMs(); lastTickCount = (lastTickCount / 50) * 50; return; } newTickCount = getSyncTimeMs(); newTickCount = (newTickCount / 50) * 50; if (newTickCount - lastTickCount > CONST_TIMER_INTERVAL) { int64_t delta = newTickCount - lastTickCount; lastTickCount = newTickCount; if (currentLocation) { currentLocation->update(delta); } } } void Game::render() { ZL::CheckGlError(__FILE__, __LINE__); glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); drawScene(); processTickCount(); SDL_GL_SwapWindow(ZL::Environment::window); } void Game::update() { SDL_Event event; while (SDL_PollEvent(&event)) { if (event.type == SDL_QUIT) { Environment::exitGameLoop = true; } if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_RESIZED) { // Обновляем размеры и сбрасываем кеш текстов, т.к. меши хранятся в пикселях Environment::width = event.window.data1; Environment::height = event.window.data2; Environment::computeProjectionDimensions(); //menuManager.uiManager.updateAllLayouts(); std::cout << "Window resized: " << Environment::width << "x" << Environment::height << std::endl; //space.clearTextRendererCache(); } #ifdef __ANDROID__ if (event.type == SDL_KEYDOWN && event.key.keysym.sym == SDLK_AC_BACK) { Environment::exitGameLoop = true; } #endif // Touch events (real fingers on Android and on mobile browsers via Emscripten). // SDL_HINT_TOUCH_MOUSE_EVENTS="0" is set in main.cpp so these do not // also fire SDL_MOUSEBUTTONDOWN, preventing double-processing. if (event.type == SDL_FINGERDOWN || event.type == SDL_FINGERUP || event.type == SDL_FINGERMOTION) { int eventX = static_cast(event.tfinger.x * Environment::width); int eventY = static_cast(event.tfinger.y * Environment::height); int mx = static_cast(event.tfinger.x * Environment::projectionWidth); int my = static_cast(event.tfinger.y * Environment::projectionHeight); int64_t fingerId = static_cast(event.tfinger.fingerId); if (event.type == SDL_FINGERDOWN) { onPointerDown(fingerId, eventX, eventY, mx, my); } else if (event.type == SDL_FINGERUP) { onPointerUp(fingerId, eventX, eventY, mx, my); } else { onPointerMotion(fingerId, eventX, eventY, mx, my); } continue; } // Mouse-left maps to a single virtual touch with MOUSE_FINGER_ID. // Right mouse is intentionally unused now — camera rotation is on hold-and-drag. if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { if (event.button.button == SDL_BUTTON_LEFT) { int eventX = event.button.x; int eventY = event.button.y; int mx = static_cast((float)eventX / Environment::width * Environment::projectionWidth); int my = static_cast((float)eventY / Environment::height * Environment::projectionHeight); if (event.type == SDL_MOUSEBUTTONDOWN) { onPointerDown(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } else { onPointerUp(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } } } else if (event.type == SDL_MOUSEMOTION) { int eventX = event.motion.x; int eventY = event.motion.y; int mx = static_cast((float)eventX / Environment::width * Environment::projectionWidth); int my = static_cast((float)eventY / Environment::height * Environment::projectionHeight); onPointerMotion(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my); } if (event.type == SDL_MOUSEWHEEL) { static const float zoomstep = 2.0f; if (event.wheel.y > 0) { Environment::zoom -= zoomstep; } else if (event.wheel.y < 0) { Environment::zoom += zoomstep; } if (Environment::zoom < zoomstep) { Environment::zoom = zoomstep; } } if (event.type == SDL_KEYDOWN && event.key.repeat == 0) { switch (event.key.keysym.sym) { case SDLK_1: if (audioPlayer) audioPlayer->playSoundAsync("audio/background.wav"); break; case SDLK_2: if (audioPlayer) audioPlayer->playMusicAsync("audio/lullaby-music-vol20-186394--online-audio-convert.com.ogg"); break; case SDLK_3: if (audioPlayer) audioPlayer->stopMusicAsync(); break; case SDLK_f: currentLocation->dialogueSystem.startDialogue("test_choice_dialogue"); break; case SDLK_e: currentLocation->dialogueSystem.startDialogue("test_cutscene_pan_dialogue"); break; case SDLK_o: //y = y + 0.002; currentLocation->player->hp = 200; break; case SDLK_k: //y = y - 0.002; std::cout << "Player pos: " << currentLocation->player->position.transpose() << std::endl; break; case SDLK_p: currentLocation = (currentLocation == location1) ? location2 : location1; std::cout << "Switched to location " << ((currentLocation == location1) ? "1" : "2") << std::endl; break; case SDLK_l: x = x - 0.002; break; case SDLK_c: std::cout << "SLOW-MO activated!" << std::endl; activateSlowMoEffect(); break; case SDLK_RETURN: default: break; } } // Обработка ввода текста if (event.type == SDL_KEYDOWN) { if (event.key.keysym.sym == SDLK_BACKSPACE) { //menuManager.uiManager.onKeyBackspace(); } } if (event.type == SDL_TEXTINPUT) { // Пропускаем ctrl+c и другие команды if ((event.text.text[0] & 0x80) == 0) { // ASCII символы //menuManager.uiManager.onKeyPress((unsigned char)event.text.text[0]); } } if (event.type == SDL_KEYUP) { if (event.key.keysym.sym == SDLK_r) { std::cout << "Camera position: x=" << x << " y=" << y << " z=" << z << std::endl; } } if (event.type == SDL_KEYUP) { } } render(); mainThreadHandler.processMainThreadTasks(); } int Game::countNonUiPointers() const { int n = 0; for (const auto& kv : activePointers) { if (!kv.second.capturedByUi) ++n; } return n; } void Game::activateSlowMoEffect() { if (!currentLocation) return; if (currentLocation->player) { currentLocation->player->slowMoTimeRemaining = currentLocation->player->slowMoActiveTime; } for (auto& npc : currentLocation->npcs) { if (npc) { npc->slowMoTimeRemaining = npc->slowMoActiveTime; } } } void Game::enterCameraDragMode(int eventX, int eventY) { cameraDragging = true; if (currentLocation) { currentLocation->cameraDragging = true; // Anchor on the *current* position, not the original press, so the // camera doesn't snap by however far the finger drifted before the // movement threshold was crossed. currentLocation->lastMouseX = eventX; currentLocation->lastMouseY = eventY; } } void Game::exitCameraDragMode() { cameraDragging = false; if (currentLocation) { currentLocation->cameraDragging = false; } } void Game::startPinch() { // Pick the first two non-UI pointers as pinch anchors. bool foundA = false, foundB = false; for (auto& kv : activePointers) { if (kv.second.capturedByUi) continue; if (!foundA) { pinchFingerA = kv.first; foundA = true; } else if (!foundB) { pinchFingerB = kv.first; foundB = true; break; } } if (!foundA || !foundB) return; const PointerState& a = activePointers[pinchFingerA]; const PointerState& b = activePointers[pinchFingerB]; float dx = static_cast(a.eventX - b.eventX); float dy = static_cast(a.eventY - b.eventY); pinchStartDistance = std::sqrt(dx * dx + dy * dy); pinchStartZoom = Environment::zoom; pinchActive = true; } void Game::updatePinchZoom() { auto itA = activePointers.find(pinchFingerA); auto itB = activePointers.find(pinchFingerB); if (itA == activePointers.end() || itB == activePointers.end()) return; if (pinchStartDistance <= 1.0f) return; float dx = static_cast(itA->second.eventX - itB->second.eventX); float dy = static_cast(itA->second.eventY - itB->second.eventY); float dist = std::sqrt(dx * dx + dy * dy); if (dist <= 1.0f) return; float newZoom = pinchStartZoom * (pinchStartDistance / dist); // Match the wheel-zoom lower bound so both gestures clamp the same way. static const float zoomMin = 2.0f; if (newZoom < zoomMin) newZoom = zoomMin; Environment::zoom = newZoom; } void Game::endPinch() { pinchActive = false; pinchStartDistance = 0.0f; pinchStartZoom = 0.0f; } void Game::onPointerDown(int64_t fingerId, int eventX, int eventY, int mx, int my) { PointerState st; st.eventX = st.downEventX = eventX; st.eventY = st.downEventY = eventY; st.mx = st.downMx = mx; st.my = st.downMy = my; const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.uiManager.onTouchDown(fingerId, uiX, uiY); st.capturedByUi = menuManager.uiManager.isUiInteractionForFinger(fingerId); activePointers[fingerId] = st; if (st.capturedByUi) { // UI button / slider / text-field grabbed this press; don't drive // gameplay or pinch from it. return; } if (countNonUiPointers() >= 2) { // Second finger landed on the gameplay area: switch to pinch-zoom // and abandon any in-flight tap/camera-drag. if (cameraDragging) { exitCameraDragMode(); } hasPrimaryPointer = false; if (!pinchActive) { startPinch(); } } else { hasPrimaryPointer = true; primaryPointerId = fingerId; } } void Game::onPointerUp(int64_t fingerId, int eventX, int eventY, int mx, int my) { const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.uiManager.onTouchUp(fingerId, uiX, uiY); auto it = activePointers.find(fingerId); if (it == activePointers.end()) return; PointerState st = it->second; activePointers.erase(it); // Pinch ends as soon as either anchor is lifted. The remaining finger, // if any, is *not* promoted back to a primary tap — the user was mid- // gesture, not starting a fresh press. if (pinchActive && (fingerId == pinchFingerA || fingerId == pinchFingerB)) { endPinch(); return; } if (!hasPrimaryPointer || fingerId != primaryPointerId) { return; } const bool wasDragging = cameraDragging; if (cameraDragging) { exitCameraDragMode(); } hasPrimaryPointer = false; if (st.capturedByUi || wasDragging) { // UI handled the press, or it was a camera-rotation drag — no tap action. return; } // Tap → walk-to / interact, using the original press coords so the target // isn't shifted by tiny finger drift before release. if (currentLocation) { currentLocation->handleDown(fingerId, st.downEventX, st.downEventY, st.downMx, st.downMy); } } void Game::onPointerMotion(int64_t fingerId, int eventX, int eventY, int mx, int my) { const int uiX = mx; const int uiY = Environment::projectionHeight - my; menuManager.uiManager.onTouchMove(fingerId, uiX, uiY); auto it = activePointers.find(fingerId); if (it != activePointers.end()) { it->second.eventX = eventX; it->second.eventY = eventY; it->second.mx = mx; it->second.my = my; } if (pinchActive) { updatePinchZoom(); return; } // Only the primary, non-UI-captured pointer can promote itself into a // camera-rotation drag once it crosses the movement threshold. if (hasPrimaryPointer && fingerId == primaryPointerId && it != activePointers.end() && !it->second.capturedByUi && !cameraDragging) { int dx = mx - it->second.downMx; int dy = my - it->second.downMy; if (dx * dx + dy * dy >= CAMERA_DRAG_PIXEL_THRESHOLD * CAMERA_DRAG_PIXEL_THRESHOLD) { enterCameraDragMode(eventX, eventY); } } if (currentLocation) { // Forwarded for dialogue hover and (when cameraDragging) camera rotation. currentLocation->handleMotion(fingerId, eventX, eventY, mx, my); } } } // namespace ZL