From 87f929b442eb08055dc63202db0349f846500ed1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Sun, 12 Jul 2026 17:13:28 +0100 Subject: [PATCH] M1: add client-local follow camera (position-follow, anti-nausea) Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Camera/FollowCamera.cs | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 Assets/Scripts/Camera/FollowCamera.cs diff --git a/Assets/Scripts/Camera/FollowCamera.cs b/Assets/Scripts/Camera/FollowCamera.cs new file mode 100644 index 0000000..99126e6 --- /dev/null +++ b/Assets/Scripts/Camera/FollowCamera.cs @@ -0,0 +1,124 @@ +using UnityEngine; + +namespace Jankenbots.Prototype +{ + /// + /// JANKENBOTS M1 — client-local follow camera for the shared bot. + /// + /// WHY THIS EXISTS: two pilots are each fighting for control of one janky body; + /// the camera's whole job is to let a friend WATCH that chaos without becoming + /// part of it. This is deliberately NOT a NetworkBehaviour — every client frames + /// their own view locally off the shared, replicated chassis transform. There is + /// nothing here for the host to authorize or sync. + /// + /// THE NAUSEA DECISION: the bot is designed to tip, spin, and lurch (that's the + /// whole "janky" bit — see TreadPart's lock-pivot + passive-lean). A camera that + /// rigidly inherits the chassis' rotation would roll and yaw right along with it, + /// which reads as motion-sickness fuel, not slapstick. So this rig follows the + /// chassis POSITION ONLY, holds a FIXED WORLD-SPACE offset (behind + above, using + /// world axes, not the bot's own forward/up), and separately looks-at the bot. + /// The tradeoff: the camera won't "face the way the bot is facing" the way a + /// chase-cam in a racing game would, so if the bot spins in place the framing + /// doesn't spin with it — on purpose. If M1 playtesting says the view feels too + /// detached (the bot wanders out of frame during a hard turn), the next lever to + /// pull is following bot YAW ONLY (ignore pitch/roll) rather than full rotation — + /// not implemented here to keep v0.1 dead simple and guaranteed comfortable. + /// + /// Position smooths with SmoothDamp (velocity-aware, no overshoot-then-snap-back); + /// the look-at direction smooths separately with a Slerp so the camera doesn't + /// whip-pan when the bot lurches sideways. Tuning fields are public so we can dial + /// the feel live in the inspector while friends are playing. + /// + public class FollowCamera : MonoBehaviour + { + [Header("Target")] + [Tooltip("The bot chassis to follow. Leave empty to auto-find at Start (by name 'Bot', falling back to any SeatManager in the scene) — handy if the bot ends up spawned rather than placed in-scene.")] + public Transform target; + + [Tooltip("Name to search for if Target is empty. Matches the in-scene Bot chassis GameObject.")] + public string targetFallbackName = "Bot"; + + [Header("Framing — fixed WORLD-space offset from the target")] + [Tooltip("Offset from the target's position, in WORLD axes (NOT the bot's local axes — see the nausea note in the class doc). Default sits behind and above a bot-scale body.")] + public Vector3 worldOffset = new Vector3(0f, 8f, -12f); + + [Header("Smoothing")] + [Tooltip("Approximate time (seconds) for the camera position to catch up to the target. Bigger = lazier/smoother, smaller = snappier/closer to rigid.")] + [Range(0.02f, 2f)] + public float positionSmoothTime = 0.25f; + + [Tooltip("Degrees/sec cap the look-at direction can rotate. Keeps a sudden bot lurch from whip-panning the view.")] + public float lookAtDegreesPerSecond = 180f; + + [Tooltip("Extra height added to the look-at point above the target's pivot, so we frame the bot's body rather than staring at its feet.")] + public float lookAtHeightOffset = 1f; + + // SmoothDamp's running velocity state — it owns this between calls, we just hand it back each frame. + Vector3 _positionVelocity; + + // Current look direction, slewed toward the target each frame — separate from position + // smoothing so a fast reposition doesn't also whip the facing around. + Quaternion _currentLookRotation; + + void Start() + { + if (target == null) + target = FindTarget(); + + if (target != null) + { + // Snap framing on the first frame instead of smoothing in from wherever the + // camera happens to sit in the scene/editor. + transform.position = target.position + worldOffset; + _currentLookRotation = Quaternion.LookRotation(LookPoint() - transform.position, Vector3.up); + transform.rotation = _currentLookRotation; + } + } + + // Runs AFTER Update/physics/NetworkTransform have moved the target this frame, so we're + // always chasing its latest replicated position rather than a stale one. + void LateUpdate() + { + if (target == null) + { + target = FindTarget(); + if (target == null) return; // bot not in the scene yet — nothing to frame + } + + // ---- Position: SmoothDamp toward target + fixed world offset --------------- + Vector3 desiredPosition = target.position + worldOffset; + transform.position = Vector3.SmoothDamp( + transform.position, desiredPosition, ref _positionVelocity, positionSmoothTime); + + // ---- Look-at: Slerp the facing toward the target, capped in degrees/sec ----- + // Position-follow-only (see class doc) means we still need to actively aim at + // the bot rather than inherit its rotation — this is that aim, smoothed so a + // sudden tip doesn't snap the view. + Vector3 toTarget = LookPoint() - transform.position; + if (toTarget.sqrMagnitude > 0.0001f) + { + Quaternion desiredLook = Quaternion.LookRotation(toTarget, Vector3.up); + _currentLookRotation = Quaternion.RotateTowards( + _currentLookRotation, desiredLook, lookAtDegreesPerSecond * Time.deltaTime); + transform.rotation = _currentLookRotation; + } + } + + Vector3 LookPoint() => target.position + Vector3.up * lookAtHeightOffset; + + // Fallback target resolution: by name first (matches the in-scene "Bot" chassis), + // then by any SeatManager in the scene (the chassis is where SeatManager lives — + // see SeatManager.cs) in case the bot ends up renamed or spawned from a prefab. + Transform FindTarget() + { + if (!string.IsNullOrEmpty(targetFallbackName)) + { + var byName = GameObject.Find(targetFallbackName); + if (byName != null) return byName.transform; + } + + var seats = Object.FindFirstObjectByType(); + return seats != null ? seats.transform : null; + } + } +}