jankenbots/Assets/Scripts/Bot/TreadPart.cs
megaproxy e8d72e4649 M1: build shared 2-tread bot; rewire TreadPart to seat-based gating
- Rewrote TreadPart: seat-based input gating (was mis-wired to NetworkObject
  IsOwner, which needs per-tread ownership that NGO forbids for nested
  NetworkObjects). Both treads now share the chassis NetworkObject; a client
  drives the tread whose SeatManager seat it holds, host re-validates OwnsSeat
  on a RequireOwnership=false RPC. Also swapped to Keyboard.current (New input).
- Moved SeatManager OnGUI below the connectivity panel (was overlapping).
- M1_Arena: in-scene Bot NetworkObject = chassis (Rigidbody 60kg + NetworkObject
  + NetworkTransform + NetworkRigidbody + SeatManager) + Tread_L/Tread_R children
  (TreadPart, world offset +/-1.2 X for differential drive).
- Solo-verified: bot auto-spawns in-scene, seat claim + input gating wired.

Controls: W/Up throttle, Shift pivot, A/D lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:13:41 +01:00

225 lines
11 KiB
C#

using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Jankenbots.Prototype
{
/// <summary>
/// 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 <see cref="SeatManager"/>): a client only reads
/// &amp; 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.
/// </summary>
[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;
// 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<Rigidbody>();
_seats = GetComponentInParent<SeatManager>();
}
// ==========================================================================
// 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);
}
/// <summary>
/// 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.
/// </summary>
[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;
// 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
// ==========================================================================
/// <summary>0..1 throttle. Forward only (no reverse in v0.1).</summary>
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;
}
/// <summary>Is the plant-pivot button held?</summary>
bool ReadPivotHeld()
{
var kb = Keyboard.current;
return kb != null && (kb.leftShiftKey.isPressed || kb.rightShiftKey.isPressed);
}
/// <summary>-1..1 sideways ballast request.</summary>
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;
}
}
}