Add radial pose menu with hotkeys, remove C cycle hotkey
- PlayerPose: Icon (Sprite) + Title (string) UI properties - PoseMenu: modal pause overlay building a radial menu from Icon Button prefab instances; active pose disabled (hover ring marks it); click applies pose via SetPoseIndex and closes - OverviewMenu: pose button wiring with pause hand-off, Esc/Tab chain extended; hotkeys C (toggle pose menu) and V (paint mode) mirroring the buttons incl. identical gating - PlayerPoseController: C cycle hotkey and NextPose() removed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3d9c06118
commit
3a2e8a7057
5 files changed
+216
-23
No files matched your search
@@ -3,14 +3,12 @@ using UnityEngine;
|
||||
using UnityEngine.Animations;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Serialization;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Owns ALL character animation (the Animator is left empty): plays the active <see cref="PlayerPose"/>'s
|
||||
/// idle or optional walk/run/fall clip through a two-input mixer <see cref="PlayableGraph"/> 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 <see cref="SetPoseIndex"/> (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.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Selectable poses, for the pose selection menu (icons/titles per entry).</summary>
|
||||
public IReadOnlyList<PlayerPose> Poses => poses;
|
||||
|
||||
/// <summary>Locomotion category resolved this frame; the owner publishes it for network sync.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
|
||||
public void NextPose() => SetPoseIndex(_currentIndex + 1);
|
||||
|
||||
/// <summary>Selects a pose by index, wrapping into range so cycling never goes out of bounds.</summary>
|
||||
/// <summary>Selects a pose by index, wrapping into range so a stale index never goes out of bounds.</summary>
|
||||
public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index);
|
||||
|
||||
private int WrapIndex(int index)
|
||||
|
||||
@@ -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 <see cref="GameModeManager.SetPaused"/>);
|
||||
/// 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 <see cref="GameModeManager.TrySwitchTo{T}"/>) 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
|
||||
/// (<see cref="GameModeManager.Modes"/>); the active mode's button is disabled. Attach to the
|
||||
/// (<see cref="GameModeManager.Modes"/>); 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; <see cref="panelRoot"/> is the toggled visual child.
|
||||
/// </summary>
|
||||
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<GameModeManager>();
|
||||
if (!sessionBrowser) sessionBrowser = FindFirstObjectByType<OnlineSessionBrowserUI>();
|
||||
if (!matchResultScreen) matchResultScreen = FindFirstObjectByType<MatchResultScreen>();
|
||||
if (!poseMenu) poseMenu = FindFirstObjectByType<PoseMenu>();
|
||||
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<PlayerControlMode>(playerControlButton, modes, active);
|
||||
SetModeButton<PaintMode>(paintButton, modes, active);
|
||||
SetModeButton<FirstPersonMode>(firstPersonButton, modes, active);
|
||||
@@ -213,6 +239,38 @@ public class OverviewMenu : MonoBehaviour
|
||||
button.interactable = !(active is T);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<PaintMode>();
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// The pose selection menu (the "Pose UI" canvas in Main): a modal pause overlay (same pattern as
|
||||
/// <see cref="MatchResultScreen"/>) opened via the overview menu's Pose button, closed by Esc through
|
||||
/// <see cref="OverviewMenu"/> (all Esc handling lives there). On every open it rebuilds a radial menu
|
||||
/// of Icon Button prefab instances from the local player's <see cref="PlayerPoseController.Poses"/>
|
||||
/// (icon + title come from each <see cref="PlayerPose"/>); 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 <see cref="panelRoot"/> is toggled - this binder stays alive
|
||||
/// on its (always-active) canvas root.
|
||||
/// </summary>
|
||||
[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<GameObject> _items = new();
|
||||
private PlayerPoseController _poseController; // controller the open menu is bound to
|
||||
private bool _open;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (!gameMode) gameMode = FindFirstObjectByType<GameModeManager>();
|
||||
if (panelRoot) panelRoot.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>True while the modal overlay is open. The overview menu yields Esc to this overlay
|
||||
/// (close-only) while it is open.</summary>
|
||||
public bool IsOpen => _open;
|
||||
|
||||
/// <summary>True while the local player has selectable poses - the overview menu hides its Pose
|
||||
/// button otherwise (no player bound, or an empty pose list).</summary>
|
||||
public bool CanOpen => LocalPoseController() && LocalPoseController().Poses.Count > 0;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Closes the modal overlay and resumes gameplay (Esc handling in <see cref="OverviewMenu"/>).</summary>
|
||||
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<PlayerPoseController>() : null;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private void BuildItems(PlayerPoseController pose)
|
||||
{
|
||||
for (int i = 0; i < _items.Count; i++)
|
||||
if (_items[i]) Destroy(_items[i]);
|
||||
_items.Clear();
|
||||
|
||||
IReadOnlyList<PlayerPose> 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<Image>() : 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<TMP_Text>(true);
|
||||
if (label) label.text = p && !string.IsNullOrEmpty(p.Title) ? p.Title : (p ? p.name : "");
|
||||
|
||||
Button button = item.GetComponent<Button>();
|
||||
if (button)
|
||||
{
|
||||
int index = i;
|
||||
button.onClick.AddListener(() => OnItem(index));
|
||||
// Disabled = current pose; its hover ring (IconButtonHover) marks it as active.
|
||||
button.interactable = pose.CurrentIndex != i;
|
||||
}
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItem(int index)
|
||||
{
|
||||
if (_poseController) _poseController.SetPoseIndex(index);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5007214fcdfdff41a4f97efacf19fbb
|
||||
Reference in new issue
Block a user