using UnityEngine;
using Unity.Netcode;
///
/// First-person mode. The player keeps walking, but the camera moves to the eye (CameraTarget) with
/// free mouse-look and the player's mesh is hidden. Orbit camera input is disabled so it doesn't fight
/// for the cursor; the drives its own dedicated CinemachineCamera.
///
public class FirstPersonMode : GameMode
{
public override string DisplayName => "First Person";
public override void Enter()
{
SetPlayerControl(true);
SetPainting(false);
SetOrbitInputEnabled(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);
SetOrbitInputEnabled(true);
if (Manager.CameraInput)
{
// 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[32];
private static bool _hitsSaturated; // log-once: a saturated buffer can silently drop the true blocker
// Cached per bound avatar: CanShoot runs every Tick during a match.
private PlayerCharacterController _setupSource;
private NetworkPlayerSetup _setup;
public override void Tick()
{
if (!CanShoot()) return;
if (Time.time < _nextShootTime) return;
if (!InputUtil.LeftClickedThisFrame()) 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;
var player = Manager.Player;
if (!player || !Manager.FirstPerson) return false;
if (player != _setupSource)
{
_setupSource = player;
_setup = player.GetComponent();
}
return _setup && _setup.ScopedModes && _setup.Role == PlayerRole.Hunter;
}
// Beam starts a touch ahead of the eye so the FP near plane doesn't clip it and it reads as coming from
// the player rather than the exact camera pivot.
private const float MuzzleForwardOffset = 0.2f;
// Lateral muzzle offset (world right of the eye) so the beam originates off-centre instead of dead ahead
// - a dead-centre beam looks bad in first person. Purely visual; the shot raycast stays centred (aim
// unchanged), only the beam's drawn origin shifts, and the world-space endpoints replicate to peers.
private const float MuzzleRightOffset = 0.15f;
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);
Vector3 origin = cam.position;
Vector3 dir = cam.forward;
int count = Physics.RaycastNonAlloc(origin, dir, _hits, range, ~0, QueryTriggerInteraction.Ignore);
if (count == _hits.Length && !_hitsSaturated)
{
_hitsSaturated = true;
Debug.LogWarning($"FirstPersonMode: shot raycast buffer saturated ({count}); the nearest blocker may be missed.");
}
Vector3 end = origin + dir * range; // nothing hit -> the beam runs to max range
if (count > 0)
{
// 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() : null;
for (int i = 0; i < count; i++)
{
var netObj = _hits[i].collider.GetComponentInParent();
if (netObj && self && netObj == self) continue;
end = _hits[i].point;
if (netObj && netObj.IsPlayerObject)
{
var setup = netObj.GetComponent();
if (setup && setup.Role == PlayerRole.Hider && !setup.IsFound)
match.ReportHit(netObj.OwnerClientId);
}
break;
}
}
// Fire the beam on every shot (hit or miss), drawn locally and replicated to the other clients.
// The muzzle sits slightly forward + right of the eye so the beam is off-centre (see MuzzleRightOffset).
Vector3 muzzle = origin + dir * MuzzleForwardOffset + cam.right * MuzzleRightOffset;
if (_setup) _setup.FireShot(muzzle, end);
}
private sealed class RaycastDistanceComparer : System.Collections.Generic.IComparer
{
public static readonly RaycastDistanceComparer Instance = new RaycastDistanceComparer();
public int Compare(RaycastHit a, RaycastHit b) => a.distance.CompareTo(b.distance);
}
}