minor fixing, adapting for mobile web

This commit is contained in:
Vladislav Khorev 2026-04-25 14:39:27 +03:00
parent 44cc0fba67
commit eac914e073
6 changed files with 393 additions and 129 deletions

View File

@ -205,7 +205,23 @@ void Character::update(int64_t deltaMs) {
cb();
}
targetFacingAngle = facingAngle;
// While standing, optionally orient toward another character (e.g. the
// player when this NPC is being talked to). The smoothed rotation block
// below converges facingAngle to targetFacingAngle.
if (faceTarget) {
Eigen::Vector3f toFace = faceTarget->position - position;
toFace.y() = 0.f;
float d = toFace.norm();
if (d > 1e-3f) {
targetFacingAngle = atan2(toFace.x() / d, -toFace.z() / d);
}
else {
targetFacingAngle = facingAngle;
}
}
else {
targetFacingAngle = facingAngle;
}
}
}
@ -267,6 +283,10 @@ void Character::update(int64_t deltaMs) {
float rotStep = rotationSpeed * static_cast<float>(deltaMs) / 1000.f;
if (std::fabs(angleDiff) <= rotStep) {
facingAngle = targetFacingAngle;
// One-shot face-target: once aligned, stop tracking. Otherwise the NPC
// would keep snapping to the player every tick after the conversation
// started, even as the player moved away.
faceTarget = nullptr;
} else {
facingAngle += (angleDiff > 0.f ? rotStep : -rotStep);
}

View File

@ -112,6 +112,11 @@ public:
float attack_cooldown = 0.f;
bool canAttack = false;
Character* attackTarget = nullptr;
// While standing still, smoothly rotate once to face this character.
// Auto-cleared by update() the moment this character finishes rotating,
// so it acts as a one-shot orient (e.g. an NPC turning to the player at
// the start of a conversation, then holding that pose).
Character* faceTarget = nullptr;
bool isPlayer = false;
bool useGpuSkinning = true;
//bool useGpuSkinning = false;

View File

@ -390,96 +390,51 @@ namespace ZL
}
#endif
#ifdef __ANDROID__
if (event.type == SDL_FINGERDOWN) {
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleDown(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
else if (event.type == SDL_FINGERUP) {
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleUp(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
else if (event.type == SDL_FINGERMOTION) {
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleMotion(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
#else
// Emscripten on mobile browser: handle real touch events with per-finger IDs.
// SDL_HINT_TOUCH_MOUSE_EVENTS="0" is set in main.cpp so these don't
// 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.
#ifdef EMSCRIPTEN
if (event.type == SDL_FINGERDOWN) {
if (event.type == SDL_FINGERDOWN || event.type == SDL_FINGERUP || event.type == SDL_FINGERMOTION) {
int eventX = static_cast<int>(event.tfinger.x * Environment::width);
int eventY = static_cast<int>(event.tfinger.y * Environment::height);
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleDown(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
else if (event.type == SDL_FINGERUP) {
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleUp(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
else if (event.type == SDL_FINGERMOTION) {
int mx = static_cast<int>(event.tfinger.x * Environment::projectionWidth);
int my = static_cast<int>(event.tfinger.y * Environment::projectionHeight);
handleMotion(static_cast<int64_t>(event.tfinger.fingerId), mx, my);
}
#endif
int64_t fingerId = static_cast<int64_t>(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 mx = static_cast<int>((float)event.button.x / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)event.button.y / Environment::height * Environment::projectionHeight);
if (event.type == SDL_MOUSEBUTTONDOWN) {
std::cout << "\n========== MOUSE DOWN EVENT ==========" << std::endl;
handleDown(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
int eventX = event.button.x;
int eventY = event.button.y;
int mx = static_cast<int>((float)eventX / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)eventY / Environment::height * Environment::projectionHeight);
if (menuManager.uiManager.isUiInteractionForFinger(ZL::UiManager::MOUSE_FINGER_ID)) {
std::cout << "[CLICK] UI handled, skipping character movement" << std::endl;
continue;
}
if (currentLocation)
{
currentLocation->handleDown(ZL::UiManager::MOUSE_FINGER_ID, event.button.x, event.button.y, mx, my);
}
///.....
} else {
handleUp(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
}
}
else if (event.button.button == SDL_BUTTON_RIGHT) {
if (event.type == SDL_MOUSEBUTTONDOWN) {
rightMouseDown = true;
lastMouseX = event.button.x;
lastMouseY = event.button.y;
if (currentLocation)
{
currentLocation->rightMouseDown = true;
currentLocation->lastMouseX = event.button.x;
currentLocation->lastMouseY = event.button.y;
}
onPointerDown(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my);
}
else {
rightMouseDown = false;
if (currentLocation)
{
currentLocation->rightMouseDown = false;
}
onPointerUp(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my);
}
}
}
else if (event.type == SDL_MOUSEMOTION) {
int mx = static_cast<int>((float)event.motion.x / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)event.motion.y / Environment::height * Environment::projectionHeight);
handleMotion(ZL::UiManager::MOUSE_FINGER_ID, mx, my);
if (currentLocation)
{
currentLocation->handleMotion(ZL::UiManager::MOUSE_FINGER_ID, event.motion.x, event.motion.y, mx, my);
}
int eventX = event.motion.x;
int eventY = event.motion.y;
int mx = static_cast<int>((float)eventX / Environment::width * Environment::projectionWidth);
int my = static_cast<int>((float)eventY / Environment::height * Environment::projectionHeight);
onPointerMotion(ZL::UiManager::MOUSE_FINGER_ID, eventX, eventY, mx, my);
}
if (event.type == SDL_MOUSEWHEEL) {
@ -560,7 +515,6 @@ namespace ZL
if (event.type == SDL_KEYUP) {
}
#endif
}
render();
@ -568,39 +522,202 @@ namespace ZL
}
void Game::handleDown(int64_t fingerId, int mx, int my)
int Game::countNonUiPointers() const
{
int uiX = mx;
int uiY = Environment::projectionHeight - my;
menuManager.uiManager.onTouchDown(fingerId, uiX, uiY);
int n = 0;
for (const auto& kv : activePointers) {
if (!kv.second.capturedByUi) ++n;
}
return n;
}
void Game::handleUp(int64_t fingerId, int mx, int my)
void Game::enterCameraDragMode(int eventX, int eventY)
{
int uiX = mx;
int uiY = Environment::projectionHeight - my;
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;
}
}
// Check BEFORE onTouchUp erases the finger from the map.
// If this finger started on a UI element, don't notify space —
// otherwise space would think the ship-control finger was released.
bool wasUiInteraction = menuManager.uiManager.isUiInteractionForFinger(fingerId);
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<float>(a.eventX - b.eventX);
float dy = static_cast<float>(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<float>(itA->second.eventX - itB->second.eventX);
float dy = static_cast<float>(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::handleMotion(int64_t fingerId, int mx, int my)
void Game::onPointerMotion(int64_t fingerId, int eventX, int eventY, int mx, int my)
{
int uiX = mx;
int uiY = Environment::projectionHeight - my;
// Check before onTouchMove so the "started on UI" state is preserved
// regardless of what onTouchMove does internally.
bool wasUiInteraction = menuManager.uiManager.isUiInteractionForFinger(fingerId);
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

View File

@ -54,21 +54,49 @@ namespace ZL {
MenuManager menuManager;
private:
bool rightMouseDown = false;
int lastMouseX = 0;
int lastMouseY = 0;
// Unified pointer handling: mouse-left and a single touch share one path.
// A press becomes a tap (interact / walk-to) on release if it never crossed
// CAMERA_DRAG_PIXEL_THRESHOLD; otherwise it becomes a camera-rotation drag.
// Two simultaneous touches enter pinch-zoom instead.
static constexpr int CAMERA_DRAG_PIXEL_THRESHOLD = 12;
struct PointerState {
int eventX = 0, eventY = 0; // current raw window-pixel coords
int mx = 0, my = 0; // current projection-space coords
int downEventX = 0, downEventY = 0;// where the press started (raw px)
int downMx = 0, downMy = 0; // where the press started (proj)
bool capturedByUi = false;
};
std::unordered_map<int64_t, PointerState> activePointers;
bool hasPrimaryPointer = false;
int64_t primaryPointerId = 0;
bool cameraDragging = false;
bool pinchActive = false;
int64_t pinchFingerA = 0;
int64_t pinchFingerB = 0;
float pinchStartDistance = 0.0f;
float pinchStartZoom = 0.0f;
std::unique_ptr<AudioPlayerAsync> audioPlayer;
int64_t getSyncTimeMs();
void processTickCount();
void drawScene();
void drawUI();
void drawLoading();
void handleDown(int64_t fingerId, int mx, int my);
void handleUp(int64_t fingerId, int mx, int my);
void handleMotion(int64_t fingerId, int mx, int my);
void onPointerDown(int64_t fingerId, int eventX, int eventY, int mx, int my);
void onPointerUp(int64_t fingerId, int eventX, int eventY, int mx, int my);
void onPointerMotion(int64_t fingerId, int eventX, int eventY, int mx, int my);
void enterCameraDragMode(int eventX, int eventY);
void exitCameraDragMode();
void startPinch();
void updatePinchZoom();
void endPinch();
int countNonUiPointers() const;
#ifdef EMSCRIPTEN
static Game* s_instance;

View File

@ -19,6 +19,9 @@ namespace ZL
extern const char* CONST_ZIP_FILE;
static constexpr float CAMERA_FOV_Y = 1.0f / 1.5f;
// How close the player needs to be to a peaceful NPC before the
// on_npc_interact callback fires and the conversation begins.
static constexpr float NPC_TALK_DISTANCE = 1.25f;
Location::Location(Renderer& iRenderer, Inventory& iInventory)
: renderer(iRenderer)
@ -293,24 +296,65 @@ namespace ZL
}
Character* Location::raycastNpcs(const Eigen::Vector3f& rayOrigin, const Eigen::Vector3f& rayDir, float maxDistance) {
// Every NPC is treated as a vertical cylinder: radius 1.0m, height 1.85m,
// base at npc->position (the model's foot). Intersection = circle hit in the
// XZ plane, then clip the entry/exit t against the [yFoot, yFoot+height] slab.
static constexpr float NPC_CLICK_RADIUS = 1.0f;
static constexpr float NPC_CLICK_HEIGHT = 1.85f;
Character* closestNpc = nullptr;
float closestDist = maxDistance;
std::cout << "[RAYCAST_NPC] Starting raycast with " << npcs.size() << " npcs" << std::endl;
for (auto& npc : npcs) {
Eigen::Vector3f toNpc = npc->position - rayOrigin;
float distAlongRay = toNpc.dot(rayDir);
if (distAlongRay < 0.1f) continue;
const float dx = rayOrigin.x() - npc->position.x();
const float dz = rayOrigin.z() - npc->position.z();
const float a = rayDir.x() * rayDir.x() + rayDir.z() * rayDir.z();
if (a < 1e-6f) continue; // purely-vertical ray; not a real click case
Eigen::Vector3f closestPoint = rayOrigin + rayDir * distAlongRay;
float distToNpc = (closestPoint - npc->position).norm();
const float b = 2.0f * (dx * rayDir.x() + dz * rayDir.z());
const float c = dx * dx + dz * dz - NPC_CLICK_RADIUS * NPC_CLICK_RADIUS;
const float disc = b * b - 4.0f * a * c;
if (disc < 0.0f) continue; // ray misses the infinite cylinder
float radius = npc->modelScale * 50.0f;
const float sqrtDisc = std::sqrt(disc);
const float tNear = (-b - sqrtDisc) / (2.0f * a);
const float tFar = (-b + sqrtDisc) / (2.0f * a);
if (distToNpc <= radius && distAlongRay < closestDist) {
closestDist = distAlongRay;
float entryT = max(tNear, 0.1f);
float exitT = tFar;
// Clip against the cylinder's vertical extent.
const float yMin = npc->position.y();
const float yMax = yMin + NPC_CLICK_HEIGHT;
if (std::abs(rayDir.y()) > 1e-6f) {
const float tYa = (yMin - rayOrigin.y()) / rayDir.y();
const float tYb = (yMax - rayOrigin.y()) / rayDir.y();
entryT = max(entryT, min(tYa, tYb));
exitT = min(exitT, max(tYa, tYb));
}
else if (rayOrigin.y() < yMin || rayOrigin.y() > yMax) {
continue; // horizontal ray passing above or below the cylinder
}
if (entryT > exitT) continue;
std::cout << "[RAYCAST_NPC] " << npc->npcId << " hit at t=" << entryT << std::endl;
if (entryT < closestDist) {
closestDist = entryT;
closestNpc = npc.get();
}
}
if (closestNpc) {
std::cout << "[RAYCAST_NPC] HIT: " << closestNpc->npcId << std::endl;
}
else {
std::cout << "[RAYCAST_NPC] No NPC hit" << std::endl;
}
return closestNpc;
}
@ -611,6 +655,26 @@ namespace ZL
targetInteractiveObject = nullptr;
}
}
// Check if player reached target NPC for interaction.
if (targetInteractNpc && targetInteractNpcIndex >= 0 && player) {
float distToNpc = (player->position - targetInteractNpc->position).norm();
if (distToNpc <= NPC_TALK_DISTANCE) {
std::cout << "[NPC] Player reached NPC index " << targetInteractNpcIndex
<< " (distance " << distToNpc << "); firing on_npc_interact" << std::endl;
// Stop the player at the talk distance and have the NPC turn to face them.
player->setTarget(player->position);
targetInteractNpc->faceTarget = player.get();
try {
scriptEngine.callNpcInteractCallback(targetInteractNpcIndex);
}
catch (const std::exception& e) {
std::cerr << "[NPC] callback error: " << e.what() << std::endl;
}
targetInteractNpc = nullptr;
targetInteractNpcIndex = -1;
}
}
}
void Location::handleDown(int64_t fingerId, int eventX, int eventY, int mx, int my)
@ -653,6 +717,8 @@ namespace ZL
<< player->position.y() << ", " << player->position.z() << ")" << std::endl;
targetInteractiveObject = clickedObject;
targetInteractNpc = nullptr;
targetInteractNpcIndex = -1;
player->setTarget(clickedObject->position);
player->attackTarget = nullptr;
std::cout << "[CLICK] Player moving to object..." << std::endl;
@ -671,22 +737,40 @@ namespace ZL
}
}
if (npcIndex != -1) {
if (distance <= clickedNpc->interactionRadius) {
std::cout << "[CLICK] *** SUCCESS: Clicked on NPC index: " << npcIndex << " ***" << std::endl;
scriptEngine.callNpcInteractCallback(npcIndex);
targetInteractiveObject = nullptr;
if (clickedNpc->canAttack) {
// Hostile NPC: combat logic walks the player in via attackTarget;
// don't queue a Lua interaction during a fight.
player->attackTarget = clickedNpc;
targetInteractNpc = nullptr;
targetInteractNpcIndex = -1;
if (distance <= clickedNpc->interactionRadius) {
std::cout << "[CLICK] Hostile NPC " << npcIndex << " in range; firing on_npc_interact" << std::endl;
scriptEngine.callNpcInteractCallback(npcIndex);
}
}
else {
std::cout << "[CLICK] Too far from NPC (distance " << distance
<< " > " << clickedNpc->interactionRadius << ")" << std::endl;
}
if (clickedNpc->canAttack)
{
player->attackTarget = clickedNpc;
}
else
{
// Peaceful NPC: walk to them, fire on_npc_interact when within talk distance.
player->attackTarget = nullptr;
if (distance <= NPC_TALK_DISTANCE) {
// Already in talk range — fire immediately, stop, and face the player.
std::cout << "[CLICK] *** SUCCESS: Clicked on NPC index: " << npcIndex << " (in range) ***" << std::endl;
player->setTarget(player->position);
clickedNpc->faceTarget = player.get();
scriptEngine.callNpcInteractCallback(npcIndex);
targetInteractNpc = nullptr;
targetInteractNpcIndex = -1;
}
else {
std::cout << "[CLICK] NPC " << npcIndex << " out of talk range (distance " << distance
<< " > " << NPC_TALK_DISTANCE << "); walking to NPC..." << std::endl;
player->setTarget(clickedNpc->position);
targetInteractNpc = clickedNpc;
targetInteractNpcIndex = npcIndex;
}
}
}
@ -699,6 +783,8 @@ namespace ZL
player->setTarget(Eigen::Vector3f(hit.x(), 0.f, hit.z()));
player->attackTarget = nullptr;
targetInteractNpc = nullptr;
targetInteractNpcIndex = -1;
}
else {
std::cout << "[CLICK] No valid target found" << std::endl;
@ -719,7 +805,7 @@ namespace ZL
);
}
if (rightMouseDown) {
if (cameraDragging) {
int dx = eventX - lastMouseX;
int dy = eventY - lastMouseY;
lastMouseX = eventX;

View File

@ -40,6 +40,12 @@ namespace ZL
Eigen::Matrix4f cameraViewMatrix = Eigen::Matrix4f::Identity();
InteractiveObject* targetInteractiveObject = nullptr;
// "Walk to NPC, then fire on_npc_interact" — mirrors targetInteractiveObject
// so a click from outside interactionRadius still leads to a Lua callback
// once the player gets close enough.
Character* targetInteractNpc = nullptr;
int targetInteractNpcIndex = -1;
std::unique_ptr<TextRenderer> npcNameText;
ScriptEngine scriptEngine;
@ -50,7 +56,9 @@ namespace ZL
void buildDebugNavMeshes();
void drawDebugNavigation();
#endif
bool rightMouseDown = false;
// Set by Game when the user's primary pointer (left mouse / single touch)
// has crossed the tap-vs-drag threshold and is now rotating the camera.
bool cameraDragging = false;
int lastMouseX = 0;
int lastMouseY = 0;