using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif /// /// Gravity-free "insect" surface walker: crawls along any surface (floors, ramps, /// walls, ceilings, around convex/concave edges) regardless of slope. No /// CharacterController, no Rigidbody — the transform is moved kinematically and /// re-stuck to the surface every frame via sphere casts. /// /// Each step: WASD is taken camera-relative (the camera stays world-up) and /// projected onto the current surface tangent plane, the body advances along that /// plane, then a small set of probes re-finds the surface to follow: /// 1. a wall directly ahead (concave corner) becomes the new ground, /// 2. otherwise a down-cast (into the surface) keeps the body stuck on /// flat/ramped/concave ground, /// 3. otherwise a forward-sweeping cast wraps the body over a convex edge. /// The body's up is aligned to the surface normal; its forward faces the move /// direction. Designed to coexist with on /// the same GameObject, toggled by the GameModeManager (only one is enabled). /// [DisallowMultipleComponent] public class InsectController : MonoBehaviour { [Header("Movement")] [Tooltip("Crawl speed along the surface (units/second).")] public float moveSpeed = 4f; [Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")] public float sprintSpeedMultiplier = 1.8f; [Tooltip("How snappily the body re-aligns its up to the surface normal and turns toward the move direction (1/second; higher = tighter).")] public float alignSmoothing = 16f; [Header("Camera")] [Tooltip("Interpret WASD relative to the camera (projected onto the surface). Off = world X/Z axes projected onto the surface.")] public bool cameraRelative = true; [Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")] public Transform cameraTransform; [Header("Surface Sticking")] [Tooltip("Distance kept between the transform origin and the surface it walks on (tune to the character's pivot; 0 = origin sits on the surface).")] public float hoverHeight = 0f; [Tooltip("Radius of the probe spheres. Roughly the body's half-width; keeps the walker off sharp edges.")] public float probeRadius = 0.3f; [Tooltip("How far the body can step up onto a rise or drop down a dip while staying stuck.")] public float stepHeight = 0.4f; [Tooltip("Surfaces the walker can stick to.")] public LayerMask groundMask = ~0; [Header("Edge Wrapping")] [Tooltip("A surface ahead steeper than this (degrees from the current up) is treated as a wall to climb into (concave corner).")] [Range(5f, 90f)] public float wallWrapAngle = 35f; [Tooltip("How far ahead to look for a wall to climb into.")] public float wallProbeDistance = 0.6f; [Tooltip("Largest angle the body wraps around a convex edge in one step (degrees). Higher = follows sharper outside corners.")] [Range(30f, 170f)] public float maxWrapAngle = 150f; [Header("Animation (optional)")] public Animator animator; [Tooltip("Animator bool set true while crawling.")] public string runningParameter = "Running"; /// When false, input is ignored (the body holds its surface pose). public bool ControlEnabled { get; set; } = true; /// True after the last step found a surface to stick to. public bool OnSurface => _onSurface; /// Surface normal the body is currently stuck to (its up). public Vector3 SurfaceNormal => _surfaceNormal; private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2 private const float Eps = 1e-5f; private int _runningHash; private bool _onSurface; private Vector3 _surfaceNormal = Vector3.up; private Vector3 _lastMove; private readonly RaycastHit[] _hits = new RaycastHit[16]; void Awake() { _runningHash = Animator.StringToHash(runningParameter); if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; if (groundMask.value == 0) groundMask = ~0; _surfaceNormal = transform.up; } void Update() { float h = 0f, v = 0f; bool sprinting = false; if (ControlEnabled) { #if ENABLE_INPUT_SYSTEM var keyboard = Keyboard.current; if (keyboard != null) { if (keyboard.aKey.isPressed) h -= 1f; if (keyboard.dKey.isPressed) h += 1f; if (keyboard.sKey.isPressed) v -= 1f; if (keyboard.wKey.isPressed) v += 1f; sprinting = keyboard.leftShiftKey.isPressed; } #else h = Input.GetAxisRaw("Horizontal"); v = Input.GetAxisRaw("Vertical"); sprinting = Input.GetKey(KeyCode.LeftShift); #endif } Step(h, v, sprinting, Time.deltaTime); } /// /// Advances the body one frame: projects the (h,v) input onto the surface, /// moves along it, re-sticks to the surface and re-orients. Public so it can be /// driven deterministically in tests (set false to /// map input to world axes without a camera). /// public void Step(float h, float v, bool sprinting, float dt) { Vector3 up = transform.up; Vector3 wish = InputToWorld(up, h, v); float inputMag = Mathf.Clamp01(new Vector2(h, v).magnitude); float speed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed; Vector3 move = wish * (speed * inputMag * dt); Vector3 tentative = transform.position + move; _lastMove = move; if (ProbeSurface(tentative, up, wish, out Vector3 point, out Vector3 normal) && normal.sqrMagnitude > Eps) { normal.Normalize(); transform.position = point + normal * hoverHeight; _surfaceNormal = normal; _onSurface = true; OrientTo(normal, wish, dt); } else { // No surface within reach (e.g. ran off the end of geometry): keep // crawling along the last tangent instead of falling. transform.position = tentative; _onSurface = false; } if (animator) animator.SetBool(_runningHash, _onSurface && inputMag > 0f && _lastMove.sqrMagnitude / Mathf.Max(dt * dt, 1e-8f) > RunningSpeedThresholdSqr); } /// /// Immediately probes straight into the current surface and places the body on /// it (no smoothing). Used when entering insect mode so the first frame is stuck. /// public void SnapToSurface() { Vector3 up = transform.up; if (ProbeSurface(transform.position, up, Vector3.zero, out Vector3 point, out Vector3 normal) && normal.sqrMagnitude > Eps) { normal.Normalize(); transform.position = point + normal * hoverHeight; _surfaceNormal = normal; _onSurface = true; Vector3 fwd = Vector3.ProjectOnPlane(transform.forward, normal); if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.right, normal); if (fwd.sqrMagnitude > Eps) transform.rotation = Quaternion.LookRotation(fwd.normalized, normal); } } /// /// Maps WASD to a world-space direction in the surface tangent plane. The camera /// stays world-up, so its axes are projected onto the plane; if an axis is nearly /// parallel to the surface normal (on a wall/ceiling) it is rebuilt from the other /// axis so the mapping never collapses. /// private Vector3 InputToWorld(Vector3 up, float h, float v) { Vector3 right, fwd; if (cameraRelative && cameraTransform) { right = Vector3.ProjectOnPlane(cameraTransform.right, up); fwd = Vector3.ProjectOnPlane(cameraTransform.forward, up); } else { right = Vector3.ProjectOnPlane(Vector3.right, up); fwd = Vector3.ProjectOnPlane(Vector3.forward, up); } if (fwd.sqrMagnitude < Eps) fwd = Vector3.Cross(right, up); if (right.sqrMagnitude < Eps) right = Vector3.Cross(up, fwd); if (fwd.sqrMagnitude < Eps || right.sqrMagnitude < Eps) return Vector3.zero; Vector3 dir = right.normalized * h + fwd.normalized * v; return dir.sqrMagnitude > 1f ? dir.normalized : dir; } /// /// Finds the surface the body should stick to at , trying in /// order: a wall ahead (concave corner), the ground below (flat/ramp/concave), a /// convex edge wrap. Returns the surface contact point and normal. /// private bool ProbeSurface(Vector3 pos, Vector3 up, Vector3 moveDir, out Vector3 point, out Vector3 normal) { bool moving = moveDir.sqrMagnitude > Eps; // Lift the sideways/edge probes clear of the surface we are already on, so the // probe sphere never starts penetrating it (which would self-hit or, worse, make // SphereCast report a bogus point at the world origin on an initial overlap). Vector3 probeOrigin = pos + up * Mathf.Max(hoverHeight, probeRadius + 0.05f); // 1) Concave corner: a wall right in front becomes the new ground to climb into. if (moving) { Vector3 d = moveDir.normalized; if (CastBest(probeOrigin, d, probeRadius + wallProbeDistance, out Vector3 wp, out Vector3 wn) && Vector3.Angle(wn, up) > wallWrapAngle) { point = wp; normal = wn; return true; } } // 2) Ground follow: cast from above the body straight into the surface. float startUp = hoverHeight + stepHeight; float downDist = startUp + hoverHeight + stepHeight + probeRadius; if (CastBest(pos + up * startUp, -up, downDist, out Vector3 gp, out Vector3 gn)) { point = gp; normal = gn; return true; } // 3) Convex edge: the surface curved away ahead. Sweep the down-probe forward, // pitching it over the edge until it catches the wrapped surface. if (moving) { Vector3 axis = Vector3.Cross(moveDir.normalized, up); if (axis.sqrMagnitude > Eps) { axis.Normalize(); float reach = hoverHeight + stepHeight + probeRadius + 0.5f; for (float ang = 20f; ang <= maxWrapAngle; ang += 20f) { Vector3 d = Quaternion.AngleAxis(ang, axis) * (-up); if (CastBest(probeOrigin, d, reach, out Vector3 ep, out Vector3 en)) { point = ep; normal = en; return true; } } } } point = pos; normal = up; return false; } /// /// Sphere-casts and returns the nearest hit that is not our own collider and has a /// usable (non-zero) normal. False if nothing valid was hit. /// private bool CastBest(Vector3 origin, Vector3 dir, float dist, out Vector3 point, out Vector3 normal) { point = origin; normal = Vector3.zero; if (dir.sqrMagnitude < Eps) return false; int n = Physics.SphereCastNonAlloc( origin, probeRadius, dir.normalized, _hits, dist, groundMask, QueryTriggerInteraction.Ignore); float best = float.MaxValue; bool found = false; for (int i = 0; i < n; i++) { RaycastHit hit = _hits[i]; if (!hit.collider || hit.collider.transform.IsChildOf(transform)) continue; // A zero distance means the sphere started inside the collider; Unity then // reports point=(0,0,0) and normal=-dir, which would teleport us — discard it. if (hit.distance <= 1e-4f || hit.normal.sqrMagnitude < Eps) continue; if (hit.distance < best) { best = hit.distance; point = hit.point; normal = hit.normal; found = true; } } return found; } /// Eases the body's up toward the surface normal and its forward toward the move direction. private void OrientTo(Vector3 normal, Vector3 wish, float dt) { Vector3 fwd = Vector3.ProjectOnPlane(wish, normal); // When the move/forward is parallel to the new normal (walking straight into a // wall), fall back to the old up projected onto the surface: that points "up the // wall", so the body keeps climbing instead of snapping a 90-degree yaw. if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.forward, normal); if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.up, normal); if (fwd.sqrMagnitude < Eps) fwd = Vector3.ProjectOnPlane(transform.right, normal); if (fwd.sqrMagnitude < Eps) return; Quaternion target = Quaternion.LookRotation(fwd.normalized, normal); float t = alignSmoothing > 0f ? 1f - Mathf.Exp(-alignSmoothing * dt) : 1f; transform.rotation = Quaternion.Slerp(transform.rotation, target, t); } void OnDrawGizmosSelected() { Gizmos.color = Color.cyan; Vector3 c = transform.position + transform.up * hoverHeight; Gizmos.DrawWireSphere(c, probeRadius); Gizmos.color = Color.green; Gizmos.DrawLine(c, c + _surfaceNormal * 0.5f); } }