From 56caa0edd829e5472d5e538ea7ecf9c0260d4aae Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sat, 27 Jun 2026 17:01:43 +0200 Subject: [PATCH] Add Hunter/Hider role selection on connect with role-scoped modes Roles are chosen in the bootstrap dev panel before Host/Client. NGO ConnectionApproval is enabled at runtime and maps the role payload to the matching prefab variant per connection (Hunter/Hider), so each peer spawns the correct avatar including the host. - PlayerRole.cs (new): single source of truth enum { Hider, Hunter }. - NetworkBootstrap.cs: role toggle, ConnectionApproval + role payload, server ApprovalCheck mapping role -> PrefabIdHash. - NetworkPlayerSetup.cs: serialized PlayerRole field + Role property. - GameModeManager.cs: BuildModes(role) - Hunter -> [First Person, Paint], Hider -> [Third Person, Paint, Free Cam]; binds role from spawned avatar. - Hunter.prefab: role = Hunter override. - SampleScene.unity: NetworkBootstrap prefab references wired. Also includes user inspector scale tuning (Hunter 2.5->2.4, Hider 0.5->0.7). Co-Authored-By: Claude Opus 4.8 --- Assets/Prefabs/Hider.prefab | 8 +-- Assets/Prefabs/Hunter.prefab | 12 +++-- Assets/Scenes/SampleScene.unity | 3 ++ Assets/Scripts/GameModeManager.cs | 66 ++++++++++++++---------- Assets/Scripts/NetworkBootstrap.cs | 76 +++++++++++++++++++++++----- Assets/Scripts/NetworkPlayerSetup.cs | 8 +++ Assets/Scripts/PlayerRole.cs | 10 ++++ Assets/Scripts/PlayerRole.cs.meta | 2 + 8 files changed, 138 insertions(+), 47 deletions(-) create mode 100644 Assets/Scripts/PlayerRole.cs create mode 100644 Assets/Scripts/PlayerRole.cs.meta diff --git a/Assets/Prefabs/Hider.prefab b/Assets/Prefabs/Hider.prefab index c340875..28684b9 100644 --- a/Assets/Prefabs/Hider.prefab +++ b/Assets/Prefabs/Hider.prefab @@ -10,7 +10,7 @@ PrefabInstance: m_Modifications: - target: {fileID: 273411532115212066, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: scale - value: 0.5 + value: 0.7 objectReference: {fileID: 0} - target: {fileID: 1557561356852236266, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: GlobalObjectIdHash @@ -22,15 +22,15 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.x - value: 0.5 + value: 0.7 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.y - value: 0.5 + value: 0.7 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.z - value: 0.5 + value: 0.7 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalPosition.x diff --git a/Assets/Prefabs/Hunter.prefab b/Assets/Prefabs/Hunter.prefab index e2fd8ec..91589cb 100644 --- a/Assets/Prefabs/Hunter.prefab +++ b/Assets/Prefabs/Hunter.prefab @@ -10,7 +10,7 @@ PrefabInstance: m_Modifications: - target: {fileID: 273411532115212066, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: scale - value: 2.5 + value: 2.4 objectReference: {fileID: 0} - target: {fileID: 1035623159758896108, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: sprintSpeedMultiplier @@ -26,15 +26,15 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.x - value: 2.5 + value: 2.4 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.y - value: 2.5 + value: 2.4 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalScale.z - value: 2.5 + value: 2.4 objectReference: {fileID: 0} - target: {fileID: 6997860362608943113, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} propertyPath: m_LocalPosition.x @@ -76,6 +76,10 @@ PrefabInstance: propertyPath: m_LocalEulerAnglesHint.z value: 0 objectReference: {fileID: 0} + - target: {fileID: 8574096554986372233, guid: 97017ed3bb40aff4e885440d9a7336ee, type: 3} + propertyPath: role + value: 1 + objectReference: {fileID: 0} m_RemovedComponents: [] m_RemovedGameObjects: [] m_AddedGameObjects: [] diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index 7002168..06c81c5 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -1251,6 +1251,9 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::NetworkBootstrap showGui: 1 enableHotkeys: 1 + hunterPrefab: {fileID: 3510981371713521867, guid: cf153a3aea3d5da4f9d4cdbff0be8180, type: 3} + hiderPrefab: {fileID: 5508335275054701003, guid: 6b0b58e8352625343951db00beb8cb0a, type: 3} + selectedRole: 0 --- !u!114 &284910515 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs index 0f1a3b2..5c6f7ad 100644 --- a/Assets/Scripts/GameModeManager.cs +++ b/Assets/Scripts/GameModeManager.cs @@ -6,9 +6,10 @@ using UnityEngine.InputSystem; #endif /// -/// Switches the game between high-level modes (Player Control, Paint, Free Cam). The switch -/// key (Tab) cycles to the next registered mode. Register additional modes in -/// . Attach to a single, persistent scene GameObject. +/// Switches the game between high-level modes. The switch key (Tab) cycles to the next registered +/// mode. The mode set is scoped to the local avatar's when it spawns +/// (Hunter: First Person + Paint; Hider: Player Control + Paint + Free Cam); a non-networked scene +/// uses the full set. Attach to a single, persistent scene GameObject. /// [DisallowMultipleComponent] public class GameModeManager : MonoBehaviour @@ -86,30 +87,40 @@ public class GameModeManager : MonoBehaviour SwitchTo(Mathf.Clamp(startModeIndex, 0, _modes.Count - 1)); } - /// Registers all available modes. Add new modes here. - private void BuildModes() + /// Registers the modes for the current context. Without a role (no networked avatar yet, + /// or a non-networked scene) the full set is built from whichever rigs are present. With a role the + /// set is scoped to that side and ordered so index 0 is its intended start mode. Add new modes here. + private void BuildModes() => BuildModes(null); + + private void BuildModes(PlayerRole? role) { _modes.Clear(); - // Player Control only joins the Tab cycle when a gravity player is present, so a scene - // running only the insect walker doesn't trap input in a dead, player-freezing mode. - if (player) - _modes.Add(new PlayerControlMode()); + switch (role) + { + case PlayerRole.Hunter: + // Hunter: first person + paint. Index 0 (First Person, if its rig exists) is the start mode. + if (firstPerson) _modes.Add(new FirstPersonMode()); + _modes.Add(new PaintMode()); + break; - _modes.Add(new PaintMode()); + case PlayerRole.Hider: + // Hider: third person (player control) + paint + free cam. Index 0 (Player Control) is the start. + if (player) _modes.Add(new PlayerControlMode()); + _modes.Add(new PaintMode()); + if (freeCam) _modes.Add(new FreeCamMode()); + break; - // Free Cam only joins the cycle when a freecam rig is present, so scenes without one are unchanged. - if (freeCam) - _modes.Add(new FreeCamMode()); - - // First Person only joins the cycle when a first-person rig is present, so scenes without one are unchanged. - if (firstPerson) - _modes.Add(new FirstPersonMode()); - - // Insect mode only joins the Tab cycle when a surface walker is present, so a scene - // without one behaves exactly as before (no dead, player-freezing mode in the cycle). - //if (insect) - // _modes.Add(new InsectMode()); + default: + // No role: legacy full set, gated on whichever rigs exist. Player Control only joins when a + // gravity player is present (player-less pre-spawn stays in Paint, not a dead frozen mode); + // Free Cam / First Person only when their rigs exist, so non-networked scenes are unchanged. + if (player) _modes.Add(new PlayerControlMode()); + _modes.Add(new PaintMode()); + if (freeCam) _modes.Add(new FreeCamMode()); + if (firstPerson) _modes.Add(new FirstPersonMode()); + break; + } foreach (var m in _modes) m.Bind(this); @@ -142,13 +153,16 @@ public class GameModeManager : MonoBehaviour if (firstPerson) firstPerson.SetPlayer(localPlayer); - // Tear down the pre-spawn (player-less) mode and rebuild with Player Control available. + // Tear down the pre-spawn (player-less) mode and rebuild scoped to this avatar's role. ActiveMode?.Exit(); _activeIndex = -1; - BuildModes(); - int idx = _modes.FindIndex(m => m is PlayerControlMode); - SwitchTo(idx >= 0 ? idx : Mathf.Clamp(startModeIndex, 0, _modes.Count - 1)); + var setup = localPlayer.GetComponent(); + PlayerRole role = setup ? setup.Role : PlayerRole.Hider; + BuildModes(role); + + // Each role list is ordered so index 0 is its start mode (Hunter -> First Person, Hider -> Player Control). + SwitchTo(0); } private void Update() diff --git a/Assets/Scripts/NetworkBootstrap.cs b/Assets/Scripts/NetworkBootstrap.cs index 6ec9573..3423d68 100644 --- a/Assets/Scripts/NetworkBootstrap.cs +++ b/Assets/Scripts/NetworkBootstrap.cs @@ -6,10 +6,11 @@ using UnityEngine.InputSystem; /// /// Dev-only connection harness for local multiplayer testing across the junction-mirror clones: -/// one instance clicks Host, the others click Client (loopback 127.0.0.1 via the configured -/// UnityTransport). Draws a small on-screen panel and exposes hotkeys (H = Host, C = Client, -/// J = Server, K = Shutdown). Purely a testing scaffold — replace with real lobby/menu UI later. -/// Attach to the same GameObject as the NetworkManager. +/// pick a role (Hunter/Hider) in the panel, then one instance clicks Host and the others click +/// Client (loopback 127.0.0.1 via the configured UnityTransport). The chosen role rides along as the +/// connection-approval payload, so the server spawns that role's prefab variant for the connection. +/// Hotkeys: H = Host, C = Client, J = Server, K = Shutdown. Purely a testing scaffold - replace with +/// real lobby/menu UI later. Attach to the same GameObject as the NetworkManager. /// [RequireComponent(typeof(NetworkManager))] [DisallowMultipleComponent] @@ -21,9 +22,51 @@ public class NetworkBootstrap : MonoBehaviour [Tooltip("Allow the H/C/J/K keyboard shortcuts.")] public bool enableHotkeys = true; + [Header("Role prefabs")] + [Tooltip("Prefab spawned for a connection that picked Hunter. Must be registered as a network prefab.")] + [SerializeField] private GameObject hunterPrefab; + + [Tooltip("Prefab spawned for a connection that picked Hider. Must be registered as a network prefab.")] + [SerializeField] private GameObject hiderPrefab; + + [Tooltip("Role this instance connects as. Toggle it in the dev panel before hosting/joining.")] + [SerializeField] private PlayerRole selectedRole = PlayerRole.Hider; + private NetworkManager _net; - private void Awake() => _net = GetComponent(); + private void Awake() + { + _net = GetComponent(); + + // Per-connection prefab selection needs connection approval: the callback maps the role byte + // the joiner sent to the matching prefab hash. Enabled here so the scene asset needn't carry it. + _net.NetworkConfig.ConnectionApproval = true; + _net.ConnectionApprovalCallback = ApprovalCheck; + } + + /// Stamps the chosen role into the connection payload, then starts the given mode. + /// StartHost runs approval on its own client too, so the host's role is read from this same payload. + private void StartAs(System.Func start) + { + _net.NetworkConfig.ConnectionData = new[] { (byte)selectedRole }; + start(); + } + + private void ApprovalCheck(NetworkManager.ConnectionApprovalRequest request, + NetworkManager.ConnectionApprovalResponse response) + { + PlayerRole role = (request.Payload != null && request.Payload.Length > 0) + ? (PlayerRole)request.Payload[0] + : PlayerRole.Hider; + + GameObject prefab = role == PlayerRole.Hunter ? hunterPrefab : hiderPrefab; + var netObj = prefab ? prefab.GetComponent() : null; + + response.Approved = true; + response.CreatePlayerObject = true; + // Null prefab/ref falls back to the NetworkManager's default PlayerPrefab. + if (netObj) response.PlayerPrefabHash = netObj.PrefabIdHash; + } private void Update() { @@ -33,9 +76,9 @@ public class NetworkBootstrap : MonoBehaviour if (kb == null || _net == null) return; if (!_net.IsListening) { - if (kb.hKey.wasPressedThisFrame) _net.StartHost(); - else if (kb.cKey.wasPressedThisFrame) _net.StartClient(); - else if (kb.jKey.wasPressedThisFrame) _net.StartServer(); + if (kb.hKey.wasPressedThisFrame) StartAs(_net.StartHost); + else if (kb.cKey.wasPressedThisFrame) StartAs(_net.StartClient); + else if (kb.jKey.wasPressedThisFrame) StartAs(_net.StartServer); } else if (kb.kKey.wasPressedThisFrame) { @@ -48,18 +91,25 @@ public class NetworkBootstrap : MonoBehaviour { if (!showGui || _net == null) return; - GUILayout.BeginArea(new Rect(10, 10, 220, 160), GUI.skin.box); + GUILayout.BeginArea(new Rect(10, 10, 220, 200), GUI.skin.box); if (!_net.IsListening) { GUILayout.Label("Netcode (dev)"); - if (GUILayout.Button("Host (H)")) _net.StartHost(); - if (GUILayout.Button("Client (C)")) _net.StartClient(); - if (GUILayout.Button("Server (J)")) _net.StartServer(); + + GUILayout.BeginHorizontal(); + GUILayout.Label("Role:", GUILayout.Width(38)); + if (GUILayout.Toggle(selectedRole == PlayerRole.Hider, "Hider", GUI.skin.button)) selectedRole = PlayerRole.Hider; + if (GUILayout.Toggle(selectedRole == PlayerRole.Hunter, "Hunter", GUI.skin.button)) selectedRole = PlayerRole.Hunter; + GUILayout.EndHorizontal(); + + if (GUILayout.Button("Host (H)")) StartAs(_net.StartHost); + if (GUILayout.Button("Client (C)")) StartAs(_net.StartClient); + if (GUILayout.Button("Server (J)")) StartAs(_net.StartServer); } else { string role = _net.IsHost ? "Host" : _net.IsServer ? "Server" : "Client"; - GUILayout.Label($"Role: {role}"); + GUILayout.Label($"Role: {role} ({selectedRole})"); GUILayout.Label($"ClientId: {_net.LocalClientId}"); GUILayout.Label($"Clients: {(_net.IsServer ? _net.ConnectedClients.Count : (_net.IsConnectedClient ? 1 : 0))}"); if (GUILayout.Button("Shutdown (K)")) _net.Shutdown(); diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index ab10d8c..1923119 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -21,6 +21,14 @@ public class NetworkPlayerSetup : NetworkBehaviour [Tooltip("Slots per ring before the radius steps outward, so spacing stays roughly constant as more join.")] [SerializeField] private int spawnRingSlots = 8; + [Header("Role")] + [Tooltip("Which side this avatar plays. Baked per prefab variant (Hider on the base/Hider prefab, " + + "Hunter on the Hunter prefab); GameModeManager reads it on spawn to build this role's mode set.")] + [SerializeField] private PlayerRole role = PlayerRole.Hider; + + /// The side this avatar plays (Hunter/Hider), baked into the prefab variant. + public PlayerRole Role => role; + public override void OnNetworkSpawn() { var controller = GetComponent(); diff --git a/Assets/Scripts/PlayerRole.cs b/Assets/Scripts/PlayerRole.cs new file mode 100644 index 0000000..421c8b8 --- /dev/null +++ b/Assets/Scripts/PlayerRole.cs @@ -0,0 +1,10 @@ +/// +/// Which side a spawned avatar plays. Picked at connect time (NetworkBootstrap) and sent as the +/// connection-approval payload so the server spawns the matching prefab variant, then baked into +/// that prefab and read back by GameModeManager to build the role's mode set. +/// +public enum PlayerRole +{ + Hider = 0, + Hunter = 1, +} diff --git a/Assets/Scripts/PlayerRole.cs.meta b/Assets/Scripts/PlayerRole.cs.meta new file mode 100644 index 0000000..132c444 --- /dev/null +++ b/Assets/Scripts/PlayerRole.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c3a46956c5cf5446ab2698d8da6b2d8 \ No newline at end of file