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