Diagnosis (agent + live values): tuning & tread geometry were fine, debugAutoDrive off — the real drag was the box chassis sliding on default ~0.6 friction, which ate forward speed AND resisted yaw (dead steering). - Bot_LowFriction physics material (0.3, Minimum combine) on the chassis → slides + yaws freely. - TreadPart yaw-assist (220 N·m, sign reinforces natural differential) so a throttle difference gives a clear turn; cancels to zero on equal throttle (straight kept). - SeatManager: context-aware controls tooltip under the seat box (on-foot vs driving), with a 'turn: differ vs teammate' hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
9 KiB
C#
200 lines
9 KiB
C#
using Unity.Netcode;
|
|
using UnityEngine;
|
|
|
|
namespace Jankenbots.Prototype
|
|
{
|
|
/// <summary>
|
|
/// SeatManager — decides WHICH player drives WHICH tread of the shared bot.
|
|
///
|
|
/// M1 coordination feel: two humans each pilot half of one janky body, so the
|
|
/// whole point is that a seat is a scarce, claimable slot. This lives on the
|
|
/// bot chassis GameObject (alongside the chassis Rigidbody / NetworkObject) and
|
|
/// is the single source of truth for seat ownership.
|
|
///
|
|
/// Model:
|
|
/// - Two seats, Left and Right.
|
|
/// - Each seat stores the ulong clientId of its pilot, or <see cref="NoPilot"/>
|
|
/// (== NEUTRAL / free) when nobody holds it.
|
|
/// - First player to ask claims Left, the next claims Right, everyone after
|
|
/// that gets nothing (a spectator for M1). Treads with no pilot stay NEUTRAL
|
|
/// and simply apply no force — the bot just doesn't move on that side.
|
|
///
|
|
/// Authority: server-only writes. Seat state is host-authoritative just like the
|
|
/// physics, so clients never guess — they read replicated NetworkVariables and
|
|
/// send a ServerRpc to *ask* for a seat.
|
|
///
|
|
/// TreadPart hooks into this via <see cref="OwnsSeat"/>: a TreadPart is tagged
|
|
/// Left or Right, and on the host it only honors input from the client that
|
|
/// currently owns that seat.
|
|
/// </summary>
|
|
[RequireComponent(typeof(NetworkObject))]
|
|
public class SeatManager : NetworkBehaviour
|
|
{
|
|
public enum Seat { Left, Right }
|
|
|
|
// Sentinel for "this seat is empty / NEUTRAL". Real clientIds are small
|
|
// ulongs starting at 0, so MaxValue is a safe "nobody" marker. We can't use
|
|
// -1 because clientId is unsigned.
|
|
public const ulong NoPilot = ulong.MaxValue;
|
|
|
|
// Replicated seat ownership. Server writes, everyone reads. Clients subscribe
|
|
// to OnValueChanged if they want to react (e.g. update the seat-claim UI);
|
|
// for M1 we just poll them in OnGUI, which is plenty.
|
|
public readonly NetworkVariable<ulong> LeftPilot =
|
|
new NetworkVariable<ulong>(NoPilot,
|
|
NetworkVariableReadPermission.Everyone,
|
|
NetworkVariableWritePermission.Server);
|
|
|
|
public readonly NetworkVariable<ulong> RightPilot =
|
|
new NetworkVariable<ulong>(NoPilot,
|
|
NetworkVariableReadPermission.Everyone,
|
|
NetworkVariableWritePermission.Server);
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Leaver-safety wiring
|
|
// ---------------------------------------------------------------------
|
|
// If a pilot rage-quits (or their WiFi dies), their seat MUST free up so a
|
|
// remaining/new player can grab it — otherwise the bot is permanently
|
|
// half-dead. We hook client-disconnect on the server and clear any seat that
|
|
// pointed at the departing client.
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
// Only the server owns seat state, so only the server needs the hook.
|
|
if (IsServer)
|
|
{
|
|
NetworkManager.Singleton.OnClientDisconnectCallback += HandleClientDisconnect;
|
|
}
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
if (IsServer && NetworkManager.Singleton != null)
|
|
{
|
|
NetworkManager.Singleton.OnClientDisconnectCallback -= HandleClientDisconnect;
|
|
}
|
|
}
|
|
|
|
// Leaver-safety: free whichever seat the departing pilot held.
|
|
void HandleClientDisconnect(ulong clientId)
|
|
{
|
|
if (LeftPilot.Value == clientId) LeftPilot.Value = NoPilot;
|
|
if (RightPilot.Value == clientId) RightPilot.Value = NoPilot;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Claiming a seat
|
|
// ---------------------------------------------------------------------
|
|
// A client presses "join the bot"; we don't let it pick a side — first come
|
|
// gets Left, second gets Right. This keeps M1 dead simple and mirrors the
|
|
// "first two players claim left/right tread" scope.
|
|
|
|
/// <summary>
|
|
/// Client asks the host for a seat. RequireOwnership is false because the
|
|
/// asking client does NOT own the bot (the host does) — any client may ask.
|
|
/// The sender's clientId comes from RpcParams, so a client can't lie about
|
|
/// who it is.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, RequireOwnership = false)]
|
|
public void ClaimSeatRpc(RpcParams rpcParams = default)
|
|
{
|
|
ulong requester = rpcParams.Receive.SenderClientId;
|
|
|
|
// Already seated? Do nothing (idempotent — safe to spam the button).
|
|
if (LeftPilot.Value == requester || RightPilot.Value == requester)
|
|
return;
|
|
|
|
// Fill Left first, then Right. Anyone after that is a spectator for M1.
|
|
if (LeftPilot.Value == NoPilot)
|
|
LeftPilot.Value = requester;
|
|
else if (RightPilot.Value == NoPilot)
|
|
RightPilot.Value = requester;
|
|
// else: bot is full — silently ignore.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Voluntarily give up your seat (frees it as NEUTRAL). Handy for testing and
|
|
/// for letting a player hop from Left to Right. Server-authoritative.
|
|
/// </summary>
|
|
[Rpc(SendTo.Server, RequireOwnership = false)]
|
|
public void ReleaseSeatRpc(RpcParams rpcParams = default)
|
|
{
|
|
ulong requester = rpcParams.Receive.SenderClientId;
|
|
if (LeftPilot.Value == requester) LeftPilot.Value = NoPilot;
|
|
if (RightPilot.Value == requester) RightPilot.Value = NoPilot;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Queries used by TreadPart (host-side input gating)
|
|
// ---------------------------------------------------------------------
|
|
|
|
/// <summary>Does <paramref name="clientId"/> currently pilot <paramref name="seat"/>?
|
|
/// TreadPart calls this on the host to decide whether to honor an input RPC.</summary>
|
|
public bool OwnsSeat(Seat seat, ulong clientId)
|
|
{
|
|
return PilotOf(seat) == clientId && clientId != NoPilot;
|
|
}
|
|
|
|
/// <summary>ClientId piloting a seat, or <see cref="NoPilot"/> if free.</summary>
|
|
public ulong PilotOf(Seat seat)
|
|
{
|
|
return seat == Seat.Left ? LeftPilot.Value : RightPilot.Value;
|
|
}
|
|
|
|
/// <summary>Which seat (if any) the local player holds — for local UI only.</summary>
|
|
Seat? LocalSeat()
|
|
{
|
|
if (NetworkManager.Singleton == null) return null;
|
|
ulong me = NetworkManager.Singleton.LocalClientId;
|
|
if (LeftPilot.Value == me) return Seat.Left;
|
|
if (RightPilot.Value == me) return Seat.Right;
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Throwaway debug UI — good enough for playtesting with friends.
|
|
// ---------------------------------------------------------------------
|
|
void OnGUI()
|
|
{
|
|
if (!IsSpawned) return;
|
|
|
|
Seat? mine = LocalSeat();
|
|
string held = mine.HasValue ? mine.Value.ToString() : "none (spectator)";
|
|
|
|
// Sits BELOW the NetworkBootstrap connectivity panel (which occupies the
|
|
// top-left ~260px) so the Claim button isn't hidden behind it.
|
|
const int x = 10, y = 300, w = 260, h = 78;
|
|
GUI.Box(new Rect(x, y, w, h), "JANKENBOTS · seat");
|
|
GUI.Label(new Rect(x + 10, y + 22, w - 20, 20), $"You hold: {held}");
|
|
GUI.Label(new Rect(x + 10, y + 42, w - 20, 20),
|
|
$"Left: {SeatLabel(LeftPilot.Value)} Right: {SeatLabel(RightPilot.Value)}");
|
|
|
|
// Client-side ask/release buttons (they just fire the ServerRpc).
|
|
if (!mine.HasValue)
|
|
{
|
|
if (GUI.Button(new Rect(x + w - 70, y + 20, 55, 20), "Claim"))
|
|
ClaimSeatRpc();
|
|
}
|
|
else if (GUI.Button(new Rect(x + w - 70, y + 20, 55, 20), "Leave"))
|
|
{
|
|
ReleaseSeatRpc();
|
|
}
|
|
|
|
// Controls hint under the seat box — context-aware (on foot vs driving).
|
|
int ty = y + h + 6, th = 54;
|
|
GUI.Box(new Rect(x, ty, w, th), "Controls");
|
|
if (mine.HasValue)
|
|
{
|
|
GUI.Label(new Rect(x + 10, ty + 20, w - 20, 18), "W throttle · LShift pivot · A/D lean");
|
|
GUI.Label(new Rect(x + 10, ty + 34, w - 20, 18), "Q leave · V camera · turn: differ vs teammate");
|
|
}
|
|
else
|
|
{
|
|
GUI.Label(new Rect(x + 10, ty + 20, w - 20, 18), "WASD move · Mouse look · Scroll zoom");
|
|
GUI.Label(new Rect(x + 10, ty + 34, w - 20, 18), "E claim tread · V camera · Esc free cursor");
|
|
}
|
|
}
|
|
|
|
static string SeatLabel(ulong pilot) => pilot == NoPilot ? "free" : $"#{pilot}";
|
|
}
|
|
}
|