From cd40f423863dbe5b1a2f9bd6a206c881ce1f4659 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 24 Jun 2026 07:27:02 +0200 Subject: [PATCH] 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 --- Assets/Scripts/GameModeManager.cs | 17 +- Assets/Scripts/InsectController.cs | 335 ++++++++++++++++++++++++ Assets/Scripts/InsectController.cs.meta | 2 + Assets/Scripts/InsectMode.cs | 38 +++ Assets/Scripts/InsectMode.cs.meta | 2 + Assets/Scripts/PaintMode.cs | 6 + Assets/Scripts/PlayerControlMode.cs | 6 + 7 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/InsectController.cs create mode 100644 Assets/Scripts/InsectController.cs.meta create mode 100644 Assets/Scripts/InsectMode.cs create mode 100644 Assets/Scripts/InsectMode.cs.meta diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs index d9c90b7..7fd93a8 100644 --- a/Assets/Scripts/GameModeManager.cs +++ b/Assets/Scripts/GameModeManager.cs @@ -22,6 +22,9 @@ public class GameModeManager : MonoBehaviour [Tooltip("Mesh painter. Empty = found automatically.")] public MeshPainter painter; + [Tooltip("Insect / surface-walk controller. Empty = found automatically.")] + public InsectController insect; + [Header("Switching")] [Tooltip("Key that cycles to the next mode.")] #if ENABLE_INPUT_SYSTEM @@ -36,6 +39,7 @@ public class GameModeManager : MonoBehaviour public PlayerCharacterController Player => player; public ThirdPersonOrbitInput CameraInput => cameraInput; public MeshPainter Painter => painter; + public InsectController Insect => insect; private readonly List _modes = new List(); private int _activeIndex = -1; @@ -49,6 +53,7 @@ public class GameModeManager : MonoBehaviour if (!player) player = FindFirstObjectByType(); if (!cameraInput) cameraInput = FindFirstObjectByType(); if (!painter) painter = FindFirstObjectByType(); + if (!insect) insect = FindFirstObjectByType(); BuildModes(); } @@ -63,9 +68,19 @@ public class GameModeManager : MonoBehaviour private void BuildModes() { _modes.Clear(); - _modes.Add(new PlayerControlMode()); + + // Player Control only joins the Tab cycle when a gravity player is present, so a scene + // running only the insect walker doesn't trap input in a dead, player-freezing mode. + if (player) + _modes.Add(new PlayerControlMode()); + _modes.Add(new PaintMode()); + // Insect mode only joins the Tab cycle when a surface walker is present, so a scene + // without one behaves exactly as before (no dead, player-freezing mode in the cycle). + if (insect) + _modes.Add(new InsectMode()); + foreach (var m in _modes) m.Bind(this); } diff --git a/Assets/Scripts/InsectController.cs b/Assets/Scripts/InsectController.cs new file mode 100644 index 0000000..8f54641 --- /dev/null +++ b/Assets/Scripts/InsectController.cs @@ -0,0 +1,335 @@ +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); + } +} diff --git a/Assets/Scripts/InsectController.cs.meta b/Assets/Scripts/InsectController.cs.meta new file mode 100644 index 0000000..baaef13 --- /dev/null +++ b/Assets/Scripts/InsectController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf4039bad99e6e64aae35786e6a87ba6 \ No newline at end of file diff --git a/Assets/Scripts/InsectMode.cs b/Assets/Scripts/InsectMode.cs new file mode 100644 index 0000000..f3804c1 --- /dev/null +++ b/Assets/Scripts/InsectMode.cs @@ -0,0 +1,38 @@ +/// +/// Insect / surface-walk mode: the player crawls along any surface (walls, ceilings, +/// arbitrary slopes) via . The gravity-based +/// is disabled so the two never fight. The +/// camera keeps its world-up orbit; painting is off. +/// +public class InsectMode : GameMode +{ + public override string DisplayName => "Insect"; + + public override void Enter() + { + if (Manager.Player) + Manager.Player.enabled = false; // stop the gravity controller (its Update/gravity) + + if (Manager.Insect) + { + Manager.Insect.enabled = true; + Manager.Insect.ControlEnabled = true; + Manager.Insect.SnapToSurface(); + } + + if (Manager.CameraInput) + Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive; + + if (Manager.Painter) + Manager.Painter.PaintingEnabled = false; + } + + public override void Exit() + { + if (Manager.Insect) + Manager.Insect.enabled = false; + + if (Manager.Player) + Manager.Player.enabled = true; + } +} diff --git a/Assets/Scripts/InsectMode.cs.meta b/Assets/Scripts/InsectMode.cs.meta new file mode 100644 index 0000000..b90176b --- /dev/null +++ b/Assets/Scripts/InsectMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e82ea3f59c643648a33cf33b32dba58 \ No newline at end of file diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs index 77b82c7..9fc2fcc 100644 --- a/Assets/Scripts/PaintMode.cs +++ b/Assets/Scripts/PaintMode.cs @@ -8,8 +8,14 @@ public class PaintMode : GameMode public override void Enter() { + if (Manager.Insect) + Manager.Insect.enabled = false; + if (Manager.Player) + { + Manager.Player.enabled = true; Manager.Player.ControlEnabled = false; + } if (Manager.CameraInput) Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.HoldRightMouseButton; diff --git a/Assets/Scripts/PlayerControlMode.cs b/Assets/Scripts/PlayerControlMode.cs index 35587a6..44e2567 100644 --- a/Assets/Scripts/PlayerControlMode.cs +++ b/Assets/Scripts/PlayerControlMode.cs @@ -8,8 +8,14 @@ public class PlayerControlMode : GameMode public override void Enter() { + if (Manager.Insect) + Manager.Insect.enabled = false; + if (Manager.Player) + { + Manager.Player.enabled = true; Manager.Player.ControlEnabled = true; + } if (Manager.CameraInput) Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive;