Files
chameleon/Assets/Scripts/FirstPersonMode.cs
T
adminandClaude Opus 4.8 5b3fe1d3d1 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>
2026-06-30 13:59:46 +02:00

125 lines
5.1 KiB
C#

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
/// isn't seen from the inside. The orbit camera input is disabled while here so it doesn't fight for
/// the cursor; the <see cref="FirstPersonController"/> drives its own dedicated CinemachineCamera.
/// </summary>
public class FirstPersonMode : GameMode
{
public override string DisplayName => "First Person";
public override void Enter()
{
// Player stays fully controllable; only the view changes.
if (Manager.Player)
{
Manager.Player.enabled = true;
Manager.Player.ControlEnabled = true;
}
if (Manager.Painter)
Manager.Painter.PaintingEnabled = false;
// The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame.
if (Manager.CameraInput)
Manager.CameraInput.enabled = false;
if (Manager.FirstPerson)
Manager.FirstPerson.SetActive(true);
}
public override void Exit()
{
// Modes are Enter-only, so first-person must undo its own camera takeover (and unhide the mesh)
// here; the next mode's Enter restores the player/painter/look-mode state.
if (Manager.FirstPerson)
Manager.FirstPerson.SetActive(false);
if (Manager.CameraInput)
{
Manager.CameraInput.enabled = true;
// Carry the first-person facing into the orbit camera so the next orbit mode (Paint / Player
// Control) keeps the current view angle instead of snapping back to the orbit's last-used angle.
// Camera.main still shows the first-person view this frame (the brain hasn't blended yet).
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);
}
}