feat(movement): make /follow actually walk toward the target

/follow already existed but was camera-only: it set followTargetGuid_/
followRenderPos_ and told the camera controller to track the target's
render position each frame, but the character itself never moved.

Adds MovementHandler::updateFollowMovement(), called from the same
per-frame block that already refreshes followRenderPos_ in
GameHandler::update(), so no new per-frame wiring is needed. It's the
same straight-line step the headless client's single-shot
/movement/goto uses (updateMovementTask() in
tools/headless_client/main.cpp), but the target position is read
fresh from the live entity every call instead of a stored waypoint (so
it re-chases a moving target, and never needs that command's
long-distance Z-interpolation guard), and it stops at a fixed distance
(1 yard, matching retail's tight follow) instead of snapping onto the
target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Josh Anderson
2026-07-12 08:16:41 -05:00
parent 9fc5d4642b
commit 22df85a0fd
4 changed files with 76 additions and 1 deletions

View File

@@ -37,6 +37,14 @@ public:
// Follow target (moved from GameHandler)
void followTarget();
void cancelFollow();
// Per-frame movement toward the followed target's live position, called
// from GameHandler::update() alongside the existing followRenderPos_
// refresh. A no-op when no follow target is set. Recomputes the target
// position fresh every call (from the live entity, not a stored point),
// so it doesn't need the long-distance Z-interpolation guard the
// headless client's single-shot /movement/goto needs - there's never a
// large stale remaining distance to interpolate across.
void updateFollowMovement(float deltaTime);
// Area trigger detection
void loadAreaTriggerDbc();
@@ -287,6 +295,8 @@ private:
uint32_t knownTaxiMask_[12] = {};
bool taxiMaskInitialized_ = false;
std::unordered_map<uint32_t, uint32_t> taxiCostMap_;
bool followMoveMoving_ = false; // whether MSG_MOVE_START_FORWARD has been sent for the current follow
};
} // namespace game

View File

@@ -813,6 +813,7 @@ void GameHandler::update(float deltaTime) {
if (followEnt) {
followRenderPos_ = core::coords::canonicalToRender(
glm::vec3(followEnt->getX(), followEnt->getY(), followEnt->getZ()));
if (movementHandler_) movementHandler_->updateFollowMovement(deltaTime);
} else {
cancelFollow();
}

View File

@@ -3085,6 +3085,10 @@ void MovementHandler::cancelFollow() {
return;
}
owner_.followTargetGuidRef() = 0;
if (followMoveMoving_) {
sendMovement(Opcode::MSG_MOVE_STOP);
followMoveMoving_ = false;
}
if (owner_.autoFollowCallbackRef()) {
owner_.autoFollowCallbackRef()(nullptr);
}
@@ -3092,5 +3096,65 @@ void MovementHandler::cancelFollow() {
owner_.fireAddonEvent("AUTOFOLLOW_END", {});
}
// Same straight-line step used by the headless client's single-shot
// /movement/goto (updateMovementTask() in tools/headless_client/main.cpp),
// but the target position is read fresh from the live entity every call
// instead of a stored waypoint, and stops at kFollowStopDistance rather
// than snapping onto the target.
void MovementHandler::updateFollowMovement(float deltaTime) {
const uint64_t guid = owner_.followTargetGuidRef();
if (guid == 0) {
return;
}
if (owner_.getState() != WorldState::IN_WORLD) {
return;
}
if (isPlayerRooted() || !isServerMovementAllowed() || isOnTaxiFlight()) {
if (followMoveMoving_) {
sendMovement(Opcode::MSG_MOVE_STOP);
followMoveMoving_ = false;
}
return;
}
auto target = owner_.getEntityManager().getEntity(guid);
if (!target) {
// cancelFollow() (called from GameHandler::update()'s
// followRenderPos_ refresh) handles the disappeared-target case;
// just stay quiet here rather than duplicate that check.
return;
}
constexpr float kFollowStopDistance = 1.0f;
const float dx = target->getX() - movementInfo.x;
const float dy = target->getY() - movementInfo.y;
const float dz = target->getZ() - movementInfo.z;
const float horizontalDist = std::sqrt(dx * dx + dy * dy);
const float distance = std::sqrt(dx * dx + dy * dy + dz * dz);
if (distance <= kFollowStopDistance) {
if (followMoveMoving_) {
sendMovement(Opcode::MSG_MOVE_STOP);
followMoveMoving_ = false;
}
return;
}
if (horizontalDist > 0.001f) {
setOrientation(std::atan2(-dy, dx));
sendMovement(Opcode::MSG_MOVE_SET_FACING);
}
if (!followMoveMoving_) {
sendMovement(Opcode::MSG_MOVE_START_FORWARD);
followMoveMoving_ = true;
}
const float speed = std::max(0.1f, getServerRunSpeed());
const float step = std::min(distance - kFollowStopDistance, speed * std::max(0.0f, deltaTime));
const float t = distance > 0.001f ? (step / distance) : 0.0f;
setPosition(movementInfo.x + dx * t, movementInfo.y + dy * t, movementInfo.z + dz * t);
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
} // namespace game
} // namespace wowee

View File

@@ -189,7 +189,7 @@ public:
return {};
}
std::vector<std::string> aliases() const override { return {"follow", "f"}; }
std::string helpText() const override { return "Follow target"; }
std::string helpText() const override { return "Walk toward and camera-follow your current target"; }
};
// --- /stopfollow ---