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) <noreply@anthropic.com>
This commit is contained in:
parent
04333223f4
commit
d723447e31
15 changed files with 1006 additions and 4 deletions
257
Assets/Scripts/Net/NetworkBootstrap.cs
Normal file
257
Assets/Scripts/Net/NetworkBootstrap.cs
Normal file
|
|
@ -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<UnityTransport>()
|
||||
: GetComponent<UnityTransport>();
|
||||
|
||||
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<string> 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<bool> 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();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Net/NetworkBootstrap.cs.meta
Normal file
2
Assets/Scripts/Net/NetworkBootstrap.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7d25ff1aef9852a4b928785411bb8612
|
||||
152
Assets/Scripts/Net/PlayerDriver.cs
Normal file
152
Assets/Scripts/Net/PlayerDriver.cs
Normal file
|
|
@ -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<Rigidbody>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Net/PlayerDriver.cs.meta
Normal file
2
Assets/Scripts/Net/PlayerDriver.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b3b2fa682cfb9874e8f27a02c4e9bf61
|
||||
Loading…
Add table
Add a link
Reference in a new issue