Introduce a second match mode alongside Classic. In Conversion mode hiders are invisible to each other (and to hunters during Hide), and a hunter hit converts the hider into a full hunter in place instead of sending them to the spectator freecam. The mode is replicated to all clients through the existing phase named-message byte; visibility is computed locally per client per mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
19 KiB
C#
358 lines
19 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)
|
|
{
|
|
// A Conversion-mode tag respawns the hider as this Hunter variant mid-Seek; keep the server-set
|
|
// position (it converts where it was tagged) by skipping the spawn-area/ring placement. Match-start
|
|
// variants spawn while the sub-phase is still None, so they DO place; end-match lobby avatars spawn
|
|
// after the sub-phase is cleared, so they place too. Only an in-place conversion lands here mid-Seek.
|
|
if (!SpawnedMidSeek())
|
|
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 (hunter bodies are always visible; only hider bodies are ever hidden). A found
|
|
// hider (Classic) is hidden for everyone. Otherwise it depends on the match MODE - see ComputeVisible.
|
|
// 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;
|
|
}
|
|
|
|
// Decides whether THIS avatar's body is rendered for the local viewer. Hunters and non-match avatars are
|
|
// always visible; a Classic-found hider is always hidden; Resolution reveals everyone. The rest is the
|
|
// hider-visibility matrix per mode:
|
|
// Classic - Hide: hidden from hunters only (hiders still see each other); Seek: visible to all.
|
|
// Conversion - Hide: hidden from EVERYONE (hiders don't see each other either); Seek: only hunters see
|
|
// hiders (so they can shoot them; hiders stay blind to each other).
|
|
private bool ComputeVisible(bool found)
|
|
{
|
|
if (found) return false; // Classic: a found hider is out of play, hidden for all
|
|
|
|
var match = MatchManager.Instance;
|
|
if (!match || !match.MatchActive) return true; // lobby / no match -> everything visible
|
|
if (role != PlayerRole.Hider) return true; // hunters are always visible
|
|
|
|
var phase = match.LocalSubPhase;
|
|
if (phase == MatchManager.MatchSubPhase.Resolution) return true; // all revealed at the end
|
|
|
|
if (match.LocalMatchMode == MatchManager.MatchMode.Conversion)
|
|
{
|
|
// Hiders are hidden from each other the whole match; only hunters get to see them, and only
|
|
// during Seek (so the hide phase truly hides them from everyone but themselves).
|
|
if (phase == MatchManager.MatchSubPhase.Hide) return false;
|
|
if (phase == MatchManager.MatchSubPhase.Seek) return LocalViewerIsHunter();
|
|
return true;
|
|
}
|
|
|
|
// Classic: hiders are hidden from hunters during Hide only; visible to everyone otherwise.
|
|
if (phase == MatchManager.MatchSubPhase.Hide && LocalViewerIsHunter()) return false;
|
|
return true;
|
|
}
|
|
|
|
// True if this avatar is spawning during the SEEK sub-phase - i.e. a Conversion-mode tag respawning a
|
|
// hider as the Hunter variant in place. Match-start and end-match respawns never happen during Seek
|
|
// (start spawns before Hide, end spawns after the phase is cleared), so this uniquely flags a conversion.
|
|
private static bool SpawnedMidSeek()
|
|
{
|
|
var match = MatchManager.Instance;
|
|
return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Seek;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|