Files
chameleon/Assets/Scripts/GameModeManager.cs
T
adminandClaude Opus 4.8 62cf0eae04 Scale third-person orbit zoom range and default by player size
The orbit radius (min/max/default and per-notch step) was in absolute world
units and ignored the player's localScale, so a small Hider started too far
out and a large Hunter too close and could not zoom out far enough. Multiply
range, step and default radius by the follow target's lossyScale (PlayerScaler
keeps the body uniformly scaled), making the inspector fields size-1 design
values that frame every role body-relative. Re-clamp every frame so a runtime
grow/shrink stays in range. GameModeManager.BindLocalPlayer calls ResetZoom
after rebinding so each new avatar starts at its scaled default zoom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:39:41 +02:00

357 lines
16 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using Unity.Cinemachine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// Switches the game between high-level modes. The switch key (Tab) cycles to the next registered
/// mode. The mode set is scoped to the local avatar's <see cref="PlayerRole"/> when it spawns
/// (Hunter: First Person + Paint; Hider: Player Control + Paint + Free Cam); a non-networked scene
/// uses the full set. Attach to a single, persistent scene GameObject.
/// </summary>
[DisallowMultipleComponent]
public class GameModeManager : MonoBehaviour
{
[Header("Controlled Systems")]
[Tooltip("Player movement controller. Empty = found automatically.")]
public PlayerCharacterController player;
[Tooltip("Third-person orbit camera input. Empty = found automatically.")]
public ThirdPersonOrbitInput cameraInput;
[Tooltip("Mesh painter. Empty = found automatically.")]
public MeshPainter painter;
[Tooltip("Free-fly spectator camera. Empty = found automatically. Absent = Free Cam is left out of the cycle.")]
public FreeCamController freeCam;
[Tooltip("First-person camera. Empty = found automatically. Absent = First Person is left out of the cycle.")]
public FirstPersonController firstPerson;
//[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
//public InsectController insect;
[Header("Switching")]
[Tooltip("Key that cycles to the next mode.")]
#if ENABLE_INPUT_SYSTEM
public Key switchKey = Key.Tab;
#else
public KeyCode switchKey = KeyCode.Tab;
#endif
[Tooltip("Mode active at startup (0 = Player Control).")]
public int startModeIndex = 0;
public PlayerCharacterController Player => player;
public ThirdPersonOrbitInput CameraInput => cameraInput;
public MeshPainter Painter => painter;
public FreeCamController FreeCam => freeCam;
public FirstPersonController FirstPerson => firstPerson;
//public InsectController Insect => insect;
/// <summary>Raised after a mode switch finishes (the new mode's Enter has already run).
/// Argument = the now-active mode. Used by UI (e.g. the paint HUD) to follow the mode.</summary>
public event System.Action<GameMode> ModeChanged;
/// <summary>Raised when the painter the HUD should edit changes - i.e. when the local
/// networked avatar spawns and <see cref="BindLocalPlayer"/> binds its per-avatar painter.
/// With spawn-on-connect there is no painter at scene start, so the paint HUD follows this
/// to (re)bind its widgets to the live painter once it exists.</summary>
public event System.Action<MeshPainter> PainterChanged;
private readonly List<GameMode> _modes = new List<GameMode>();
private int _activeIndex = -1;
private bool _paused;
private int _resumeIndex = -1;
/// <summary>True while local gameplay is suspended for a modal overlay (see <see cref="SetPaused"/>).</summary>
public bool IsPaused => _paused;
/// <summary>The currently active mode (null before startup).</summary>
public GameMode ActiveMode =>
(_activeIndex >= 0 && _activeIndex < _modes.Count) ? _modes[_activeIndex] : null;
private void Awake()
{
if (!player) player = FindFirstObjectByType<PlayerCharacterController>();
if (!cameraInput) cameraInput = FindFirstObjectByType<ThirdPersonOrbitInput>();
if (!painter) painter = FindFirstObjectByType<MeshPainter>();
// Include inactive so a freecam rig that starts disabled is still found and wired.
if (!freeCam) freeCam = FindFirstObjectByType<FreeCamController>(FindObjectsInactive.Include);
if (!firstPerson) firstPerson = FindFirstObjectByType<FirstPersonController>(FindObjectsInactive.Include);
//if (!insect) insect = FindFirstObjectByType<InsectController>();
BuildModes();
}
private void Start()
{
if (_modes.Count > 0)
SwitchTo(Mathf.Clamp(startModeIndex, 0, _modes.Count - 1));
}
/// <summary>Registers the modes for the current context. Without a role (no networked avatar yet,
/// or a non-networked scene) the full set is built from whichever rigs are present. With a role the
/// set is scoped to that side and ordered so index 0 is its intended start mode. Add new modes here.</summary>
private void BuildModes() => BuildModes(null);
private void BuildModes(PlayerRole? role)
{
_modes.Clear();
switch (role)
{
case PlayerRole.Hunter:
// Hunter: first person + paint. Index 0 (First Person, if its rig exists) is the start mode.
if (firstPerson) _modes.Add(new FirstPersonMode());
_modes.Add(new PaintMode());
break;
case PlayerRole.Hider:
// Hider: third person (player control) + paint + free cam. Index 0 (Player Control) is the start.
if (player) _modes.Add(new PlayerControlMode());
_modes.Add(new PaintMode());
if (freeCam) _modes.Add(new FreeCamMode());
break;
default:
// No role: legacy full set, gated on whichever rigs exist. Player Control only joins when a
// gravity player is present (player-less pre-spawn stays in Paint, not a dead frozen mode);
// Free Cam / First Person only when their rigs exist, so non-networked scenes are unchanged.
if (player) _modes.Add(new PlayerControlMode());
_modes.Add(new PaintMode());
if (freeCam) _modes.Add(new FreeCamMode());
if (firstPerson) _modes.Add(new FirstPersonMode());
break;
}
foreach (var m in _modes)
m.Bind(this);
}
/// <summary>Spectator mode set for a mid-match joiner: free-fly camera only (no player control, no
/// paint). They can fly around and watch, but not play, until the host ends the match (then they
/// respawn as a neutral lobby Player). Falls back to nothing if the scene has no free-cam rig.</summary>
private void BuildSpectatorModes()
{
_modes.Clear();
if (freeCam) _modes.Add(new FreeCamMode());
foreach (var m in _modes)
m.Bind(this);
}
/// <summary>
/// Binds a mid-match spectator. The spectator object is empty (no body/painter to bind), so just clear
/// the player/painter and switch the cycle to free-cam-only. The free-cam rig is a separate persistent
/// scene object that flies off Camera.main, so it needs nothing from the spectator. Called from
/// <see cref="SpectatorSetup"/> on the owning client.
/// </summary>
public void BindSpectator()
{
_paused = false;
_resumeIndex = -1;
player = null;
painter = null;
PainterChanged?.Invoke(null);
ActiveMode?.Exit();
_activeIndex = -1;
BuildSpectatorModes();
SwitchTo(0);
}
/// <summary>
/// Binds the scene singletons (this manager, the orbit/first-person cameras, the painter) to the
/// locally-owned networked player after it spawns, then enters Player Control. Called from
/// <see cref="NetworkPlayerSetup"/> on the owning client. Before this runs the scene has no player,
/// so the cycle sits in a player-less mode (Paint); this promotes it once the avatar exists.
/// </summary>
public void BindLocalPlayer(PlayerCharacterController localPlayer)
{
if (!localPlayer) return;
// A fresh bind is a definitive state reset - drop any modal pause so the new avatar starts live.
_paused = false;
_resumeIndex = -1;
var setup = localPlayer.GetComponent<NetworkPlayerSetup>();
player = localPlayer;
// The painter lives on the player (per-avatar), so bind the local one - UNCONDITIONALLY. On a
// lobby->match respawn the previous avatar is despawned and the role variant spawned in the same
// server frame; the old painter is Destroy-pending (still non-null) when this runs, so a
// `if (!painter)` guard would keep the stale reference and the new avatar's painter would never be
// mode-toggled - it would paint in every mode. Always re-point to the fresh avatar.
painter = localPlayer.GetComponentInChildren<MeshPainter>(true);
PainterChanged?.Invoke(painter);
// Point the cameras at the avatar's follow target.
Transform camTarget = localPlayer.transform.Find("CameraTarget");
if (cameraInput)
{
var vcam = cameraInput.GetComponent<CinemachineCamera>();
if (vcam)
{
vcam.Follow = camTarget;
vcam.LookAt = camTarget;
// Follow is now the new avatar's target, so its lossyScale is live - start the camera at this
// avatar's scaled default zoom (Hider pulls in, Hunter pushes out) instead of inheriting the
// previous avatar's radius.
cameraInput.ResetZoom();
}
}
if (firstPerson)
firstPerson.SetPlayer(localPlayer);
// Tear down the pre-spawn (player-less) mode and rebuild scoped to this avatar's role.
ActiveMode?.Exit();
_activeIndex = -1;
// Lobby/neutral avatar (base Player prefab) -> full mode set; match avatar (Hunter/Hider variant)
// -> only its role's modes. Scope is baked per prefab variant, so it follows the spawned avatar
// with no replicated-phase timing race. (Mid-match spectators take a separate, empty prefab and
// bind via BindSpectator, not here.)
if (setup && setup.ScopedModes)
BuildModes(setup.Role);
else
BuildModes(null);
// Role lists are ordered so index 0 is the start mode (Hunter -> First Person, Hider -> Player
// Control); the full lobby set starts at Player Control too.
SwitchTo(0);
}
/// <summary>
/// Drops back to a player-less state when the local avatar is gone (disconnect / server shutdown
/// returns the session to the empty Main scene). Tears down the active mode so any FreeCam/First-Person
/// camera takeover is undone (which unlocks the cursor), clears the mode cycle, and - crucially - parks
/// the persistent orbit input in Disabled: it otherwise re-locks the cursor every frame while
/// AlwaysActive, so a one-off unlock would be reverted next frame and the OS cursor would stay captured
/// with no avatar to control. A reconnect's <see cref="BindLocalPlayer"/> restores everything.
/// </summary>
public void ReleaseToNoPlayer()
{
_paused = false;
_resumeIndex = -1;
ActiveMode?.Exit();
_activeIndex = -1;
_modes.Clear();
player = null;
painter = null;
PainterChanged?.Invoke(null);
// No SwitchTo runs here (the cycle is now empty), so notify mode-following UI explicitly that
// there is no active mode - otherwise the paint HUD, which only hides via ModeChanged, would stay
// open back in Main after a shutdown that happened while PaintMode was active.
ModeChanged?.Invoke(null);
if (cameraInput)
cameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.Disabled;
// Belt-and-braces explicit release (covers the orbit input being absent/disabled).
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
/// <summary>
/// Pauses or resumes local gameplay for a full-screen modal overlay (the online session panel, opened
/// with Esc in the lobby/map). Pausing frees the OS cursor and suppresses ALL local input - movement,
/// painting, camera look and the mode-switch key - WITHOUT tearing down the mode cycle, so resuming
/// re-enters the exact mode that was active and restores its camera/cursor state.
///
/// Crucially it does NOT Exit the active mode while pausing: the First-Person / Free-Cam rigs keep their
/// CinemachineCamera at its live priority, so the view stays frozen exactly where it is instead of
/// blending back to the orbit camera (the camera "jump" when the menu opened). Pausing therefore only
/// disables each rig's per-frame look input - dropping the controller's <c>enabled</c> freezes its look
/// without touching its priority - and parks the orbit rig in Disabled so it stops re-grabbing the cursor
/// every frame. Resuming re-runs the same mode's Enter(); because no priority was lowered, that mode's
/// snap-to-current-view is a no-op, so there is no jump on close either. No-op if unchanged.
/// </summary>
public void SetPaused(bool paused)
{
if (paused == _paused) return;
_paused = paused;
if (paused)
{
_resumeIndex = _activeIndex;
_activeIndex = -1; // bookkeeping: no live mode while paused (Tick won't run)
// Freeze every input source but leave the active camera rig untouched (priority unchanged) so the
// view stays put. Painting/movement run off their own flags; the FP/freecam rigs drive their own
// CinemachineCamera each frame, so dropping their `enabled` freezes the look in place WITHOUT
// lowering priority; the orbit rig re-asserts the cursor each frame from its LookMode, so a
// Disabled look-mode keeps the cursor free every frame. At most one camera rig is ever enabled,
// so disabling both here is safe regardless of which mode was active.
if (player) player.ControlEnabled = false;
if (painter) painter.PaintingEnabled = false;
if (firstPerson) firstPerson.enabled = false;
if (freeCam) freeCam.enabled = false;
if (cameraInput)
{
cameraInput.enabled = true;
cameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.Disabled;
}
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
ModeChanged?.Invoke(null); // hide mode-following UI (the paint HUD) beneath the overlay
}
else
{
int resume = _resumeIndex;
_resumeIndex = -1;
if (resume >= 0 && resume < _modes.Count)
{
// Re-Enter the SAME mode (it was never Exited). The active rig still holds the current view,
// so Enter()'s sync-to-current-view is a no-op -> no jump. Enter restores control/painting/
// look + cursor and re-shows the HUD.
_activeIndex = resume;
ActiveMode.Enter();
ModeChanged?.Invoke(ActiveMode);
}
else
ModeChanged?.Invoke(ActiveMode);
}
}
private void Update()
{
if (_paused) return;
if (_modes.Count == 0) return;
if (WasSwitchPressed())
SwitchTo((_activeIndex + 1) % _modes.Count);
ActiveMode?.Tick();
}
/// <summary>Activates the mode at <paramref name="index"/> (Exit old, Enter new).</summary>
public void SwitchTo(int index)
{
if (index < 0 || index >= _modes.Count || index == _activeIndex) return;
ActiveMode?.Exit();
_activeIndex = index;
ActiveMode.Enter();
Debug.Log($"[GameModeManager] Mode -> {ActiveMode.DisplayName}", this);
ModeChanged?.Invoke(ActiveMode);
}
private bool WasSwitchPressed()
{
#if ENABLE_INPUT_SYSTEM
return Keyboard.current != null && Keyboard.current[switchKey].wasPressedThisFrame;
#else
return Input.GetKeyDown(switchKey);
#endif
}
}