group2 0.1.0
CSE 125 Group 2
Loading...
Searching...
No Matches
GamepadAimAssistSystem.hpp
Go to the documentation of this file.
1
39
40#pragma once
41
42#include "InputSampleSystem.hpp" // for the gamepad::normaliseAxis helper + samplers
53
54#include <SDL3/SDL.h>
55
56#include <algorithm>
57#include <cmath>
58#include <glm/glm.hpp>
59#include <glm/trigonometric.hpp>
60#include <limits>
61
62namespace systems
63{
64
71{
72 bool enabled = true;
73
76 float innerConeDeg = 3.0f;
77
80 float outerConeDeg = 8.0f;
81
83 float maxRange = 3000.0f;
84
88 float activationStickThresh = 0.00f;
89
101
106 float maxPullRate = 3.0f;
107
116 float slowdownStrength = 0.1f;
117};
118
123{
126 entt::entity lastTarget = entt::null;
130 glm::vec3 anchorLocal{0.0f};
133 glm::vec3 lastTargetPos{0.0f};
137 glm::vec3 lastEye{0.0f};
141 bool initialised = false;
142};
143
144namespace aimassist
145{
146
150inline void dirToYawPitch(const glm::vec3& dir, float& outYaw, float& outPitch)
151{
152 outYaw = std::atan2(dir.x, dir.z);
153 outPitch = -std::asin(std::clamp(dir.y, -1.0f, 1.0f));
154}
155
157inline float coneFalloff(float angleRad, float innerRad, float outerRad)
158{
159 if (angleRad <= innerRad)
160 return 1.0f;
161 if (angleRad >= outerRad)
162 return 0.0f;
163 return (outerRad - angleRad) / (outerRad - innerRad);
164}
165
169inline glm::vec3 rayOntoAABB(
170 const glm::vec3& eye, const glm::vec3& dir, const glm::vec3& aabbMin, const glm::vec3& aabbMax, float fallbackDist)
171{
172 // Slab method. Reject divisions by ~0 by treating tiny `dir[i]` as
173 // "ray parallel to axis"; if origin is outside the slab on that axis,
174 // the ray can never hit (we'll fall through to the fallback below).
175 float tEnter = -std::numeric_limits<float>::infinity();
176 float tExit = std::numeric_limits<float>::infinity();
177 bool hits = true;
178 for (int i = 0; i < 3; ++i) {
179 if (std::fabs(dir[i]) < 1e-6f) {
180 if (eye[i] < aabbMin[i] || eye[i] > aabbMax[i]) {
181 hits = false;
182 break;
183 }
184 } else {
185 float t1 = (aabbMin[i] - eye[i]) / dir[i];
186 float t2 = (aabbMax[i] - eye[i]) / dir[i];
187 if (t1 > t2)
188 std::swap(t1, t2);
189 tEnter = std::max(tEnter, t1);
190 tExit = std::min(tExit, t2);
191 if (tEnter > tExit) {
192 hits = false;
193 break;
194 }
195 }
196 }
197 if (hits && tEnter >= 0.0f) {
198 return eye + dir * tEnter;
199 }
200 // Fallback: clamp the ray-at-target-distance point onto the AABB. Always
201 // produces a sensible "closest face/edge/corner" anchor — used when the
202 // player is aiming just past the silhouette of the target.
203 const glm::vec3 raySample = eye + dir * fallbackDist;
204 return glm::clamp(raySample, aabbMin, aabbMax);
205}
206
207} // namespace aimassist
208
219inline void runGamepadAimAssist(Registry& registry,
220 SDL_Gamepad* gamepad,
221 const GamepadAimAssistConfig& cfg,
223 float lookSens,
224 float dt)
225{
226 if (!gamepad || !cfg.enabled) {
227 // Drop our memory of any previous target so the next acquisition
228 // re-initialises cleanly (otherwise a stale anchor could fire one
229 // bogus pull when assist is re-enabled).
230 state.lastTarget = entt::null;
231 state.initialised = false;
232 return;
233 }
234
235 // ── Activation gate ───────────────────────────────────────────────────
236 const float lx = gamepad::normaliseAxis(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX));
237 const float ly = gamepad::normaliseAxis(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY));
238 const float rx = gamepad::normaliseAxis(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX));
239 const float ry = gamepad::normaliseAxis(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY));
240
241 const float moveStickMag = std::sqrt(lx * lx + ly * ly);
242 const float lookStickMag = std::sqrt(rx * rx + ry * ry);
243
244 // When activationStickThresh <= 0, aim assist is always active (useful
245 // for testing rotational assist on a single PC without a second player).
246 const bool alwaysActive = cfg.activationStickThresh <= 0.0f;
247 if (!alwaysActive && moveStickMag < cfg.activationStickThresh && lookStickMag < cfg.activationStickThresh) {
248 // Player held still — keep the state so the anchor doesn't reset
249 // when they nudge again, but skip pull/slowdown this frame.
250 return;
251 }
252
253 // ── Local player aim state ────────────────────────────────────────────
254 entt::entity localEntity = entt::null;
255 glm::vec3 eye{0.0f};
256 float curYaw = 0.0f, curPitch = 0.0f;
257 bool foundLocal = false;
259 [&](entt::entity e,
260 const Position& pos,
261 const CollisionShape& shape,
262 const InputSnapshot& snap,
263 const PlayerVisState& pvis) {
264 localEntity = e;
265 const float eyeOffset = shape.halfExtents.y * 0.77f;
266 const float aaEyeDir = pvis.gravityFlipped ? -1.0f : 1.0f;
267 eye = pos.value + glm::vec3{0.0f, eyeOffset * aaEyeDir, 0.0f};
268 curYaw = snap.yaw;
269 curPitch = snap.pitch;
270 foundLocal = true;
271 });
272 if (!foundLocal)
273 return;
274
275 // Current aim direction, in the renderer's convention.
276 const float cosPitchInit = std::cos(curPitch);
277 const glm::vec3 camFwd{std::sin(curYaw) * cosPitchInit, -std::sin(curPitch), std::cos(curYaw) * cosPitchInit};
278
279 // ── Target selection ──────────────────────────────────────────────────
280 // Pick the alive remote player whose Position is closest to the crosshair
281 // in angular terms, range-gated and LOS-clear (no walls in the way).
282 // We use Position (AABB centre) for selection; the *anchor on the AABB*
283 // is computed below. Selecting on AABB centre is stable — selecting on
284 // the anchor itself would create feedback when the anchor moves.
285 entt::entity bestTarget = entt::null;
286 glm::vec3 bestTargetPos{0.0f};
287 glm::vec3 bestHalfExtents{0.0f};
288 float bestAngle = glm::radians(cfg.outerConeDeg);
289 float bestDist = cfg.maxRange;
290
291 const auto& world = physics::activeWorld();
292
293 registry.view<Position, CollisionShape, PlayerVisState>(entt::exclude<LocalPlayer>)
294 .each([&](entt::entity e, const Position& pos, const CollisionShape& shape, const PlayerVisState& pvis) {
295 if (e == localEntity)
296 return;
297 if (pvis.isDead)
298 return;
299 if (registry.all_of<RespawnTimer>(e))
300 return;
301
302 const glm::vec3 toCentre = pos.value - eye;
303 const float dist = glm::length(toCentre);
304 if (dist < 1e-3f || dist > cfg.maxRange)
305 return;
306
307 const glm::vec3 dirToCentre = toCentre / dist;
308 const float dot = glm::clamp(glm::dot(camFwd, dirToCentre), -1.0f, 1.0f);
309 if (dot <= 0.0f)
310 return; // behind camera
311
312 const float angle = std::acos(dot);
313 if (angle >= bestAngle)
314 return;
315
316 // LOS check against world geometry. We don't test player
317 // hitboxes — the target's own capsule would self-occlude.
318 const physics::HitscanHit blocker = physics::raycastWorld(eye, dirToCentre, world);
319 if (blocker.hit && blocker.distance < dist - 1.0f)
320 return;
321
322 bestAngle = angle;
323 bestTarget = e;
324 bestTargetPos = pos.value;
325 bestHalfExtents = shape.halfExtents;
326 bestDist = dist;
327 });
328
329 if (bestTarget == entt::null) {
330 // Lost target — clear state so the next acquisition starts fresh.
331 state.lastTarget = entt::null;
332 state.initialised = false;
333 return;
334 }
335
336 // Cone falloff: 1.0 inside inner cone, 0.0 outside outer cone.
337 const float coneFactor =
338 aimassist::coneFalloff(bestAngle, glm::radians(cfg.innerConeDeg), glm::radians(cfg.outerConeDeg));
339 // Distance falloff: 1.0 at point-blank, 0.0 at maxRange.
340 const float distFactor = 1.0f - std::clamp(bestDist / cfg.maxRange, 0.0f, 1.0f);
341 // Combined strength drives slowdown. Rotational pull uses coneFactor
342 // alone so that rotationalCompensation=1.0 gives perfect tracking at
343 // any valid distance.
344 const float strength = coneFactor * distFactor;
345
346 if (coneFactor <= 0.0f) {
347 // Outside the outer cone — nothing to do, but keep state alive so
348 // the anchor persists as the player approaches the target.
349 return;
350 }
351
352 // Target AABB derived from Position (centre) + CollisionShape::halfExtents,
353 // with horizontal half-extents clamped to 18 so the aim-assist silhouette
354 // is closer to the real character width rather than the full collision box.
355 const glm::vec3 aaHalfExtents{
356 std::min(bestHalfExtents.x, 18.0f), bestHalfExtents.y, std::min(bestHalfExtents.z, 18.0f)};
357 const glm::vec3 aabbMin = bestTargetPos - aaHalfExtents;
358 const glm::vec3 aabbMax = bestTargetPos + aaHalfExtents;
359
360 // Detect target-switch (or first-ever frame): re-initialise state.
361 const bool switchedTarget = (state.lastTarget != bestTarget);
362 if (switchedTarget || !state.initialised) {
363 const glm::vec3 hitWorld = aimassist::rayOntoAABB(eye, camFwd, aabbMin, aabbMax, bestDist);
364 state.anchorLocal = hitWorld - bestTargetPos;
365 state.lastTargetPos = bestTargetPos;
366 state.lastEye = eye;
367 state.lastTarget = bestTarget;
368 state.initialised = true;
369 // No previous frame yet → no pull this frame. Slowdown still
370 // applies (it depends only on current strength, not on history).
371 }
372
373 // ── Compute direction to target center for asymmetric slowdown ──────
374 // Offset from crosshair to target centre in (yaw, pitch) space.
375 const glm::vec3 dirToTarget = glm::normalize(bestTargetPos - eye);
376 float targetYaw = 0.0f, targetPitch = 0.0f;
377 aimassist::dirToYawPitch(dirToTarget, targetYaw, targetPitch);
378 const float dYawToTarget = std::remainder(targetYaw - curYaw, glm::radians(360.0f));
379 const float dPitchToTarget = targetPitch - curPitch;
380
381 registry.view<InputSnapshot, LocalPlayer, Controllable>().each([&](InputSnapshot& snap) {
382 // ── 1. Asymmetric slowdown — refund part of look input, but MORE
383 // when moving away from target centre and LESS when moving
384 // toward it. Proximity to centre amplifies the effect (curve).
385 if (alwaysActive || lookStickMag >= cfg.activationStickThresh) {
386 // Stick direction in (yaw, pitch) camera-motion space.
387 // runGamepadLook does: yaw -= rx*..., pitch += ry*...
388 // So stick-induced camera motion direction is (-rx, +ry).
389 const float stickYaw = -rx;
390 const float stickPitch = ry;
391
392 const float stickLen = std::sqrt(stickYaw * stickYaw + stickPitch * stickPitch);
393 const float targetOffsetLen = std::sqrt(dYawToTarget * dYawToTarget + dPitchToTarget * dPitchToTarget);
394
395 float dirDot = 0.0f;
396 if (stickLen > 1e-4f && targetOffsetLen > 1e-4f) {
397 // Normalised dot product: +1 = moving directly toward target
398 // centre, -1 = directly away.
399 dirDot = (stickYaw * dYawToTarget + stickPitch * dPitchToTarget) / (stickLen * targetOffsetLen);
400 dirDot = std::clamp(dirDot, -1.0f, 1.0f);
401 }
402
403 // Proximity factor: how close the crosshair is to target centre.
404 // 1.0 = dead centre, 0.0 = at outer cone edge. Squared for a
405 // curve that ramps up quickly near centre.
406 const float outerRad = glm::radians(cfg.outerConeDeg);
407 const float proximity = 1.0f - std::clamp(bestAngle / outerRad, 0.0f, 1.0f);
408 const float proxCurve = proximity * proximity;
409
410 // Asymmetric strength:
411 // dirDot > 0 (toward centre): raise effective slowdown toward 1.0
412 // (less friction — let player get on target easily)
413 // dirDot < 0 (away from centre): lower effective slowdown toward 0.0
414 // (more friction — resist leaving target)
415 // Scaled by proximity curve so the effect is strongest near centre.
416 float effectiveSlowdown = cfg.slowdownStrength;
417 if (dirDot > 0.0f) {
418 // Moving toward: lerp slowdown up toward 1.0 (less sticky)
419 effectiveSlowdown = cfg.slowdownStrength + (1.0f - cfg.slowdownStrength) * dirDot * proxCurve;
420 } else {
421 // Moving away: lerp slowdown down toward 0.0 (more sticky)
422 effectiveSlowdown = cfg.slowdownStrength * (1.0f + dirDot * proxCurve);
423 }
424 effectiveSlowdown = std::clamp(effectiveSlowdown, 0.0f, 1.0f);
425
426 const float refund = (1.0f - effectiveSlowdown) * strength;
427 snap.yaw += rx * lookSens * dt * refund;
428 snap.pitch -= ry * lookSens * dt * refund;
429 }
430
431 // ── 2. Movement-tracking rotational pull.
432 // Skipped on the acquisition frame (no previous-frame anchor).
433 // Uses coneFactor only (NOT distFactor) — rotationalCompensation
434 // of 1.0 means perfect tracking regardless of distance, as long
435 // as the target is within the cone and range.
436 if (state.initialised && !switchedTarget) {
437 // Use the SAME anchor_local for both endpoints — that isolates
438 // the contribution of (target movement) + (player translation),
439 // excluding the contribution of the player's own *aim* drag.
440 // Drag is supposed to redirect the anchor on the body, not
441 // generate a phantom pull, so it's attributed only to the
442 // anchor-update step at the end of this function.
443 const glm::vec3 prevAnchorWorld = state.lastTargetPos + state.anchorLocal;
444 const glm::vec3 curAnchorWorld = bestTargetPos + state.anchorLocal;
445
446 const glm::vec3 dirPrev = glm::normalize(prevAnchorWorld - state.lastEye);
447 const glm::vec3 dirCur = glm::normalize(curAnchorWorld - eye);
448
449 float yawPrev = 0.0f, pitchPrev = 0.0f, yawCur = 0.0f, pitchCur = 0.0f;
450 aimassist::dirToYawPitch(dirPrev, yawPrev, pitchPrev);
451 aimassist::dirToYawPitch(dirCur, yawCur, pitchCur);
452
453 // Wrap yaw delta to [-π, π] so we always rotate the short way.
454 const float dYaw = std::remainder(yawCur - yawPrev, glm::radians(360.0f));
455 const float dPitch = pitchCur - pitchPrev;
456
457 // Compensate `rotationalCompensation` of the apparent motion,
458 // scaled by coneFactor (smooth falloff at cone edge) but NOT by
459 // distFactor — distance already gates target selection, and the
460 // user expects 1.0 to mean "perfect tracking" at any valid range.
461 // Capped by `maxPullRate` so a teleport spike can't fling the
462 // camera off the player's actual aim.
463 const float maxThisFrame = cfg.maxPullRate * dt;
464 const float yawPull =
465 std::clamp(cfg.rotationalCompensation * coneFactor * dYaw, -maxThisFrame, maxThisFrame);
466 const float pitchPull =
467 std::clamp(cfg.rotationalCompensation * coneFactor * dPitch, -maxThisFrame, maxThisFrame);
468 snap.yaw += yawPull;
469 snap.pitch += pitchPull;
470
471 // Re-wrap / clamp to match runGamepadLook's invariants.
472 snap.yaw = std::remainder(snap.yaw, glm::radians(360.0f));
473 snap.pitch = std::clamp(snap.pitch, -glm::radians(89.0f), glm::radians(89.0f));
474 }
475 });
476
477 // ── Update anchor for next frame ──────────────────────────────────────
478 // The new camFwd already includes both player input (runGamepadLook)
479 // AND the pull we just applied, so the anchor naturally tracks where
480 // the player + assist combined ended up looking. Clamped onto the
481 // target AABB so it never drifts off the silhouette.
482 float updatedYaw = curYaw, updatedPitch = curPitch;
483 registry.view<LocalPlayer, InputSnapshot>().each([&](const InputSnapshot& snap) {
484 updatedYaw = snap.yaw;
485 updatedPitch = snap.pitch;
486 });
487 const float cosUpdated = std::cos(updatedPitch);
488 const glm::vec3 camFwdAfter{
489 std::sin(updatedYaw) * cosUpdated, -std::sin(updatedPitch), std::cos(updatedYaw) * cosUpdated};
490 const glm::vec3 newHitWorld = aimassist::rayOntoAABB(eye, camFwdAfter, aabbMin, aabbMax, bestDist);
491 state.anchorLocal = newHitWorld - bestTargetPos;
492 state.lastTargetPos = bestTargetPos;
493 state.lastEye = eye;
494 state.lastTarget = bestTarget;
495 state.initialised = true;
496}
497
498} // namespace systems
AABB collision shape component for physics entities.
Client side Tag component — entity is eligible to receive player input.
Client-only input sampling for mouse look and movement keys.
Per-tick player input snapshot for networking and prediction.
Marker component identifying the locally controlled player entity.
Visible / replicated half of the player locomotion state.
World-space position component for ECS entities.
Shared hitscan raycasting against world geometry and player hitboxes.
Shared ECS registry type alias for the game engine.
entt::registry Registry
Shared ECS registry type alias.
Definition Registry.hpp:11
Component for tracking player respawn times.
Shared world geometry for collision / movement / raycast systems.
const WorldGeometry & activeWorld()
Return the world geometry most recently set via setActiveWorld(), or fall back to testWorld() if none...
Definition WorldData.hpp:236
HitscanHit raycastWorld(glm::vec3 origin, glm::vec3 direction, const WorldGeometry &world)
Raycast against all static world geometry (planes + boxes + cylinders + spheres).
Definition Raycast.hpp:370
Definition GamepadAimAssistSystem.hpp:145
float coneFalloff(float angleRad, float innerRad, float outerRad)
Linear cone falloff: 1.0 inside innerRad, 0.0 outside outerRad.
Definition GamepadAimAssistSystem.hpp:157
glm::vec3 rayOntoAABB(const glm::vec3 &eye, const glm::vec3 &dir, const glm::vec3 &aabbMin, const glm::vec3 &aabbMax, float fallbackDist)
Find the world point where the camera ray hits the target's AABB, or — if it misses — the closest poi...
Definition GamepadAimAssistSystem.hpp:169
void dirToYawPitch(const glm::vec3 &dir, float &outYaw, float &outPitch)
Convert a world-space direction into yaw/pitch matching the renderer convention used by cachedCamFwd_...
Definition GamepadAimAssistSystem.hpp:150
Definition InputSampleSystem.hpp:173
float normaliseAxis(Sint16 raw)
Convert SDL's int16 axis value to a deadzoned float in [-1, 1].
Definition InputSampleSystem.hpp:192
Client-only input sampling system — split into two halves so mouse look can run every iterate() (smoo...
Definition DebugUI.hpp:15
void runGamepadAimAssist(Registry &registry, SDL_Gamepad *gamepad, const GamepadAimAssistConfig &cfg, GamepadAimAssistState &state, float lookSens, float dt)
Apply gamepad aim assist (slowdown + movement-tracking pull) to the local player's InputSnapshot.
Definition GamepadAimAssistSystem.hpp:219
Axis-aligned bounding box defined by half-extents from the entity's Position.
Definition CollisionShape.hpp:16
glm::vec3 halfExtents
AABB half-dimensions (units).
Definition CollisionShape.hpp:17
Client side Tag component — entity is eligible to receive player input.
Definition Controllable.hpp:9
One tick of player input, stamped with the tick it was sampled on.
Definition InputSnapshot.hpp:17
float yaw
Horizontal look angle in radians (accumulated from mouse X deltas).
Definition InputSnapshot.hpp:39
float pitch
Vertical look angle in radians, clamped to [-89°, +89°] by InputSampleSystem.
Definition InputSnapshot.hpp:40
Marker component that tags exactly one entity per client as the locally controlled player.
Definition LocalPlayer.hpp:12
Replicated subset of player locomotion state.
Definition PlayerVisState.hpp:39
bool gravityFlipped
True when the player's gravity is inverted (walking on ceilings).
Definition PlayerVisState.hpp:54
bool isDead
True while the player is dead and waiting to respawn.
Definition PlayerVisState.hpp:46
World-space position of an entity, in game units.
Definition Position.hpp:10
glm::vec3 value
XYZ position (Y-up, Quake units).
Definition Position.hpp:11
ECS component: countdown until a dead player respawns.
Definition RespawnTimer.hpp:10
Result of a hitscan raycast.
Definition Raycast.hpp:34
bool hit
Definition Raycast.hpp:35
float distance
Definition Raycast.hpp:36
Tunable parameters for gamepad aim assist.
Definition GamepadAimAssistSystem.hpp:71
float slowdownStrength
Slowdown factor (0..1).
Definition GamepadAimAssistSystem.hpp:116
float activationStickThresh
Stick activation threshold (0..1 fraction of full deflection).
Definition GamepadAimAssistSystem.hpp:88
float innerConeDeg
Inner cone (degrees from crosshair) where pull strength is at maximum.
Definition GamepadAimAssistSystem.hpp:76
float maxPullRate
Hard cap on rotational pull this frame, in radians/second.
Definition GamepadAimAssistSystem.hpp:106
float rotationalCompensation
Tracking compensation factor.
Definition GamepadAimAssistSystem.hpp:100
float maxRange
Maximum world distance to a target at which aim assist applies.
Definition GamepadAimAssistSystem.hpp:83
bool enabled
Definition GamepadAimAssistSystem.hpp:72
float outerConeDeg
Outer cone (degrees from crosshair) where pull falls to zero.
Definition GamepadAimAssistSystem.hpp:80
Per-frame state required to compute the rotational pull as a delta between frames.
Definition GamepadAimAssistSystem.hpp:123
glm::vec3 anchorLocal
Anchor offset from the target's Position.value in world axes.
Definition GamepadAimAssistSystem.hpp:130
glm::vec3 lastEye
Local player's eye position last frame.
Definition GamepadAimAssistSystem.hpp:137
bool initialised
False until we have one completed frame of history for the current target.
Definition GamepadAimAssistSystem.hpp:141
glm::vec3 lastTargetPos
Target's world position last frame — combined with the (then-) anchorLocal to recover where the ancho...
Definition GamepadAimAssistSystem.hpp:133
entt::entity lastTarget
Target locked-onto last frame (entt::null when none).
Definition GamepadAimAssistSystem.hpp:126