Add the hide/seek/resolution match state machine

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>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-30 13:59:46 +02:00
1 parent 95effb91da
commit 5b3fe1d3d1
5 files changed
+555 -3

No files matched your search

+77
View File
@@ -1,3 +1,9 @@
using UnityEngine;
using Unity.Netcode;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// First-person mode. The player keeps walking (WASD, camera-relative) but the camera moves to the
/// player's eye (the CameraTarget) with free mouse-look, and the player's mesh is hidden so the body
@@ -44,4 +50,75 @@ public class FirstPersonMode : GameMode
Manager.CameraInput.SyncToView();
}
}
// --- Hunter shooting -------------------------------------------------------------------------------
// A hunter in first person shoots a single ray from the camera centre on left-click (with a cooldown),
// but only during the SEEK sub-phase. A hit on an un-found hider is reported to the server, which
// validates and marks the hider found. Gated on the local avatar actually being a hunter role variant,
// so the legacy lobby first-person view never shoots.
private float _nextShootTime;
private static readonly RaycastHit[] _hits = new RaycastHit[16];
public override void Tick()
{
if (!CanShoot()) return;
if (Time.time < _nextShootTime) return;
if (!ClickedThisFrame()) return;
_nextShootTime = Time.time + Mathf.Max(0f, MatchManager.Instance.ShootCooldown);
TryShoot();
}
private bool CanShoot()
{
var match = MatchManager.Instance;
if (!match || !match.MatchActive || match.LocalSubPhase != MatchManager.MatchSubPhase.Seek) return false;
if (!Manager.Player || !Manager.FirstPerson) return false;
var setup = Manager.Player.GetComponent<NetworkPlayerSetup>();
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
private void TryShoot()
{
var match = MatchManager.Instance;
Transform cam = Manager.FirstPerson.transform; // FP vcam sits at the eye, facing the look direction
float range = Mathf.Max(0f, match.ShootRange);
int count = Physics.RaycastNonAlloc(cam.position, cam.forward, _hits, range, ~0, QueryTriggerInteraction.Ignore);
if (count == 0) return;
// Nearest-first so the shot stops at the first solid thing it meets (a wall blocks; a nearer avatar
// shields a farther one). The hunter's own body is skipped so looking down doesn't self-block.
System.Array.Sort(_hits, 0, count, RaycastDistanceComparer.Instance);
NetworkObject self = Manager.Player ? Manager.Player.GetComponent<NetworkObject>() : null;
for (int i = 0; i < count; i++)
{
var netObj = _hits[i].collider.GetComponentInParent<NetworkObject>();
if (netObj && self && netObj == self) continue; // ignore our own collider
if (!netObj || !netObj.IsPlayerObject) return; // first solid hit is world geometry -> blocked
var setup = netObj.GetComponent<NetworkPlayerSetup>();
if (setup && setup.Role == PlayerRole.Hider && !setup.IsFound)
match.ReportHit(netObj.OwnerClientId);
return; // the first non-self hit decides the shot
}
}
private static bool ClickedThisFrame()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame;
#else
return Input.GetMouseButtonDown(0);
#endif
}
private sealed class RaycastDistanceComparer : System.Collections.Generic.IComparer<RaycastHit>
{
public static readonly RaycastDistanceComparer Instance = new RaycastDistanceComparer();
public int Compare(RaycastHit a, RaycastHit b) => a.distance.CompareTo(b.distance);
}
}