Add insect surface-walker controller and mode

Add InsectController (transform-driven SphereCast surface following, no
CharacterController/Rigidbody, no gravity or slope limit) and InsectMode as a
third game mode. Wire it into GameModeManager with conditional registration so
PlayerControl and Insect modes only join the Tab cycle when their controller is
present. PaintMode/PlayerControlMode toggle the insect controller accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-24 07:27:17 +02:00
1 parent dbf5104611
commit cd40f42386
7 files changed
+405 -1

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// 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 <see cref="PlayerCharacterController"/> on
/// the same GameObject, toggled by the GameModeManager (only one is enabled).
/// </summary>
[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";
/// <summary>When false, input is ignored (the body holds its surface pose).</summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True after the last step found a surface to stick to.</summary>
public bool OnSurface => _onSurface;
/// <summary>Surface normal the body is currently stuck to (its up).</summary>
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);
}
/// <summary>
/// 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 <see cref="cameraRelative"/> false to
/// map input to world axes without a camera).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Finds the surface the body should stick to at <paramref name="pos"/>, 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Eases the body's up toward the surface normal and its forward toward the move direction.</summary>
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);
}
}