Drive the match through three server-authoritative phases (hide, seek, resolution) with per-phase durations, broadcasting the active phase and countdown to clients over the existing named-message infrastructure so MatchManager stays a plain MonoBehaviour and Main keeps zero in-scene network objects. During hide, hider meshes are culled on hunter clients only (each client computes visibility locally from role, phase and found state). During seek, hunters shoot a single raycast from the first-person camera centre (left click, cooldown-gated); a validated hit marks the hider found, which hides its mesh for everyone and binds the owner to spectator freecam. Seek ends early once every hider is found, then resolution runs before returning to the lobby. Force AlwaysAnimate on remote replicas so the animator keeps writing bone transforms while the body is hidden or off-screen. The Paintman animator is authored CullUpdateTransforms, so a culled renderer froze the pose on the rest pose and the skinned collider baked that frozen pose, landing replicated paint stamps on the wrong body location and leaving the painting desynced after hiders were shown again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
326 lines
16 KiB
C#
326 lines
16 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;
|
|
|
|
[Tooltip("Role-specific spawn areas, tried FIRST by an owner based on its Role (Hunter uses " +
|
|
"hunterSpawnAreaTag, Hider uses hiderSpawnAreaTag). If a loaded scene holds a GameObject with the " +
|
|
"matching tag, the owner spawns at a random point inside its collider. If this avatar's role area " +
|
|
"is absent it falls back to spawnAreaTag, then to the client-id ring. Empty disables the role lookup.")]
|
|
[SerializeField] private string hunterSpawnAreaTag = "HunterSpawnArea";
|
|
[SerializeField] private string hiderSpawnAreaTag = "HiderSpawnArea";
|
|
|
|
[Tooltip("Shared fallback spawn area, used when this avatar's role-specific area (hunter/hiderSpawnAreaTag) " +
|
|
"is not present in any loaded scene. If a loaded scene holds a GameObject with this tag, owners " +
|
|
"spawn at a RANDOM point inside its collider instead of the client-id ring (random scatter already " +
|
|
"prevents the stack-up the ring guards against). Empty disables the lookup. The area collider " +
|
|
"should be convex (a convex MeshCollider or a primitive) so the point is constrained to its actual " +
|
|
"shape, not just its bounding box; a non-convex collider falls back to the bounding box.")]
|
|
[SerializeField] private string spawnAreaTag = "SpawnArea";
|
|
|
|
[Tooltip("Metres the spawn is lifted above the spawn area's floor (collider bounds bottom) so the " +
|
|
"CharacterController doesn't start embedded; gravity then settles it. Mirrors the lobby/match " +
|
|
"spawn-centre lift.")]
|
|
[SerializeField] private float spawnAreaGroundLift = 1f;
|
|
|
|
// Tries to land a random sample inside the convex footprint before falling back to the bounds centre.
|
|
// The footprint is typically a chunk of its AABB, so a handful of tries hits the inside with near-certainty.
|
|
private const int SpawnAreaSampleAttempts = 24;
|
|
|
|
[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;
|
|
|
|
// Server-authoritative "this hider has been shot/found". Server-write (a hunter's hit is validated on
|
|
// the server, not by the hider's owner); read by everyone. When it flips true the body is hidden on
|
|
// every client and the owning client drops into free-cam-only spectating. Stays false on hunters/lobby.
|
|
private readonly NetworkVariable<bool> _isFound =
|
|
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
|
|
|
/// <summary>True once a hunter has found this hider (replicated). Read by the shoot raycast to skip a
|
|
/// target that is already out of play.</summary>
|
|
public bool IsFound => _isFound.Value;
|
|
|
|
private Renderer[] _bodyRenderers; // skinned body mesh(es), toggled by the per-client visibility pass
|
|
private bool _visibilityApplied;
|
|
private bool _lastVisible = true;
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
_bodyRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
|
_isFound.OnValueChanged += OnFoundChanged;
|
|
if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found
|
|
|
|
var controller = GetComponent<PlayerCharacterController>();
|
|
|
|
if (IsOwner)
|
|
{
|
|
PlaceAtSpawn();
|
|
|
|
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.
|
|
|
|
// The rig must keep evaluating even while this body is HIDDEN on the local client. During the hide
|
|
// phase a hider's body is hidden on hunter clients (UpdateVisibility sets renderer.enabled = false),
|
|
// and a remote can also sit off-screen. The avatar's Animator is authored as CullUpdateTransforms,
|
|
// which STOPS writing the bone transforms whenever its renderer isn't visible. PlayerPoseController
|
|
// drives the rig through a PlayableGraph on this Animator and resets un-keyed bones to rest every
|
|
// frame, so a culled Animator leaves the rig frozen at the rest/T-pose. SkinnedColliderBaker then
|
|
// bakes that wrong pose, and any paint stamp replicated during the hidden window lands on the wrong
|
|
// body part - a desync that persists once the body is shown again. Forcing AlwaysAnimate keeps the
|
|
// pose (and thus the baked mesh paint replay rasterizes) correct regardless of visibility. Remote
|
|
// replicas only: an owner is never hidden from its own camera.
|
|
foreach (var anim in GetComponentsInChildren<Animator>(true))
|
|
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
|
|
}
|
|
|
|
public override void OnNetworkDespawn()
|
|
{
|
|
_isFound.OnValueChanged -= OnFoundChanged;
|
|
}
|
|
|
|
/// <summary>Server-only: flag this hider as found by a hunter. Replicates to every client (the body is
|
|
/// hidden for all; the owner drops into free-cam-only spectating). Idempotent.</summary>
|
|
public void ServerMarkFound()
|
|
{
|
|
if (!IsServer || _isFound.Value) return;
|
|
_isFound.Value = true; // OnValueChanged -> ApplyFound on every machine, including this server/host
|
|
}
|
|
|
|
private void OnFoundChanged(bool previous, bool current)
|
|
{
|
|
if (current) ApplyFound();
|
|
}
|
|
|
|
// A found hider leaves play: the per-client visibility pass hides its body, its baked collider is dropped
|
|
// (so the invisible body neither blocks nor can be hit again), and on the owning client it switches to
|
|
// free-cam-only so the player can still watch the rest of the match. Idempotent.
|
|
private void ApplyFound()
|
|
{
|
|
var baker = GetComponentInChildren<SkinnedColliderBaker>(true);
|
|
if (baker) baker.enabled = false;
|
|
var meshCol = GetComponentInChildren<MeshCollider>(true);
|
|
if (meshCol) meshCol.enabled = false;
|
|
|
|
if (IsOwner)
|
|
{
|
|
var controller = GetComponent<PlayerCharacterController>();
|
|
if (controller) controller.enabled = false;
|
|
var cc = GetComponent<CharacterController>();
|
|
if (cc) cc.enabled = false;
|
|
|
|
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
|
|
if (manager) manager.BindSpectator();
|
|
}
|
|
}
|
|
|
|
private void Update() => UpdateVisibility();
|
|
|
|
// Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL
|
|
// viewer's perspective. A found hider is hidden for everyone; during HIDE, hiders are hidden on hunter
|
|
// clients only (the spec - hunters can't see hiders yet). The local owner's own body is left alone unless
|
|
// found, so this never fights the FirstPersonController's own-body self-hide.
|
|
private void UpdateVisibility()
|
|
{
|
|
bool found = _isFound.Value;
|
|
if (IsOwner && !found) return;
|
|
|
|
bool visible = ComputeVisible(found);
|
|
if (_visibilityApplied && visible == _lastVisible) return;
|
|
|
|
if (_bodyRenderers != null)
|
|
foreach (var r in _bodyRenderers)
|
|
if (r) r.enabled = visible;
|
|
|
|
_lastVisible = visible;
|
|
_visibilityApplied = true;
|
|
}
|
|
|
|
private bool ComputeVisible(bool found)
|
|
{
|
|
if (found) return false;
|
|
|
|
var match = MatchManager.Instance;
|
|
if (match && match.MatchActive
|
|
&& match.LocalSubPhase == MatchManager.MatchSubPhase.Hide
|
|
&& role == PlayerRole.Hider
|
|
&& LocalViewerIsHunter())
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
|
|
private static bool LocalViewerIsHunter()
|
|
{
|
|
var nm = NetworkManager.Singleton;
|
|
if (!nm || nm.LocalClient == null) return false;
|
|
var po = nm.LocalClient.PlayerObject;
|
|
if (!po) return false;
|
|
var setup = po.GetComponent<NetworkPlayerSetup>();
|
|
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Places the freshly-spawned owner. Prefers this avatar's role-specific spawn area (Hunter/Hider tag),
|
|
/// then the shared <see cref="spawnAreaTag"/> area; if a matching area is loaded the owner drops at a
|
|
/// random point inside that collider (designer-authored spawn region). With no area present it falls back
|
|
/// to the deterministic client-id ring. Owner-authoritative either way - the NetworkTransform replicates
|
|
/// the result, and PlayerCharacterController captures it on its first Update as the kill-plane rescue target.
|
|
/// </summary>
|
|
private void PlaceAtSpawn()
|
|
{
|
|
if (TryGetSpawnArea(out Collider area))
|
|
PlaceRandomlyInArea(area);
|
|
else
|
|
PlaceOnSpawnRing();
|
|
}
|
|
|
|
// Resolves the spawn-area collider for this avatar: the Role-specific area (Hunter/Hider) takes priority,
|
|
// then the shared spawnAreaTag area; a miss on both leaves it to the caller's client-id ring fallback.
|
|
private bool TryGetSpawnArea(out Collider area)
|
|
{
|
|
string roleTag = role == PlayerRole.Hunter ? hunterSpawnAreaTag : hiderSpawnAreaTag;
|
|
return TryFindAreaByTag(roleTag, out area) || TryFindAreaByTag(spawnAreaTag, out area);
|
|
}
|
|
|
|
// Finds a collider tagged 'tag' in any loaded scene, if present. FindWithTag throws when the tag is
|
|
// undefined (e.g. stripped from a build), so it is guarded - a missing/undefined tag just means "not here".
|
|
private static bool TryFindAreaByTag(string tag, out Collider area)
|
|
{
|
|
area = null;
|
|
if (string.IsNullOrEmpty(tag)) return false;
|
|
|
|
GameObject go;
|
|
try { go = GameObject.FindWithTag(tag); }
|
|
catch (UnityException) { return false; }
|
|
|
|
if (!go) return false;
|
|
area = go.GetComponent<Collider>();
|
|
return area;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops the owner at a random point inside the spawn-area collider. X/Z are sampled within the
|
|
/// collider's footprint (rejection-sampled against the convex shape so the irregular outline is honoured,
|
|
/// not just its bounding box); Y sits a touch above the area floor so gravity settles the avatar onto the
|
|
/// ground. The CharacterController is toggled around the move because it otherwise overrides direct
|
|
/// transform writes (same pattern as <see cref="PlaceOnSpawnRing"/>).
|
|
/// </summary>
|
|
private void PlaceRandomlyInArea(Collider area)
|
|
{
|
|
Bounds b = area.bounds;
|
|
bool convex = SupportsClosestPoint(area);
|
|
|
|
Vector3 pick = b.center;
|
|
for (int i = 0; i < SpawnAreaSampleAttempts; i++)
|
|
{
|
|
Vector3 p = new Vector3(Random.Range(b.min.x, b.max.x), b.center.y, Random.Range(b.min.z, b.max.z));
|
|
// ClosestPoint returns the input unchanged when it lies inside a convex collider, so an unchanged
|
|
// sample is a point within the real (possibly non-box) footprint.
|
|
if (!convex || (area.ClosestPoint(p) - p).sqrMagnitude <= 1e-6f)
|
|
{
|
|
pick = p;
|
|
break;
|
|
}
|
|
}
|
|
|
|
Vector3 target = new Vector3(pick.x, b.min.y + spawnAreaGroundLift, pick.z);
|
|
|
|
var cc = GetComponent<CharacterController>();
|
|
bool reenable = cc && cc.enabled;
|
|
if (cc) cc.enabled = false;
|
|
transform.position = target;
|
|
if (cc) cc.enabled = reenable;
|
|
}
|
|
|
|
// Collider.ClosestPoint only constrains to the real shape for primitives and CONVEX mesh colliders;
|
|
// a non-convex mesh returns the input unchanged (no containment info), so we fall back to its AABB.
|
|
private static bool SupportsClosestPoint(Collider c)
|
|
{
|
|
return !(c is MeshCollider mc) || mc.convex;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|