From d723447e31efa38093bcad7c1b00fc6f13e83e76 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 10 Jul 2026 23:24:47 +0100 Subject: [PATCH] M1: add NGO + Multiplayer Services packages and prototype scripts (compiling) Netcode for GameObjects 2.13.0 + Unity Multiplayer Services 2.2.4 (unified Relay/Lobby SDK; supersedes deprecated standalone relay/lobby). Four M1 scripts, verified against live editor reflection and compiling clean: - Net/NetworkBootstrap.cs Relay host/join-by-code harness (IMGUI) - Net/PlayerDriver.cs replicated host-auth capsule (input->ServerRpc->host) - Bot/TreadPart.cs v0.1 tread feel: snap-to-cruise/lock-pivot/lean - Bot/SeatManager.cs left/right tread seat claim + leaver-safety Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/DefaultNetworkPrefabs.asset | 16 ++ Assets/DefaultNetworkPrefabs.asset.meta | 8 + Assets/Scripts.meta | 8 + Assets/Scripts/Bot.meta | 8 + Assets/Scripts/Bot/SeatManager.cs | 184 ++++++++++++++ Assets/Scripts/Bot/SeatManager.cs.meta | 2 + Assets/Scripts/Bot/TreadPart.cs | 250 +++++++++++++++++++ Assets/Scripts/Bot/TreadPart.cs.meta | 2 + Assets/Scripts/Net.meta | 8 + Assets/Scripts/Net/NetworkBootstrap.cs | 257 ++++++++++++++++++++ Assets/Scripts/Net/NetworkBootstrap.cs.meta | 2 + Assets/Scripts/Net/PlayerDriver.cs | 152 ++++++++++++ Assets/Scripts/Net/PlayerDriver.cs.meta | 2 + Packages/manifest.json | 2 + Packages/packages-lock.json | 109 ++++++++- 15 files changed, 1006 insertions(+), 4 deletions(-) create mode 100644 Assets/DefaultNetworkPrefabs.asset create mode 100644 Assets/DefaultNetworkPrefabs.asset.meta create mode 100644 Assets/Scripts.meta create mode 100644 Assets/Scripts/Bot.meta create mode 100644 Assets/Scripts/Bot/SeatManager.cs create mode 100644 Assets/Scripts/Bot/SeatManager.cs.meta create mode 100644 Assets/Scripts/Bot/TreadPart.cs create mode 100644 Assets/Scripts/Bot/TreadPart.cs.meta create mode 100644 Assets/Scripts/Net.meta create mode 100644 Assets/Scripts/Net/NetworkBootstrap.cs create mode 100644 Assets/Scripts/Net/NetworkBootstrap.cs.meta create mode 100644 Assets/Scripts/Net/PlayerDriver.cs create mode 100644 Assets/Scripts/Net/PlayerDriver.cs.meta diff --git a/Assets/DefaultNetworkPrefabs.asset b/Assets/DefaultNetworkPrefabs.asset new file mode 100644 index 0000000..219664e --- /dev/null +++ b/Assets/DefaultNetworkPrefabs.asset @@ -0,0 +1,16 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e651dbb3fbac04af2b8f5abf007ddc23, type: 3} + m_Name: DefaultNetworkPrefabs + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkPrefabsList + IsDefault: 1 + List: [] diff --git a/Assets/DefaultNetworkPrefabs.asset.meta b/Assets/DefaultNetworkPrefabs.asset.meta new file mode 100644 index 0000000..e57a20a --- /dev/null +++ b/Assets/DefaultNetworkPrefabs.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 26dc7ddf766d17a4f81221bfec5c38f7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts.meta b/Assets/Scripts.meta new file mode 100644 index 0000000..6eca423 --- /dev/null +++ b/Assets/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 085dcb5c31d236f4286afc181d1f9825 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Bot.meta b/Assets/Scripts/Bot.meta new file mode 100644 index 0000000..6514c81 --- /dev/null +++ b/Assets/Scripts/Bot.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 913ee549699dc974a8fa30340fd726f8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Bot/SeatManager.cs b/Assets/Scripts/Bot/SeatManager.cs new file mode 100644 index 0000000..f34d1d8 --- /dev/null +++ b/Assets/Scripts/Bot/SeatManager.cs @@ -0,0 +1,184 @@ +using Unity.Netcode; +using UnityEngine; + +namespace Jankenbots.Prototype +{ + /// + /// 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 + /// (== 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 : a TreadPart is tagged + /// Left or Right, and on the host it only honors input from the client that + /// currently owns that seat. + /// + [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 LeftPilot = + new NetworkVariable(NoPilot, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + + public readonly NetworkVariable RightPilot = + new NetworkVariable(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. + + /// + /// 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. + /// + [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. + } + + /// + /// Voluntarily give up your seat (frees it as NEUTRAL). Handy for testing and + /// for letting a player hop from Left to Right. Server-authoritative. + /// + [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) + // --------------------------------------------------------------------- + + /// Does currently pilot ? + /// TreadPart calls this on the host to decide whether to honor an input RPC. + public bool OwnsSeat(Seat seat, ulong clientId) + { + return PilotOf(seat) == clientId && clientId != NoPilot; + } + + /// ClientId piloting a seat, or if free. + public ulong PilotOf(Seat seat) + { + return seat == Seat.Left ? LeftPilot.Value : RightPilot.Value; + } + + /// Which seat (if any) the local player holds — for local UI only. + 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)"; + + const int w = 260, h = 78; + GUI.Box(new Rect(10, 10, w, h), "JANKENBOTS · seat"); + GUI.Label(new Rect(20, 32, w - 20, 20), $"You hold: {held}"); + GUI.Label(new Rect(20, 52, 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(w - 70, 30, 55, 20), "Claim")) + ClaimSeatRpc(); + } + else if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Leave")) + { + ReleaseSeatRpc(); + } + } + + static string SeatLabel(ulong pilot) => pilot == NoPilot ? "free" : $"#{pilot}"; + } +} diff --git a/Assets/Scripts/Bot/SeatManager.cs.meta b/Assets/Scripts/Bot/SeatManager.cs.meta new file mode 100644 index 0000000..cfc4ea9 --- /dev/null +++ b/Assets/Scripts/Bot/SeatManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3dd8122938f14804f9e4ef7e494fb2f6 \ No newline at end of file diff --git a/Assets/Scripts/Bot/TreadPart.cs b/Assets/Scripts/Bot/TreadPart.cs new file mode 100644 index 0000000..ca98c0c --- /dev/null +++ b/Assets/Scripts/Bot/TreadPart.cs @@ -0,0 +1,250 @@ +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; + } + } + } +} diff --git a/Assets/Scripts/Bot/TreadPart.cs.meta b/Assets/Scripts/Bot/TreadPart.cs.meta new file mode 100644 index 0000000..d961dba --- /dev/null +++ b/Assets/Scripts/Bot/TreadPart.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a07e6377a09865c4e8c2b59a637e7dd1 \ No newline at end of file diff --git a/Assets/Scripts/Net.meta b/Assets/Scripts/Net.meta new file mode 100644 index 0000000..e7c9565 --- /dev/null +++ b/Assets/Scripts/Net.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 737e8fe9fca19404bb92c886b857a5bf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Net/NetworkBootstrap.cs b/Assets/Scripts/Net/NetworkBootstrap.cs new file mode 100644 index 0000000..53ce09e --- /dev/null +++ b/Assets/Scripts/Net/NetworkBootstrap.cs @@ -0,0 +1,257 @@ +// JANKENBOTS — M1 step-1 connectivity harness. +// +// This is THROWAWAY prototype code. Its only job is to get two friends into the +// same networked session with zero port-forwarding: one clicks Host (which spins +// up a Unity Relay allocation and prints a short join code), the other types that +// code and clicks Join. Everything is host-authoritative from here on out; this +// file just establishes the pipe. +// +// Attach this component to the SAME GameObject that carries NetworkManager + +// UnityTransport (see the M1 scene-wiring notes). The IMGUI panel at the bottom is +// deliberately ugly — it exists so we can smoke-test connectivity before any real +// UI exists. + +using System; +using System.Threading.Tasks; +using UnityEngine; +using Unity.Netcode; +using Unity.Netcode.Transports.UTP; +using Unity.Services.Core; +using Unity.Services.Authentication; +using Unity.Services.Relay; // RelayService, RelayServiceException +using Unity.Services.Relay.Models; // Allocation, JoinAllocation, AllocationUtils <-- REQUIRED for AllocationUtils + +public class NetworkBootstrap : MonoBehaviour +{ + [Header("Relay")] + // maxConnections = number of CLIENTS excluding the host. Keep this small for M1 + // smoke-testing; bump toward 24 for the ~25-player target later. + [SerializeField] private int maxConnections = 4; + + // "dtls" = encrypted UDP, the right choice for desktop friends. ("udp" is + // unencrypted; "wss" is WebGL-only and would also need utp.UseWebSockets = true.) + [SerializeField] private string connectionType = "dtls"; + + // Cached transport pulled off the same GameObject at startup. + private UnityTransport _utp; + + // The join code the host hands out. Empty until we've successfully hosted. + private string _hostJoinCode = ""; + + // What the joining player types in. Bound to the OnGUI text field. + private string _joinCodeInput = ""; + + // Simple UI/log line so the harness gives feedback without opening the console. + private string _status = "Idle — Host or Join to begin."; + + // Guard so double-clicking Host/Join doesn't fire two overlapping async flows. + private bool _busy; + + // ----------------------------------------------------------------------------- + // Startup: bring Unity Services online and sign in anonymously ONCE. Relay and + // Auth both refuse to work until this completes, so we do it eagerly on Start + // rather than lazily inside Host/Join (which also re-guard, so it's safe either way). + // ----------------------------------------------------------------------------- + private async void Start() + { + _utp = NetworkManager.Singleton != null + ? NetworkManager.Singleton.GetComponent() + : GetComponent(); + + if (_utp == null) + Debug.LogError("[NetworkBootstrap] No UnityTransport found on the NetworkManager GameObject."); + + await EnsureServicesReady(); + } + + // Idempotent: safe to call before every Host/Join. Guarded by the Services state + // machine and the sign-in flag so we never double-initialize or double-sign-in. + private async Task EnsureServicesReady() + { + try + { + if (UnityServices.State != ServicesInitializationState.Initialized) + await UnityServices.InitializeAsync(); + + if (!AuthenticationService.Instance.IsSignedIn) + await AuthenticationService.Instance.SignInAnonymouslyAsync(); + } + catch (Exception e) + { + // A linked Unity Cloud project ID (Project Settings ▸ Services) is required + // or InitializeAsync throws here. + Debug.LogError($"[NetworkBootstrap] Services init/sign-in failed: {e}"); + _status = "Services init failed — see console."; + throw; // let the caller abort its Host/Join flow. + } + } + + // ----------------------------------------------------------------------------- + // HOST: create a Relay allocation, feed it into UnityTransport, fetch the human- + // friendly join code, then StartHost(). Returns the join code (or null on failure). + // ----------------------------------------------------------------------------- + public async Task HostGame() + { + if (_busy) return null; + _busy = true; + _status = "Hosting…"; + try + { + await EnsureServicesReady(); + + // Reserve a slot on Relay. This is where the actual relay server is picked. + Allocation allocation = await RelayService.Instance.CreateAllocationAsync(maxConnections); + + // Hand the allocation to the transport. NOTE: with the unified + // com.unity.services.multiplayer SDK, AllocationUtils.ToRelayServerData is the + // ONLY valid conversion — the old `new RelayServerData(allocation, "dtls")` + // ctor does not exist here. + _utp.SetRelayServerData(AllocationUtils.ToRelayServerData(allocation, connectionType)); + + // The short code friends will type to join. + _hostJoinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId); + + // SetRelayServerData must happen BEFORE StartHost. StartHost() returns bool. + if (!NetworkManager.Singleton.StartHost()) + { + Debug.LogError("[NetworkBootstrap] StartHost() returned false."); + _status = "StartHost failed — see console."; + return null; + } + + _status = $"Hosting. Join code: {_hostJoinCode}"; + Debug.Log($"[NetworkBootstrap] Hosting with join code {_hostJoinCode}"); + return _hostJoinCode; + } + catch (RelayServiceException e) + { + Debug.LogError($"[NetworkBootstrap] Relay error while hosting: {e}"); + _status = "Relay error while hosting — see console."; + return null; + } + catch (Exception e) + { + Debug.LogError($"[NetworkBootstrap] Unexpected error while hosting: {e}"); + _status = "Host failed — see console."; + return null; + } + finally + { + _busy = false; + } + } + + // ----------------------------------------------------------------------------- + // JOIN: resolve the join code into a JoinAllocation, configure transport, StartClient(). + // ----------------------------------------------------------------------------- + public async Task JoinGame(string joinCode) + { + if (_busy) return false; + if (string.IsNullOrWhiteSpace(joinCode)) + { + Debug.LogError("[NetworkBootstrap] JoinGame called with an empty code."); + _status = "Enter a join code first."; + return false; + } + + _busy = true; + _status = "Joining…"; + try + { + await EnsureServicesReady(); + + // Relay codes are case-insensitive but we trim stray whitespace/newlines. + JoinAllocation allocation = + await RelayService.Instance.JoinAllocationAsync(joinCode.Trim()); + + _utp.SetRelayServerData(AllocationUtils.ToRelayServerData(allocation, connectionType)); + + if (!NetworkManager.Singleton.StartClient()) + { + Debug.LogError("[NetworkBootstrap] StartClient() returned false."); + _status = "StartClient failed — see console."; + return false; + } + + _status = "Joined. Waiting for host…"; + Debug.Log("[NetworkBootstrap] Client started, connecting to host via Relay."); + return true; + } + catch (RelayServiceException e) + { + Debug.LogError($"[NetworkBootstrap] Relay error while joining: {e}"); + _status = "Bad code / Relay error — see console."; + return false; + } + catch (Exception e) + { + Debug.LogError($"[NetworkBootstrap] Unexpected error while joining: {e}"); + _status = "Join failed — see console."; + return false; + } + finally + { + _busy = false; + } + } + + // ----------------------------------------------------------------------------- + // Dead-simple IMGUI harness. Once we're connected the buttons hide themselves and + // we just show status + (for the host) a selectable join code you can copy out. + // async void event handlers are fine for a throwaway prototype. + // ----------------------------------------------------------------------------- + private void OnGUI() + { + const float pad = 10f; + GUILayout.BeginArea(new Rect(pad, pad, 320f, 260f), GUI.skin.box); + + GUILayout.Label("JANKENBOTS — M1 connectivity"); + GUILayout.Label(_status); + GUILayout.Space(6f); + + bool live = NetworkManager.Singleton != null && + (NetworkManager.Singleton.IsHost || + NetworkManager.Singleton.IsServer || + NetworkManager.Singleton.IsClient); + + if (!live) + { + GUI.enabled = !_busy; + + // HOST ------------------------------------------------------------- + if (GUILayout.Button("Host", GUILayout.Height(30f))) + _ = HostGame(); // fire-and-forget; HostGame owns its own error logging. + + GUILayout.Space(8f); + + // JOIN ------------------------------------------------------------- + GUILayout.Label("Join code:"); + _joinCodeInput = GUILayout.TextField(_joinCodeInput, GUILayout.Height(24f)); + if (GUILayout.Button("Join", GUILayout.Height(30f))) + _ = JoinGame(_joinCodeInput); + + GUI.enabled = true; + } + else + { + // Connected. If we're the host, surface the code as a selectable text field + // so the tester can highlight + copy it to paste into chat. + if (NetworkManager.Singleton.IsHost && !string.IsNullOrEmpty(_hostJoinCode)) + { + GUILayout.Label("Share this join code:"); + // A read-only-ish selectable field: users can select/copy, edits are ignored. + GUILayout.TextField(_hostJoinCode, GUILayout.Height(28f)); + } + + GUILayout.Space(8f); + if (GUILayout.Button("Disconnect", GUILayout.Height(28f))) + { + NetworkManager.Singleton.Shutdown(); + _hostJoinCode = ""; + _status = "Disconnected."; + } + } + + GUILayout.EndArea(); + } +} diff --git a/Assets/Scripts/Net/NetworkBootstrap.cs.meta b/Assets/Scripts/Net/NetworkBootstrap.cs.meta new file mode 100644 index 0000000..017ffb4 --- /dev/null +++ b/Assets/Scripts/Net/NetworkBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d25ff1aef9852a4b928785411bb8612 \ No newline at end of file diff --git a/Assets/Scripts/Net/PlayerDriver.cs b/Assets/Scripts/Net/PlayerDriver.cs new file mode 100644 index 0000000..640f4f5 --- /dev/null +++ b/Assets/Scripts/Net/PlayerDriver.cs @@ -0,0 +1,152 @@ +// PlayerDriver.cs — JANKENBOTS Milestone 1, step 2: the replicated player-driven capsule. +// +// WHAT THIS IS (feel over fidelity — throwaway prototype code): +// A single free-roaming capsule that one player owns and drives around the empty +// networked scene. It exists purely to prove out the M1 netcode loop before we bolt +// on the shared bot: "owning client reads input -> host simulates physics -> NGO +// replicates the transform to everyone." If this capsule slides around smoothly on +// a friend's screen over Relay, the host-authoritative pipeline works. +// +// HOST-AUTHORITATIVE, NO PREDICTION (decided for this throwaway only): +// The host owns the Rigidbody and is the ONLY peer that applies force. The owning +// client never moves itself locally — it just ships its input up to the server every +// FixedUpdate via an [Rpc(SendTo.Server)]. The server caches that input and, in its +// own FixedUpdate, pushes the Rigidbody. A server-authoritative NetworkTransform then +// streams the resulting position/rotation back down to all clients. Because there's +// no client-side prediction, the local player feels one round-trip of lag on their own +// capsule — that's fine and expected for M1; we're testing coordination feel, not twitch. +// +// REQUIRED PREFAB SETUP (assumed wired in the Editor — this script does NOT add them): +// * Rigidbody — the simulated body. Only the host actually integrates it; +// NetworkRigidbody/NetworkTransform force isKinematic=true on +// client copies, so DON'T touch isKinematic yourself. +// * NetworkObject — makes it a spawnable networked entity; register the prefab in +// NetworkManager -> Network Prefabs (or set it as the PlayerPrefab). +// * NetworkTransform — server-authoritative by default (leave Interpolate = true). +// This is what actually replicates our host-driven motion to clients. +// (A NetworkRigidbody is optional for a slow capsule; add one — with UseRigidBodyForMotion +// for the fast shove-ball later — if the plain NetworkTransform sync looks jittery.) +// +// Unity 6.x notes baked in below: Rigidbody.linearVelocity (was .velocity); RPC method +// names MUST end in "Rpc"; physics is gated behind IsServer and only ever runs in FixedUpdate. + +using Unity.Netcode; +using UnityEngine; + +namespace Jankenbots.Net +{ + [RequireComponent(typeof(Rigidbody))] + public class PlayerDriver : NetworkBehaviour + { + [Header("Drive feel (host applies these as literal forces)")] + [Tooltip("Force pushing the capsule along the input direction, in Newtons-ish. Tune for a floaty, cartoony glide — this is a party game, not a sim.")] + [SerializeField] float moveForce = 40f; + + [Tooltip("Extra damping the host applies when there's no input, so the capsule coasts to a stop instead of drifting forever on the frictionless-ish floor.")] + [SerializeField] float idleDamping = 4f; + + [Tooltip("Clamp on horizontal speed so a friend can't rocket the capsule off the arena.")] + [SerializeField] float maxSpeed = 8f; + + // The Rigidbody we drive. Present on every copy, but only the HOST integrates it. + Rigidbody rb; + + // --- Host-side input cache ------------------------------------------------------- + // The owning client streams its raw stick/keys into these fields via the ServerRpc. + // The host reads them in FixedUpdate. On client copies these just sit unused. + // Stored as an already-normalized planar direction (x = strafe, z = forward). + Vector2 cachedInput; + + void Awake() + { + rb = GetComponent(); + } + + public override void OnNetworkSpawn() + { + // Belt-and-suspenders: physics must only be integrated on the authority (host). + // NetworkTransform/NetworkRigidbody normally kinematic-lock client copies for us, + // but if this capsule is used with a bare NetworkTransform we make doubly sure the + // non-server copies never fight the replicated transform with their own gravity. + if (!IsServer) + rb.isKinematic = true; + } + + // ------------------------------------------------------------------------------------- + // OWNING CLIENT: read input every frame and ship it to the host. + // We sample in Update (once per rendered frame — cheap, responsive) and send the RPC. + // The host applies it on its own FixedUpdate clock, so exact send cadence doesn't matter. + // ------------------------------------------------------------------------------------- + void Update() + { + // Only the player who OWNS this capsule may drive it. Everyone else (including the + // host, for capsules it doesn't own) skips input entirely — IsOwner is the gate. + if (!IsOwner) return; + + // Throwaway-prototype input: legacy Input axes are perfectly fine here. WASD / arrows + // / left stick map to Horizontal + Vertical out of the box. (The project ships the new + // Input System, but its compatibility mode feeds these same axes, so this Just Works + // for M1 without authoring an action asset.) + float strafe = Input.GetAxisRaw("Horizontal"); // A/D, left-stick X + float fwd = Input.GetAxisRaw("Vertical"); // W/S, left-stick Y + + Vector2 input = new Vector2(strafe, fwd); + // Normalize so diagonal input isn't ~1.4x faster; keep magnitude for analog sticks. + if (input.sqrMagnitude > 1f) input.Normalize(); + + // Fire the input up to the server. Sending every frame (even zero) is fine for a + // handful of M1 players — it keeps the host's cache fresh and lets it damp to a stop. + SubmitMoveInputRpc(input); + } + + // ------------------------------------------------------------------------------------- + // CLIENT -> HOST input channel. [Rpc(SendTo.Server)] is the NGO 6.x universal RPC form; + // the method name MUST end in "Rpc". RequireOwnership defaults to true, which is exactly + // what we want: only this capsule's owner may push input into it. + // ------------------------------------------------------------------------------------- + [Rpc(SendTo.Server)] + void SubmitMoveInputRpc(Vector2 input, RpcParams _ = default) + { + // Runs on the host. Just cache — never apply force here. All physics happens in the + // host's FixedUpdate so it's on the fixed timestep the Rigidbody is integrated on. + cachedInput = input; + } + + // ------------------------------------------------------------------------------------- + // HOST-ONLY physics. Guarded by IsServer so client copies never integrate — they only + // receive the replicated NetworkTransform. Everything here runs on the fixed timestep. + // ------------------------------------------------------------------------------------- + void FixedUpdate() + { + if (!IsServer) return; + + // Turn the cached 2D input into a world-space push on the XZ plane. We drive in world + // axes (not capsule-local) so a capsule with no meaningful facing still moves intuitively + // relative to the arena — good enough for the M1 "does it replicate?" test. + Vector3 dir = new Vector3(cachedInput.x, 0f, cachedInput.y); + + if (dir.sqrMagnitude > 0.0001f) + { + rb.AddForce(dir * moveForce, ForceMode.Force); + } + else + { + // No input: bleed off horizontal drift so the capsule settles. Cheap manual damping + // on the planar velocity only (leave gravity on Y alone). + Vector3 v = rb.linearVelocity; // Unity 6: .linearVelocity (was .velocity) + Vector3 planar = new Vector3(v.x, 0f, v.z); + planar = Vector3.MoveTowards(planar, Vector3.zero, idleDamping * Time.fixedDeltaTime); + rb.linearVelocity = new Vector3(planar.x, v.y, planar.z); + } + + // Clamp planar speed so nobody launches the capsule out of the arena. + Vector3 vel = rb.linearVelocity; + Vector3 flat = new Vector3(vel.x, 0f, vel.z); + if (flat.magnitude > maxSpeed) + { + flat = flat.normalized * maxSpeed; + rb.linearVelocity = new Vector3(flat.x, vel.y, flat.z); + } + } + } +} diff --git a/Assets/Scripts/Net/PlayerDriver.cs.meta b/Assets/Scripts/Net/PlayerDriver.cs.meta new file mode 100644 index 0000000..8867d96 --- /dev/null +++ b/Assets/Scripts/Net/PlayerDriver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3b2fa682cfb9874e8f27a02c4e9bf61 \ No newline at end of file diff --git a/Packages/manifest.json b/Packages/manifest.json index 8445ccd..5c7a31f 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -7,7 +7,9 @@ "com.unity.ide.visualstudio": "2.0.26", "com.unity.inputsystem": "1.19.0", "com.unity.multiplayer.center": "1.0.1", + "com.unity.netcode.gameobjects": "2.13.0", "com.unity.render-pipelines.universal": "17.5.0", + "com.unity.services.multiplayer": "2.2.4", "com.unity.test-framework": "1.7.0", "com.unity.timeline": "1.8.12", "com.unity.ugui": "2.5.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index d32be9c..37a06a9 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -45,7 +45,7 @@ }, "com.unity.collections": { "version": "6.5.0", - "depth": 2, + "depth": 1, "source": "builtin", "dependencies": { "com.unity.burst": "1.8.25", @@ -89,7 +89,7 @@ }, "com.unity.mathematics": { "version": "1.4.0", - "depth": 3, + "depth": 2, "source": "builtin", "dependencies": {} }, @@ -101,9 +101,19 @@ "com.unity.modules.uielements": "1.0.0" } }, + "com.unity.netcode.gameobjects": { + "version": "2.13.0", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.transport": "2.6.0", + "com.unity.nuget.mono-cecil": "1.11.4" + }, + "url": "https://packages.unity.com" + }, "com.unity.nuget.mono-cecil": { "version": "1.11.6", - "depth": 3, + "depth": 1, "source": "registry", "dependencies": {}, "url": "https://packages.unity.com" @@ -151,6 +161,87 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.services.authentication": { + "version": "3.7.1", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.ugui": "1.0.0", + "com.unity.services.core": "1.18.0", + "com.unity.nuget.newtonsoft-json": "3.2.2", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.core": { + "version": "1.18.0", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.modules.androidjni": "1.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.2", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.deployment": { + "version": "1.7.2", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.services.core": "1.15.1", + "com.unity.services.deployment.api": "1.1.2" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.deployment.api": { + "version": "1.1.3", + "depth": 2, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.services.multiplayer": { + "version": "2.2.4", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.transport": "2.6.0", + "com.unity.collections": "2.2.1", + "com.unity.services.qos": "1.4.1", + "com.unity.services.core": "1.16.0", + "com.unity.services.wire": "1.4.4", + "com.unity.services.deployment": "1.7.1", + "com.unity.nuget.newtonsoft-json": "3.2.2", + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.services.authentication": "3.6.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.qos": { + "version": "1.4.1", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.collections": "1.2.4", + "com.unity.services.core": "1.12.5", + "com.unity.nuget.newtonsoft-json": "3.0.2", + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.services.authentication": "3.5.2" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.wire": { + "version": "1.4.4", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.services.core": "1.12.5", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.services.authentication": "2.7.4" + }, + "url": "https://packages.unity.com" + }, "com.unity.shadergraph": { "version": "17.5.0", "depth": 1, @@ -172,7 +263,7 @@ }, "com.unity.test-framework.performance": { "version": "3.5.0", - "depth": 3, + "depth": 2, "source": "registry", "dependencies": { "com.unity.test-framework": "1.1.33", @@ -192,6 +283,16 @@ }, "url": "https://packages.unity.com" }, + "com.unity.transport": { + "version": "6.5.0", + "depth": 1, + "source": "builtin", + "dependencies": { + "com.unity.collections": "6.5.0", + "com.unity.burst": "1.8.24", + "com.unity.mathematics": "1.4.0" + } + }, "com.unity.ugui": { "version": "2.5.0", "depth": 0,