diff --git a/Assets/Scripts/Components/PoseIconStage.cs b/Assets/Scripts/Components/PoseIconStage.cs
index 613de4a..ce935c3 100644
--- a/Assets/Scripts/Components/PoseIconStage.cs
+++ b/Assets/Scripts/Components/PoseIconStage.cs
@@ -4,7 +4,8 @@ using UnityEngine;
///
/// Authoring tool for the Pose Icon scene: holds a list of pose entries (animation clip + root
/// position/rotation) and applies the selected one to the paintman rig in edit mode, for framing
-/// each pose in front of the camera (icon screenshots are a later step). Clips are sampled via
+/// each pose in front of the camera. The context menu's "Capture All Icons" renders every entry
+/// into Assets/Textures/Icons and wires the sprites onto the PlayerPose assets. Clips are sampled via
/// AnimationClip.SampleAnimation (no foot IK), matching how PlayerPoseController plays them.
///
[ExecuteAlways]
@@ -39,7 +40,7 @@ public class PoseIconStage : MonoBehaviour
// Rest-pose snapshot of the rig, taken before the first sample this session. A clip only writes
// the bones its curves cover, so switching between clips would otherwise leave residue from the
// previous pose on un-keyed bones (same reasoning as PlayerPoseController.CaptureRestPose).
- // Not serialized: reload the scene to get a clean rest pose if it was saved mid-pose.
+ // Read from the prefab asset when available, so a scene saved mid-pose cannot poison the snapshot.
private Transform[] _restBones;
private Vector3[] _restPos;
private Quaternion[] _restRot;
@@ -49,8 +50,35 @@ public class PoseIconStage : MonoBehaviour
if (!target)
target = GetComponentInChildren();
_dirty = true;
+#if UNITY_EDITOR
+ UnityEditor.SceneManagement.EditorSceneManager.sceneSaving += OnSceneSaving;
+ UnityEditor.SceneManagement.EditorSceneManager.sceneSaved += OnSceneSaved;
+#endif
}
+#if UNITY_EDITOR
+ void OnDisable()
+ {
+ UnityEditor.SceneManagement.EditorSceneManager.sceneSaving -= OnSceneSaving;
+ UnityEditor.SceneManagement.EditorSceneManager.sceneSaved -= OnSceneSaved;
+ }
+
+ // Save the scene in rest pose: a scene saved mid-pose would become the next session's rest
+ // snapshot (the prefab asset's own rest cannot be used - the clips do not key the bone that
+ // uprights the blend-imported rig), silently degrading every pose that samples on top of it.
+ void OnSceneSaving(UnityEngine.SceneManagement.Scene scene, string path)
+ {
+ if (scene == gameObject.scene && _restBones != null)
+ ResetToRestPose();
+ }
+
+ void OnSceneSaved(UnityEngine.SceneManagement.Scene scene)
+ {
+ if (scene == gameObject.scene)
+ _dirty = true;
+ }
+#endif
+
void OnValidate()
{
// Only flag here; Update applies (coalesced to once per frame, safe object access).
@@ -112,4 +140,180 @@ public class PoseIconStage : MonoBehaviour
t.localRotation = _restRot[i];
}
}
+
+#if UNITY_EDITOR
+ // ---- Icon capture (editor only) ----
+ // Right-click the component header -> "Capture All Icons": renders every entry through the scene
+ // camera at CaptureResolution (background cleared to alpha 0 so the alpha channel is the mask),
+ // un-blends edge pixels against the background color, content-crops with a small margin, squares
+ // the result, writes Assets/Textures/Icons/Icon_pose_.png and assigns the imported sprite
+ // to the matching PlayerPose asset (matched by idle clip).
+
+ private const int CaptureResolution = 4096;
+ private const float CropMargin = 0.04f;
+ private const string IconFolder = "Assets/Textures/Icons";
+
+ [ContextMenu("Capture All Icons")]
+ private void CaptureAllIcons()
+ {
+ Camera cam = Camera.main;
+ if (!cam)
+ {
+ Debug.LogError("[PoseIconStage] No enabled MainCamera in the scene.");
+ return;
+ }
+ if (!target || entries.Count == 0)
+ return;
+
+ var poses = new List();
+ foreach (string guid in UnityEditor.AssetDatabase.FindAssets("t:PlayerPose"))
+ poses.Add(UnityEditor.AssetDatabase.LoadAssetAtPath(
+ UnityEditor.AssetDatabase.GUIDToAssetPath(guid)));
+
+ // Skinned meshes only follow sampled bones in unfocused/background renders when forced.
+ var skinned = target.GetComponentsInChildren(true);
+ var forcePrev = new bool[skinned.Length];
+ for (int i = 0; i < skinned.Length; i++)
+ {
+ forcePrev[i] = skinned[i].forceMatrixRecalculationPerRender;
+ skinned[i].forceMatrixRecalculationPerRender = true;
+ }
+
+ CameraClearFlags prevFlags = cam.clearFlags;
+ Color prevBg = cam.backgroundColor;
+ cam.clearFlags = CameraClearFlags.SolidColor;
+ Color bg = new Color(prevBg.r, prevBg.g, prevBg.b, 0f); // alpha 0 -> alpha channel is the mask
+ cam.backgroundColor = bg;
+
+ int savedIndex = currentIndex;
+ var rt = new RenderTexture(CaptureResolution, CaptureResolution, 32, RenderTextureFormat.ARGB32);
+ var readback = new Texture2D(CaptureResolution, CaptureResolution, TextureFormat.RGBA32, false);
+ int written = 0;
+ try
+ {
+ for (int e = 0; e < entries.Count; e++)
+ {
+ Entry entry = entries[e];
+ if (entry == null || !entry.clip)
+ continue;
+ currentIndex = e;
+ Apply();
+
+ var request = new UnityEngine.Rendering.RenderPipeline.StandardRequest { destination = rt };
+ UnityEngine.Rendering.RenderPipeline.SubmitRenderRequest(cam, request);
+
+ RenderTexture prevActive = RenderTexture.active;
+ RenderTexture.active = rt;
+ readback.ReadPixels(new Rect(0, 0, CaptureResolution, CaptureResolution), 0, 0);
+ readback.Apply();
+ RenderTexture.active = prevActive;
+
+ PlayerPose pose = poses.Find(p => p && p.Clip == entry.clip);
+ string suffix = pose
+ ? pose.name.Replace("Pose_", "").ToLowerInvariant()
+ : entry.clip.name.ToLowerInvariant();
+ string assetPath = $"{IconFolder}/Icon_pose_{suffix}.png";
+
+ if (SaveIcon(readback, bg, assetPath) && pose)
+ AssignIcon(pose, assetPath);
+ written++;
+ }
+ }
+ finally
+ {
+ currentIndex = Mathf.Clamp(savedIndex, 0, entries.Count - 1);
+ Apply();
+ cam.clearFlags = prevFlags;
+ cam.backgroundColor = prevBg;
+ for (int i = 0; i < skinned.Length; i++)
+ skinned[i].forceMatrixRecalculationPerRender = forcePrev[i];
+ rt.Release();
+ DestroyImmediate(rt);
+ DestroyImmediate(readback);
+ }
+
+ UnityEditor.AssetDatabase.SaveAssets();
+ Debug.Log($"[PoseIconStage] Captured {written} pose icon(s) to {IconFolder}.");
+ }
+
+ /// Un-blends edges, content-crops with margin, squares, writes the PNG and imports it.
+ private static bool SaveIcon(Texture2D source, Color bg, string assetPath)
+ {
+ Color32[] px = source.GetPixels32();
+ int w = source.width, h = source.height;
+
+ // Content bounds from the alpha mask.
+ int minX = w, minY = h, maxX = -1, maxY = -1;
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ if (px[y * w + x].a > 0)
+ {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ if (maxX < 0)
+ {
+ Debug.LogWarning($"[PoseIconStage] Empty render for {assetPath} - nothing written.");
+ return false;
+ }
+
+ int bw = maxX - minX + 1, bh = maxY - minY + 1;
+ int margin = Mathf.RoundToInt(Mathf.Max(bw, bh) * CropMargin);
+ int size = Mathf.Max(bw, bh) + 2 * margin;
+ int offX = (size - bw) / 2, offY = (size - bh) / 2;
+
+ var outPx = new Color32[size * size];
+ for (int y = 0; y < bh; y++)
+ {
+ for (int x = 0; x < bw; x++)
+ {
+ Color32 c = px[(minY + y) * w + minX + x];
+ if (c.a > 0 && c.a < 255)
+ {
+ // Un-blend semi-transparent edge pixels against the background color
+ // (fg = (c - bg * (1 - a)) / a), otherwise the fringe keeps the bg tint.
+ float a = c.a / 255f;
+ c.r = (byte)Mathf.Clamp(Mathf.RoundToInt((c.r - bg.r * 255f * (1f - a)) / a), 0, 255);
+ c.g = (byte)Mathf.Clamp(Mathf.RoundToInt((c.g - bg.g * 255f * (1f - a)) / a), 0, 255);
+ c.b = (byte)Mathf.Clamp(Mathf.RoundToInt((c.b - bg.b * 255f * (1f - a)) / a), 0, 255);
+ }
+ outPx[(offY + y) * size + offX + x] = c;
+ }
+ }
+
+ var outTex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ outTex.SetPixels32(outPx);
+ byte[] png = outTex.EncodeToPNG();
+ DestroyImmediate(outTex);
+
+ System.IO.Directory.CreateDirectory(IconFolder);
+ System.IO.File.WriteAllBytes(assetPath, png);
+ UnityEditor.AssetDatabase.ImportAsset(assetPath);
+
+ // First-time import: match the settings of the existing UI icons.
+ var importer = (UnityEditor.TextureImporter)UnityEditor.AssetImporter.GetAtPath(assetPath);
+ if (importer.textureType != UnityEditor.TextureImporterType.Sprite)
+ {
+ importer.textureType = UnityEditor.TextureImporterType.Sprite;
+ importer.spriteImportMode = UnityEditor.SpriteImportMode.Single;
+ importer.mipmapEnabled = false;
+ importer.alphaIsTransparency = true;
+ importer.maxTextureSize = 256;
+ importer.SaveAndReimport();
+ }
+ return true;
+ }
+
+ private static void AssignIcon(PlayerPose pose, string assetPath)
+ {
+ var sprite = UnityEditor.AssetDatabase.LoadAssetAtPath(assetPath);
+ if (!sprite)
+ return;
+ var so = new UnityEditor.SerializedObject(pose);
+ so.FindProperty("icon").objectReferenceValue = sprite;
+ so.ApplyModifiedPropertiesWithoutUndo();
+ }
+#endif
}
diff --git a/Assets/Scripts/Components/UI/OverviewMenu.cs b/Assets/Scripts/Components/UI/OverviewMenu.cs
index 02d1907..2b79cf7 100644
--- a/Assets/Scripts/Components/UI/OverviewMenu.cs
+++ b/Assets/Scripts/Components/UI/OverviewMenu.cs
@@ -9,8 +9,7 @@ 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, 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
+/// while the session browser, match results or pose menu overlay is open instead, Esc closes that. 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. Hotkeys mirror the
@@ -124,13 +123,6 @@ public class OverviewMenu : MonoBehaviour
// pausing or switching modes here corrupts the camera rigs - Esc/Tab do nothing.
else if (MatchManager.Instance && MatchManager.Instance.IsLoadingLocally)
{ }
- // Paint mode: Esc exits back to the camera mode instead of opening the menu. Player Control
- // first; the hunter's role-scoped set has none, so it falls through to First Person.
- else if (gameMode && gameMode.EffectiveMode is PaintMode)
- {
- if (!gameMode.TrySwitchTo() && !gameMode.TrySwitchTo())
- Open();
- }
// No active/paused mode = the inert no-player state (pre-lobby Main, where the session panel
// is always shown) - nothing to pause, don't open.
else if (gameMode && gameMode.EffectiveMode != null)
diff --git a/Assets/Scripts/Components/UI/PoseMenu.cs b/Assets/Scripts/Components/UI/PoseMenu.cs
index 6d3e2df..796a007 100644
--- a/Assets/Scripts/Components/UI/PoseMenu.cs
+++ b/Assets/Scripts/Components/UI/PoseMenu.cs
@@ -132,6 +132,7 @@ public class PoseMenu : MonoBehaviour
icon.sprite = p ? p.Icon : null;
icon.preserveAspect = true;
icon.enabled = icon.sprite; // no icon authored -> no solid white square
+ icon.rectTransform.localScale = Vector3.one * (p ? p.IconScale : 1f);
}
TMP_Text label = item.GetComponentInChildren(true);
if (label) label.text = p && !string.IsNullOrEmpty(p.Title) ? p.Title : (p ? p.name : "");
diff --git a/Assets/Scripts/ScriptableObject/PlayerPose.cs b/Assets/Scripts/ScriptableObject/PlayerPose.cs
index 3365272..bb250f3 100644
--- a/Assets/Scripts/ScriptableObject/PlayerPose.cs
+++ b/Assets/Scripts/ScriptableObject/PlayerPose.cs
@@ -15,6 +15,11 @@ public class PlayerPose : ScriptableObject
[Tooltip("Display name shown for this pose in the pose selection menu.")]
[SerializeField] private string title;
+ [Tooltip("Scale multiplier applied to the icon in the pose selection menu. 1 = fills the button's " +
+ "icon area (aspect preserved).")]
+ [Range(0.1f, 2f)]
+ [SerializeField] private float iconScale = 1f;
+
[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.")]
@@ -77,6 +82,7 @@ public class PlayerPose : ScriptableObject
// Read-only accessors; semantics are documented on the serialized fields' tooltips above.
public Sprite Icon => icon;
public string Title => title;
+ public float IconScale => iconScale;
public AnimationClip Clip => clip;
public float ClipSpeed => clipSpeed;
public AnimationClip WalkClip => walkClip;