using Unity.Netcode; using UnityEngine; 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: the CHASSIS. Both treads are just /// force-emitters that push on that shared body. No tread has its own /// Rigidbody. Differential drive "emerges" because the two treads apply /// their forces at different WORLD POSITIONS (left vs right of centre). /// * The owning pilot's client only READS input and ships {throttle, pivotHeld, /// lean} to the host via an RPC. It applies NOTHING locally. /// * The host (IsServer) caches the latest 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 — a tread does not have an analog gas pedal. /// Push the stick fully forward and it commits to a fixed CRUISE force /// (chunky, momentum-y, a bit out of your hands). Partial stick is /// proportional so you CAN feather it, but the intent is "slam it to /// cruise and live with the consequences" — that shared over-commitment /// is where the comedy of coordination comes from. /// (b) LOCK-PIVOT — hold a button and THIS tread plants itself as an anchor. /// Its drive contribution is cancelled and we actively fight the chassis' /// motion AT the tread's position, so the whole bot swings/rotates about /// this tread like a pinned foot. Two pilots learn "you plant, I drive" /// to turn on the spot. /// (c) PASSIVE LEAN — this tall bot WANTS to tip over. Nudging the stick /// sideways dumps a ballast torque along the drive axis to counter-roll, /// a constant low-key balancing chore shared between pilots. /// /// 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. Purely descriptive for M1 (the actual // left/right behaviour comes from where the tread SITS on the chassis, not // from this enum) — but it's handy for seat-assignment logs and inspector // sanity, and lets us bias per-side tuning later if we want asymmetry. public enum Side { Left, Right } [Header("Identity")] [Tooltip("Which tread this is. Descriptive — real behaviour comes from world position on the chassis.")] 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. Bigger = the bot lurches harder and is twic­e as hard to coordinate.")] public float cruiseForce = 1200f; [Tooltip("Below this stick magnitude we treat throttle as zero — kills drift/noise so a resting stick doesn't creep the bot.")] [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. Higher = a crisper, more locked pivot; too high = the whole bot snaps rigidly and feels un-janky.")] public float pivotAnchorStrength = 2500f; [Tooltip("Extra angular damping (torque opposing spin) while planting. Keeps the pivot from becoming a wild spin — the planted foot should feel 'stuck', not greasy.")] 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 to counter-roll the tall bot. Tune vs how tippy the chassis is.")] public float leanTorque = 800f; [Tooltip("Below this sideways magnitude, no lean torque — resting stick = no ballast.")] [Range(0f, 0.5f)] public float leanDeadzone = 0.08f; // -------------------------------------------------------------------------- // HOST-SIDE cached input. These are written ONLY by the RPC (which only runs // on the server) and read ONLY in FixedUpdate (also gated to server). We never // touch them on a non-owning client, so no sync primitive is needed — the // authoritative simulation is entirely host-local. // -------------------------------------------------------------------------- float _throttle; // 0..1, already deadzoned/clamped by the sender bool _pivotHeld; // is the pilot planting this tread right now? float _lean; // -1..1 sideways ballast request void Awake() { // Convenience: if nobody wired the chassis in the inspector, grab the // Rigidbody off a parent. All treads should end up pointing at the SAME // chassis Rigidbody — that shared reference is what makes it one bot. if (chassis == null) chassis = GetComponentInParent(); } // ========================================================================== // CLIENT: read local input, ship it to the host. Nothing is applied locally. // Runs every frame on the owning pilot only. // ========================================================================== void Update() { if (!IsOwner) return; float throttle = ReadThrottle(); // 0..1 bool pivot = ReadPivotHeld(); float lean = ReadLean(); // -1..1 // One tiny packet per frame to the host. The host simulates; we watch the // replicated chassis move. That round-trip "lag between my stick and the // bot lurching" is itself part of the janky feel we're testing. SubmitTreadInputRpc(throttle, pivot, lean); } /// /// Owning client → host. Named *Rpc + [Rpc(SendTo.Server)] per NGO 2.x. /// RequireOwnership stays true (default): only the pilot who owns this tread /// may drive it. The host just caches; forces are applied in FixedUpdate. /// [Rpc(SendTo.Server)] void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams _ = default) { _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. // This is the entire physics of the bot. 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; Vector3 treadPos = transform.position; // where THIS tread pushes from if (_pivotHeld) { // ---- (b) LOCK-PIVOT -------------------------------------------- // The pilot has planted this tread. We do NOT drive with it; instead // we make the chassis behave as if it's pinned at this tread's // position, so the OTHER tread's thrust swings the whole bot around // this point like a pivoting foot. // // 1) Cancel the sideways/linear slip AT the tread position by pushing // back against the local velocity there. GetPointVelocity gives the // chassis' velocity at this world point (includes rotation), so // opposing it plants the point in space. Vector3 pointVel = chassis.GetPointVelocity(treadPos); chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force); // 2) Bleed off raw spin so the pivot feels 'stuck', not greasy. This // is a soft angular brake, NOT a hard lock — we still want jank. chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force); // NOTE: no throttle drive while planting — a planted tread is an // anchor, not a motor. (Lean is also skipped: you're busy pivoting.) return; } // ---- (a) SNAP-TO-CRUISE THROTTLE ----------------------------------- // throttle is 0..1. Full stick == full cruiseForce (the "snap to cruise" // commitment); partial stick scales it down so feathering is possible but // not the point. Push along the tread's own forward so a mis-aligned / // knocked-askew tread pushes the bot in a wonky direction — jank on // purpose. Applied AT the tread's world position → 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 --------------------------- // Sideways stick shovels ballast torque along the drive (forward) axis to // counter-roll the top-heavy bot. It's a constant balancing chore the // pilots share; it does NOT steer (that's the throttle differential). if (Mathf.Abs(_lean) > leanDeadzone) { chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force); } } // ========================================================================== // INPUT READERS — placeholder wiring for M1. Swap for real Input System // actions once seats are assigned; kept trivial so the physics is testable // immediately. Only ever called on the owning client (inside Update's guard). // ========================================================================== /// 0..1 throttle. Vertical axis, forward only (no reverse in v0.1). float ReadThrottle() { // Forward-only: negative stick = 0 throttle (reverse is a later feel test). float v = Mathf.Max(0f, Input.GetAxisRaw("Vertical")); return v < throttleDeadzone ? 0f : v; } /// Is the plant-pivot button held? bool ReadPivotHeld() { // Placeholder: left shift = plant. Real build: per-seat gamepad button. return Input.GetKey(KeyCode.LeftShift); } /// -1..1 sideways ballast request. float ReadLean() { float h = Input.GetAxisRaw("Horizontal"); return Mathf.Abs(h) < leanDeadzone ? 0f : h; } // ========================================================================== // Seat lifecycle. The bot spawns owned by the server; the seat manager grants // a tread to a pilot via NetworkObject.ChangeOwnership(clientId) (server-only, // see cheat-sheet §8). These hooks just log so we can see claims land while // testing with friends, and clear stale input if a pilot leaves. // ========================================================================== public override void OnGainedOwnership() { base.OnGainedOwnership(); if (IsOwner) Debug.Log($"[TreadPart] {side} tread claimed by local pilot (client {OwnerClientId})."); } public override void OnLostOwnership() { base.OnLostOwnership(); // On the host, wipe cached input so an un-piloted tread goes limp instead // of coasting on the last pilot's stick. if (IsServer) { _throttle = 0f; _pivotHeld = false; _lean = 0f; } } } }