First 2-pilot test: bot was too hot (1200N cruise) and top-heavy -> launched off on any nudge. Tamed in-scene: cruiseForce 1200->450, linearDamping 1.5, angularDamping 6, maxAngularVelocity capped at 3, and lowered centerOfMass to (0,-0.6,0) so it's bottom-heavy and resists cartwheeling. Added a host-only debugAutoDriveFull flag on TreadPart to feel two-tread coordination solo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
239 lines
12 KiB
C#
239 lines
12 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
|
|
/// & 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;
|
|
|
|
// ---- 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<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;
|
|
|
|
// 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
|
|
// ==========================================================================
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|