using Unity.Netcode; using UnityEngine; using UnityEngine.InputSystem; namespace Jankenbots.Prototype { /// /// JANKENBOTS M1 — ONE tread of the shared janky bot. /// /// This is the HEART of the control-feel test. Everything here exists to answer /// one question: is it FUN for two friends to each drive one tread of the same /// clumsy body and try to make it go where they want together? /// /// ARCHITECTURE (host-authoritative, no prediction — see the M1 cheat-sheet): /// * There is ONE simulated Rigidbody + ONE NetworkObject: the CHASSIS. Both /// treads are plain CHILD GameObjects (no Rigidbody, no NetworkObject of their /// own); each TreadPart is a NetworkBehaviour bound to the chassis' shared /// NetworkObject. (NGO forbids nested NetworkObjects in a spawned prefab, so /// per-tread ownership is impossible — we gate on the SEAT instead.) /// * Differential drive "emerges" because the two treads apply their forces at /// different WORLD POSITIONS (left vs right of centre). /// * SEAT-BASED input gating (via ): a client only reads /// & sends input for a tread whose seat it currently holds. The host re-checks /// seat ownership before honoring any input RPC, so a client can't drive a tread /// it doesn't pilot. /// * The host (IsServer) caches the latest validated input per tread and applies /// ALL forces in FixedUpdate. NGO's NetworkRigidbody/NetworkTransform on the /// chassis replicates the resulting motion back to everyone. /// /// FEEL MODEL v0.1 — three deliberately "janky" verbs, each a literal force: /// (a) SNAP-TO-CRUISE THROTTLE — slam forward → a fixed CRUISE force; partial is /// proportional but the intent is over-commitment (that's the comedy). /// (b) LOCK-PIVOT — hold a button and THIS tread plants as an anchor; the bot /// swings about it like a pinned foot. /// (c) PASSIVE LEAN — sideways stick dumps a ballast torque to counter-roll the /// top-heavy bot; a constant shared balancing chore. /// /// Tuning fields are public so we can dial the feel live in the inspector while /// friends are playing. Numbers here are only sane starting points. /// [DisallowMultipleComponent] public class TreadPart : NetworkBehaviour { // Which side of the bot this tread is. Drives the SEAT it maps to (Left/Right) // AND, because the tread physically SITS left/right of centre, the differential // drive. Set per-tread in the inspector. public enum Side { Left, Right } [Header("Identity")] [Tooltip("Which tread this is → which SeatManager seat it maps to, and (via its world position) which side of the chassis it pushes.")] public Side side = Side.Left; [Header("Shared body")] [Tooltip("The ONE simulated chassis Rigidbody every tread pushes on. Leave empty to auto-find on a parent.")] public Rigidbody chassis; // ---- (a) SNAP-TO-CRUISE THROTTLE tuning ---------------------------------- [Header("(a) Throttle — snap-to-cruise")] [Tooltip("Force (Newtons) applied at full-forward stick. This is the 'cruise' the tread snaps to.")] public float cruiseForce = 1200f; [Tooltip("Below this stick magnitude we treat throttle as zero.")] [Range(0f, 0.5f)] public float throttleDeadzone = 0.08f; // ---- (b) LOCK-PIVOT tuning ----------------------------------------------- [Header("(b) Lock-pivot — plant this tread as an anchor")] [Tooltip("How hard the planted tread resists the chassis sliding at its position.")] public float pivotAnchorStrength = 2500f; [Tooltip("Extra angular damping (torque opposing spin) while planting.")] public float pivotAngularResistance = 400f; // ---- (c) PASSIVE LEAN tuning --------------------------------------------- [Header("(c) Passive lean — anti-tip ballast")] [Tooltip("Ballast torque (N·m) at full sideways stick, applied along the drive (forward) axis.")] public float leanTorque = 800f; [Tooltip("Below this sideways magnitude, no lean torque.")] [Range(0f, 0.5f)] public float leanDeadzone = 0.08f; // ---- Debug / testing ----------------------------------------------------- [Header("Debug / testing")] [Tooltip("HOST ONLY: when true, this tread ignores seat/input and drives at full cruise every FixedUpdate. Lets ONE machine feel two-tread coordination — the host auto-drives one tread while a friend/clone drives the other. Leave OFF for real play.")] public bool debugAutoDriveFull = false; // The seat this tread maps to, resolved once from `side`. SeatManager.Seat MySeat => side == Side.Left ? SeatManager.Seat.Left : SeatManager.Seat.Right; // Source of truth for who pilots which tread. Found on a parent (the chassis). SeatManager _seats; // -------------------------------------------------------------------------- // HOST-SIDE cached input. Written ONLY by the (validated) RPC on the server and // read ONLY in FixedUpdate (also server-gated). No sync primitive needed — the // authoritative simulation is entirely host-local. // -------------------------------------------------------------------------- float _throttle; // 0..1 bool _pivotHeld; float _lean; // -1..1 void Awake() { if (chassis == null) chassis = GetComponentInParent(); _seats = GetComponentInParent(); } // ========================================================================== // CLIENT: if the LOCAL player holds this tread's seat, read input and ship it // to the host. Nothing is applied locally. Seat ownership — not NetworkObject // ownership — is the gate, so each client self-selects the tread it pilots. // ========================================================================== void Update() { if (!IsSpawned || _seats == null) return; ulong me = NetworkManager.Singleton.LocalClientId; if (_seats.PilotOf(MySeat) != me) return; // I don't drive this tread float throttle = ReadThrottle(); // 0..1 bool pivot = ReadPivotHeld(); float lean = ReadLean(); // -1..1 SubmitTreadInputRpc(throttle, pivot, lean); } /// /// Owning pilot's client → host. RequireOwnership is FALSE (the bot's /// NetworkObject is owned by the server, not the pilot) — instead the host /// re-validates the SEAT so a client can only drive the tread it actually /// pilots. The host just caches; forces are applied in FixedUpdate. /// [Rpc(SendTo.Server, RequireOwnership = false)] void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams rpcParams = default) { // Anti-spoof: only honor input from the client that currently holds this seat. if (_seats == null || !_seats.OwnsSeat(MySeat, rpcParams.Receive.SenderClientId)) return; _throttle = Mathf.Clamp01(throttle); _pivotHeld = pivotHeld; _lean = Mathf.Clamp(lean, -1f, 1f); } // ========================================================================== // HOST ONLY: turn the cached input into literal forces on the SHARED chassis. // Two TreadParts running this in the same FixedUpdate, pushing at their two // different world positions, ARE the differential drive. // ========================================================================== void FixedUpdate() { if (!IsServer) return; // authority guard — clients never simulate if (chassis == null) return; // Test affordance: drive full-forward regardless of seat/input, so one // machine can feel two-tread coordination (host drives this tread, a // clone/friend drives the other). Off for real play. if (debugAutoDriveFull) { chassis.AddForceAtPosition(transform.forward * cruiseForce, transform.position, ForceMode.Force); return; } // Un-piloted tread goes limp: no pilot → no cached force. (Also clears any // stale input the instant a pilot leaves, so the bot doesn't coast on it.) if (_seats == null || _seats.PilotOf(MySeat) == SeatManager.NoPilot) { _throttle = 0f; _pivotHeld = false; _lean = 0f; return; } Vector3 treadPos = transform.position; // where THIS tread pushes from if (_pivotHeld) { // ---- (b) LOCK-PIVOT -------------------------------------------- // Plant this tread: cancel linear slip at its position and bleed spin // so the OTHER tread's thrust swings the bot about this point. Vector3 pointVel = chassis.GetPointVelocity(treadPos); chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force); chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force); return; // a planted tread is an anchor, not a motor } // ---- (a) SNAP-TO-CRUISE THROTTLE ----------------------------------- // Full stick == full cruiseForce; partial scales down. Applied AT the // tread's world position along its own forward → left/right offset = // differential drive (asymmetric throttle turns the bot). if (_throttle > throttleDeadzone) { Vector3 drive = transform.forward * (_throttle * cruiseForce); chassis.AddForceAtPosition(drive, treadPos, ForceMode.Force); } // ---- (c) PASSIVE LEAN — anti-tip ballast --------------------------- if (Mathf.Abs(_lean) > leanDeadzone) { chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force); } } // ========================================================================== // INPUT READERS — new Input System (project is Input-System-New-only, so legacy // UnityEngine.Input would throw). Same keys for both seats; the SEAT gate in // Update() decides which tread a given client actually drives. Only called for // the tread the local player pilots. Swap for per-seat gamepad actions later. // Throttle = W / Up · Pivot = Left Shift · Lean = A/D or Left/Right // ========================================================================== /// 0..1 throttle. Forward only (no reverse in v0.1). float ReadThrottle() { var kb = Keyboard.current; if (kb == null) return 0f; float v = (kb.wKey.isPressed || kb.upArrowKey.isPressed) ? 1f : 0f; return v < throttleDeadzone ? 0f : v; } /// Is the plant-pivot button held? bool ReadPivotHeld() { var kb = Keyboard.current; return kb != null && (kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed); } /// -1..1 sideways ballast request. float ReadLean() { var kb = Keyboard.current; if (kb == null) return 0f; float h = 0f; if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) h -= 1f; if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) h += 1f; return Mathf.Abs(h) < leanDeadzone ? 0f : h; } } }