Replace the connection-approval role selection with a server-authoritative match flow built on NGO additive scene management: - Scenes: Main is the persistent foundation (NetworkManager, GameModeManager, cameras, HUD); Lobby and Test Map load/unload additively. Add Lobby + Test Map to the build settings; remove the obsolete SampleScene. - MatchManager (plain MonoBehaviour singleton): loads Lobby on server start, StartMatchTrigger lets the host start the match, rolls one random Hunter (rest Hider), swaps Lobby -> Test Map (strictly serialized per NGO), respawns each player as its role variant. Forces additive client sync mode so clients keep Main instead of single-loading it (fixes "Destination scene is not valid"). - Server-driven spawning: null out NetworkConfig.PlayerPrefab at runtime to disable auto-spawn, then spawn players after the lobby floor exists (fixes falling through the floor) with double-spawn and transition guards. - StartMatchTrigger: server-gated OnTriggerEnter, only the host's own player starts the match. - NetworkBootstrap: drop role selection / connection approval; everyone spawns as the neutral Player prefab. Host/Client/Server/Shutdown buttons stay. - NetworkPlayerSetup.ScopedModes flag (baked on Hunter/Hider prefabs): lobby avatar gets the full mode set, match variants get the role-scoped set. - GameModeManager.BindLocalPlayer reads ScopedModes and rebinds the painter unconditionally (fixes stale painter binding on respawn -> brush painting in every mode). - MeshPainter.PaintingEnabled defaults to false (unbound/pre-spawn/remote never paints); PaintHud visibility now also gates on a bound painter (fixes the HUD showing at startup before any avatar exists). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
5.3 KiB
C#
104 lines
5.3 KiB
C#
using Unity.Netcode;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Per-avatar network glue on the spawned player. The local owner binds the scene singletons
|
|
/// (GameModeManager, cameras) to itself via <see cref="GameModeManager.BindLocalPlayer"/>; remote
|
|
/// avatars stop simulating so their transform is driven purely by the NetworkTransform replication.
|
|
/// Position-sync milestone: remote avatars still show their mesh/idle pose, just no local control.
|
|
/// </summary>
|
|
[RequireComponent(typeof(PlayerCharacterController))]
|
|
[DisallowMultipleComponent]
|
|
public class NetworkPlayerSetup : NetworkBehaviour
|
|
{
|
|
[Header("Spawn placement")]
|
|
[Tooltip("All avatars spawn from the same prefab position, so without an offset they stack and shove " +
|
|
"each other on connect. The owner places itself on a ring keyed by its client id (owner-" +
|
|
"authoritative NetworkTransform replicates it). Id 0 (host) stays at the origin; later joiners " +
|
|
"fan out. Spacing between ring slots, in world units. 0 disables placement.")]
|
|
[SerializeField] private float spawnRingRadius = 2.5f;
|
|
|
|
[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;
|
|
|
|
[Tooltip("False = lobby/neutral avatar (the base Player prefab) gets the FULL mode set; true = match " +
|
|
"avatar (Hunter/Hider variant) gets only its role's modes. Baked per prefab variant, so the " +
|
|
"mode set follows the spawned avatar with no replicated-phase timing race.")]
|
|
[SerializeField] private bool scopedModes = false;
|
|
|
|
/// <summary>The side this avatar plays (Hunter/Hider), baked into the prefab variant.</summary>
|
|
public PlayerRole Role => role;
|
|
|
|
/// <summary>True on the match role variants (scope the mode set to <see cref="Role"/>); false on the
|
|
/// neutral lobby Player prefab (full mode set). Baked per prefab variant.</summary>
|
|
public bool ScopedModes => scopedModes;
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
var controller = GetComponent<PlayerCharacterController>();
|
|
|
|
if (IsOwner)
|
|
{
|
|
PlaceOnSpawnRing();
|
|
|
|
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
|
|
if (manager)
|
|
manager.BindLocalPlayer(controller);
|
|
return;
|
|
}
|
|
|
|
// Remote avatar: NetworkTransform owns the transform here, so disable the local
|
|
// simulation (input/movement) and the CharacterController's own depenetration.
|
|
if (controller) controller.enabled = false;
|
|
var cc = GetComponent<CharacterController>();
|
|
if (cc) cc.enabled = false;
|
|
|
|
// Owner cleanup: the concealment meter is a per-frame skinned-mesh bake + voxelization +
|
|
// physics overlap whose result only feeds the LOCAL player (HUD / gameplay). A remote avatar's
|
|
// concealment is computed on its own owner's machine, so running it here is pure wasted CPU.
|
|
var concealment = GetComponentInChildren<ConcealmentMeter>(true);
|
|
if (concealment) concealment.enabled = false;
|
|
|
|
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes. MeshPainter's stamp replay
|
|
// (ApplyNetworkStamp -> DrawBrushGpu) rasterizes the baker's live, per-frame BakedMesh to map a
|
|
// 3D brush placement into UV space; freezing the bake would mis-map replicated paint onto any
|
|
// non-rest pose. Its bake is the necessary cost of correct remote paint, not wasted work.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Offsets the freshly-spawned owner onto a deterministic ring keyed by its client id so avatars
|
|
/// don't pile up at the shared prefab spawn point. Id 0 (the host) keeps the origin so single-player
|
|
/// "host alone" is unchanged; every later joiner takes the next ring slot, stepping the radius out
|
|
/// once a ring fills. Only X/Z are shifted (gravity settles Y), and the CharacterController is toggled
|
|
/// around the move because it otherwise overrides direct transform writes. Owner-authoritative, so the
|
|
/// NetworkTransform replicates the placement outward.
|
|
/// </summary>
|
|
private void PlaceOnSpawnRing()
|
|
{
|
|
if (spawnRingRadius <= 0f || spawnRingSlots < 1) return;
|
|
|
|
ulong id = OwnerClientId;
|
|
if (id == 0) return; // host stays centered
|
|
|
|
ulong k = id - 1;
|
|
int ring = (int)(k / (ulong)spawnRingSlots);
|
|
int slot = (int)(k % (ulong)spawnRingSlots);
|
|
|
|
float radius = spawnRingRadius * (ring + 1);
|
|
// Half-slot stagger per ring so outer slots don't line up radially with inner ones.
|
|
float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f;
|
|
Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius;
|
|
|
|
var cc = GetComponent<CharacterController>();
|
|
bool reenable = cc && cc.enabled;
|
|
if (cc) cc.enabled = false;
|
|
transform.position += offset;
|
|
if (cc) cc.enabled = reenable;
|
|
}
|
|
}
|