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:
adminandClaude Fable 5 committed 2026-07-12 16:17:40 +02:00
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)
+61 -3
View File
@@ -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()
+138
View File
@@ -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
@@ -8,6 +8,13 @@ using UnityEngine;
[CreateAssetMenu(fileName = "NewPose", menuName = "Chameleon/Pose")]
public class PlayerPose : ScriptableObject
{
[Header("UI")]
[Tooltip("Icon shown for this pose in the pose selection menu.")]
[SerializeField] private Sprite icon;
[Tooltip("Display name shown for this pose in the pose selection menu.")]
[SerializeField] private string title;
[Tooltip("Clip held while the player is idle in this pose (looped if the clip itself loops, " +
"otherwise frozen on its last frame). Authored for the paintman generic rig; clips for " +
"other hierarchies will not retarget.")]
@@ -68,6 +75,8 @@ public class PlayerPose : ScriptableObject
[SerializeField] private float firstPersonCameraTargetYOffset;
// Read-only accessors; semantics are documented on the serialized fields' tooltips above.
public Sprite Icon => icon;
public string Title => title;
public AnimationClip Clip => clip;
public float ClipSpeed => clipSpeed;
public AnimationClip WalkClip => walkClip;