diff --git a/Assets/Scripts/Components/Controller/PlayerPoseController.cs b/Assets/Scripts/Components/Controller/PlayerPoseController.cs
index ceca823..3e52e4f 100644
--- a/Assets/Scripts/Components/Controller/PlayerPoseController.cs
+++ b/Assets/Scripts/Components/Controller/PlayerPoseController.cs
@@ -3,14 +3,12 @@ using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Serialization;
-#if ENABLE_INPUT_SYSTEM
-using UnityEngine.InputSystem;
-#endif
///
/// Owns ALL character animation (the Animator is left empty): plays the active 's
/// idle or optional walk/run/fall clip through a two-input mixer that
-/// crossfades every clip switch. One key cycles poses, sprinting resets to the default pose; each pose
+/// crossfades every clip switch. Poses are selected via (the pose menu UI);
+/// sprinting resets to the default pose; each pose
/// also carries its own eased CharacterController capsule and per-pose camera y-offsets.
///
[RequireComponent(typeof(PlayerCharacterController))]
@@ -28,15 +26,6 @@ public class PlayerPoseController : MonoBehaviour
[Min(0)]
[SerializeField] private int defaultPoseIndex = 0;
- [Header("Input")]
-#if ENABLE_INPUT_SYSTEM
- [Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
- [SerializeField] private Key cycleKey = Key.C;
-#else
- [Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
- [SerializeField] private KeyCode cycleKey = KeyCode.C;
-#endif
-
[Header("Transitions")]
[Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")]
[Min(0f)]
@@ -111,6 +100,9 @@ public class PlayerPoseController : MonoBehaviour
public int CurrentIndex => _currentIndex;
+ /// Selectable poses, for the pose selection menu (icons/titles per entry).
+ public IReadOnlyList Poses => poses;
+
/// Locomotion category resolved this frame; the owner publishes it for network sync.
public Locomotion CurrentLocomotion => _currentLocomotion;
@@ -169,9 +161,6 @@ public class PlayerPoseController : MonoBehaviour
// Local control only - a remote's pose index is pushed in via SetNetworkState.
if (!_networkControlled)
{
- if (InputUtil.GameplayKeyPressed(cycleKey))
- NextPose();
-
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
// pose. Held continuously, so the reset shows up immediately, not only once back on the floor.
if (_player.IsSprinting)
@@ -234,10 +223,7 @@ public class PlayerPoseController : MonoBehaviour
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
}
- /// Advances to the next pose, wrapping at the end of the list.
- public void NextPose() => SetPoseIndex(_currentIndex + 1);
-
- /// Selects a pose by index, wrapping into range so cycling never goes out of bounds.
+ /// Selects a pose by index, wrapping into range so a stale index never goes out of bounds.
public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index);
private int WrapIndex(int index)
diff --git a/Assets/Scripts/Components/UI/OverviewMenu.cs b/Assets/Scripts/Components/UI/OverviewMenu.cs
index 244af95..fe4edf9 100644
--- a/Assets/Scripts/Components/UI/OverviewMenu.cs
+++ b/Assets/Scripts/Components/UI/OverviewMenu.cs
@@ -9,11 +9,12 @@ using UnityEngine.InputSystem;
/// The Esc overview menu (the "Overview UI" canvas in Main). Owns ALL Escape handling (Tab acts as an
/// alternative to Esc): Esc toggles
/// this panel as a modal pause overlay (gameplay suspended via );
-/// while the session browser or match results overlay is open instead, Esc closes that, and while paint
+/// while the session browser, match results or pose menu overlay is open instead, Esc closes that, and while paint
/// mode is active Esc exits to the camera mode (Player Control, or First Person for the hunter). Buttons
/// switch game modes (via ) or open the session browser /
/// match results overlay, and every click closes the panel. Mode buttons are only shown for modes present in the current role-scoped set
-/// (); the active mode's button is disabled. Attach to the
+/// (); the active mode's button is disabled. Hotkeys mirror the
+/// buttons: C toggles the pose menu, V switches to paint mode (E = taunt lives in GameModeManager). Attach to the
/// always-active canvas root; is the toggled visual child.
///
public class OverviewMenu : MonoBehaviour
@@ -29,6 +30,10 @@ public class OverviewMenu : MonoBehaviour
"menu stays closed. Empty = found automatically.")]
[SerializeField] private MatchResultScreen matchResultScreen;
+ [Tooltip("Pose selection menu binder. While its overlay is open it owns Esc (close-only) and this " +
+ "menu stays closed. Empty = found automatically.")]
+ [SerializeField] private PoseMenu poseMenu;
+
[Header("Panel")]
[Tooltip("Visual container toggled by Esc. Must be a CHILD of this binder's object so the binder " +
"keeps reading Esc while the panel is hidden.")]
@@ -45,6 +50,8 @@ public class OverviewMenu : MonoBehaviour
[Tooltip("Triggers the hider's manual radar ping (same as the E key) and closes the menu. Only shown " +
"for a hider during Seek.")]
[SerializeField] private Button tauntButton;
+ [Tooltip("Opens the pose selection menu. Only shown while the local player has selectable poses.")]
+ [SerializeField] private Button poseButton;
private bool _open;
@@ -53,6 +60,7 @@ public class OverviewMenu : MonoBehaviour
if (!gameMode) gameMode = FindFirstObjectByType();
if (!sessionBrowser) sessionBrowser = FindFirstObjectByType();
if (!matchResultScreen) matchResultScreen = FindFirstObjectByType();
+ if (!poseMenu) poseMenu = FindFirstObjectByType();
if (panelRoot) panelRoot.SetActive(false);
}
@@ -65,6 +73,7 @@ public class OverviewMenu : MonoBehaviour
if (firstPersonButton) firstPersonButton.onClick.AddListener(OnFirstPerson);
if (freeCamButton) freeCamButton.onClick.AddListener(OnFreeCam);
if (tauntButton) tauntButton.onClick.AddListener(OnTaunt);
+ if (poseButton) poseButton.onClick.AddListener(OnPose);
}
private void OnDisable()
@@ -76,18 +85,20 @@ public class OverviewMenu : MonoBehaviour
if (firstPersonButton) firstPersonButton.onClick.RemoveListener(OnFirstPerson);
if (freeCamButton) freeCamButton.onClick.RemoveListener(OnFreeCam);
if (tauntButton) tauntButton.onClick.RemoveListener(OnTaunt);
+ if (poseButton) poseButton.onClick.RemoveListener(OnPose);
}
private void Update()
{
bool browserOpen = sessionBrowser && sessionBrowser.IsOpen;
bool resultsOpen = matchResultScreen && matchResultScreen.IsOpen;
+ bool poseOpen = poseMenu && poseMenu.IsOpen;
if (_open)
{
// Another overlay took over (results Resolution auto-open) -> hide this panel; the pause
// is that overlay's now, so don't release it.
- if (browserOpen || resultsOpen)
+ if (browserOpen || resultsOpen || poseOpen)
HidePanel();
// An external state reset (respawn/bind, disconnect) dropped the pause underneath the
// open panel -> don't linger as a stale overlay over live gameplay.
@@ -95,12 +106,16 @@ public class OverviewMenu : MonoBehaviour
HidePanel();
}
+ if (HandleHotkeys(browserOpen, resultsOpen, poseOpen)) return;
+
if (!EscapePressed()) return;
if (browserOpen)
sessionBrowser.Close();
else if (resultsOpen)
matchResultScreen.Close();
+ else if (poseOpen)
+ poseMenu.Close();
else if (_open)
Close(resume: true);
// Loading (joining a session, or the pre-match Loading barrier): the scene/avatar is mid-setup,
@@ -157,6 +172,13 @@ public class OverviewMenu : MonoBehaviour
gameMode.SetPaused(false);
}
+ private void OnPose()
+ {
+ Close(resume: false);
+ if (!(poseMenu && poseMenu.Open()) && gameMode)
+ gameMode.SetPaused(false);
+ }
+
private void OnTaunt()
{
Close(resume: true);
@@ -196,6 +218,10 @@ public class OverviewMenu : MonoBehaviour
tauntButton.gameObject.SetActive(gameMode.LocalPlayerIsHider && MatchManager.Instance &&
MatchManager.Instance.LocalSubPhase == MatchManager.MatchSubPhase.Seek);
+ // Pose selection needs a bound local player with poses - hide the button otherwise.
+ if (poseButton)
+ poseButton.gameObject.SetActive(poseMenu && poseMenu.CanOpen);
+
SetModeButton(playerControlButton, modes, active);
SetModeButton(paintButton, modes, active);
SetModeButton(firstPersonButton, modes, active);
@@ -213,6 +239,38 @@ public class OverviewMenu : MonoBehaviour
button.interactable = !(active is T);
}
+ /// Hotkeys mirroring the overlay buttons: C = pose menu (toggle), V = paint mode.
+ /// Same gating as the buttons: inert while another overlay owns input, while loading, and in the
+ /// no-player state. Returns true when a hotkey acted this frame.
+ private bool HandleHotkeys(bool browserOpen, bool resultsOpen, bool poseOpen)
+ {
+ if (browserOpen || resultsOpen) return false;
+ if (MatchManager.Instance && MatchManager.Instance.IsLoadingLocally) return false;
+ if (!gameMode || gameMode.EffectiveMode == null) return false;
+
+#if ENABLE_INPUT_SYSTEM
+ bool posePressed = InputUtil.GameplayKeyPressed(Key.C);
+ bool paintPressed = InputUtil.GameplayKeyPressed(Key.V);
+#else
+ bool posePressed = InputUtil.GameplayKeyPressed(KeyCode.C);
+ bool paintPressed = InputUtil.GameplayKeyPressed(KeyCode.V);
+#endif
+
+ if (posePressed)
+ {
+ if (poseOpen) poseMenu.Close();
+ else if (poseMenu && poseMenu.CanOpen) OnPose();
+ return true;
+ }
+
+ if (paintPressed && !poseOpen)
+ {
+ SwitchMode();
+ return true;
+ }
+ return false;
+ }
+
// Esc is NOT TypingGuard-gated: it must work even while a text field has focus.
// Tab (alternative binding) IS gated so it stays usable for typing/field navigation.
private static bool EscapePressed()
diff --git a/Assets/Scripts/Components/UI/PoseMenu.cs b/Assets/Scripts/Components/UI/PoseMenu.cs
new file mode 100644
index 0000000..2d319fc
--- /dev/null
+++ b/Assets/Scripts/Components/UI/PoseMenu.cs
@@ -0,0 +1,138 @@
+using System.Collections.Generic;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+
+///
+/// The pose selection menu (the "Pose UI" canvas in Main): a modal pause overlay (same pattern as
+/// ) opened via the overview menu's Pose button, closed by Esc through
+/// (all Esc handling lives there). On every open it rebuilds a radial menu
+/// of Icon Button prefab instances from the local player's
+/// (icon + title come from each ); clicking an item applies that pose and
+/// closes the menu. The active pose's button is disabled (its hover ring marks it, like the overview
+/// menu's mode buttons). Only the visual is toggled - this binder stays alive
+/// on its (always-active) canvas root.
+///
+[DisallowMultipleComponent]
+public class PoseMenu : MonoBehaviour
+{
+ [Tooltip("Mode manager supplying the local player (whose PlayerPoseController is driven) and the " +
+ "pause hook. Empty = found automatically.")]
+ [SerializeField] private GameModeManager gameMode;
+
+ [Header("Overlay")]
+ [Tooltip("Visual container toggled via Open/Close. Must be a CHILD of this binder's object so the " +
+ "binder stays alive while the panel is hidden.")]
+ [SerializeField] private GameObject panelRoot;
+
+ [Header("Radial menu")]
+ [Tooltip("Center the pose items are arranged around (typically a centered child of the panel).")]
+ [SerializeField] private RectTransform itemContainer;
+
+ [Tooltip("Icon Button prefab instantiated per pose (Icon child image + Label child TMP are bound).")]
+ [SerializeField] private GameObject itemPrefab;
+
+ [Tooltip("Distance of each item from the container center, in canvas units.")]
+ [Min(0f)]
+ [SerializeField] private float radius = 220f;
+
+ private readonly List _items = new();
+ private PlayerPoseController _poseController; // controller the open menu is bound to
+ private bool _open;
+
+ private void Awake()
+ {
+ if (!gameMode) gameMode = FindFirstObjectByType();
+ if (panelRoot) panelRoot.SetActive(false);
+ }
+
+ /// True while the modal overlay is open. The overview menu yields Esc to this overlay
+ /// (close-only) while it is open.
+ public bool IsOpen => _open;
+
+ /// True while the local player has selectable poses - the overview menu hides its Pose
+ /// button otherwise (no player bound, or an empty pose list).
+ public bool CanOpen => LocalPoseController() && LocalPoseController().Poses.Count > 0;
+
+ /// Opens the modal overlay and (re)builds the radial items; false when there is nothing
+ /// to show, so the caller can drop the pause instead of stranding it.
+ public bool Open()
+ {
+ PlayerPoseController pose = LocalPoseController();
+ if (!pose || pose.Poses.Count == 0 || !itemContainer || !itemPrefab) return false;
+ _poseController = pose;
+ BuildItems(pose);
+ _open = true;
+ if (panelRoot) panelRoot.SetActive(true);
+ if (gameMode) gameMode.SetPaused(true);
+ return true;
+ }
+
+ /// Closes the modal overlay and resumes gameplay (Esc handling in ).
+ public void Close()
+ {
+ _open = false;
+ if (panelRoot) panelRoot.SetActive(false);
+ if (gameMode) gameMode.SetPaused(false);
+ }
+
+ private void Update()
+ {
+ // The bound player went away underneath the open menu (despawn/disconnect) -> don't linger
+ // as a stale pause overlay.
+ if (_open && !_poseController)
+ Close();
+ }
+
+ private PlayerPoseController LocalPoseController()
+ {
+ PlayerCharacterController player = gameMode ? gameMode.Player : null;
+ return player ? player.GetComponent() : null;
+ }
+
+ /// Instantiates one Icon Button per pose, evenly on a circle starting at 12 o'clock going
+ /// clockwise. Rebuilt on every open - pose count is tiny and the list can differ per bound player.
+ private void BuildItems(PlayerPoseController pose)
+ {
+ for (int i = 0; i < _items.Count; i++)
+ if (_items[i]) Destroy(_items[i]);
+ _items.Clear();
+
+ IReadOnlyList poses = pose.Poses;
+ for (int i = 0; i < poses.Count; i++)
+ {
+ GameObject item = Instantiate(itemPrefab, itemContainer);
+ float angle = (90f - i * 360f / poses.Count) * Mathf.Deg2Rad;
+ ((RectTransform)item.transform).anchoredPosition =
+ new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
+
+ PlayerPose p = poses[i];
+ Transform iconChild = item.transform.Find("Icon");
+ Image icon = iconChild ? iconChild.GetComponent() : null;
+ if (icon)
+ {
+ icon.sprite = p ? p.Icon : null;
+ icon.preserveAspect = true;
+ icon.enabled = icon.sprite; // no icon authored -> no solid white square
+ }
+ TMP_Text label = item.GetComponentInChildren(true);
+ if (label) label.text = p && !string.IsNullOrEmpty(p.Title) ? p.Title : (p ? p.name : "");
+
+ Button button = item.GetComponent