Add pose icon capture pipeline, icon scale, Esc/Tab opens overview in paint mode

- PoseIconStage: permanent editor capture pipeline (context menu 'Capture All Icons') — renders each entry at 4096x4096, alpha-crops, writes Icon_pose_*.png and wires sprites onto matching PlayerPose assets
- PlayerPose: new iconScale property (0.1-2), applied to the icon child in PoseMenu's radial layout
- OverviewMenu: paint-mode special-case removed — Esc/Tab opens the overview menu in every mode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-12 21:08:56 +02:00
1 parent 2de153d8fe
commit 5f5892047c
4 files changed
+214 -11

No files matched your search

+206 -2
View File
@@ -4,7 +4,8 @@ using UnityEngine;
/// <summary>
/// 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.
/// </summary>
[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<Animator>();
_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_<name>.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<PlayerPose>();
foreach (string guid in UnityEditor.AssetDatabase.FindAssets("t:PlayerPose"))
poses.Add(UnityEditor.AssetDatabase.LoadAssetAtPath<PlayerPose>(
UnityEditor.AssetDatabase.GUIDToAssetPath(guid)));
// Skinned meshes only follow sampled bones in unfocused/background renders when forced.
var skinned = target.GetComponentsInChildren<SkinnedMeshRenderer>(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}.");
}
/// <summary>Un-blends edges, content-crops with margin, squares, writes the PNG and imports it.</summary>
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<Sprite>(assetPath);
if (!sprite)
return;
var so = new UnityEditor.SerializedObject(pose);
so.FindProperty("icon").objectReferenceValue = sprite;
so.ApplyModifiedPropertiesWithoutUndo();
}
#endif
}
+1 -9
View File
@@ -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 <see cref="GameModeManager.SetPaused"/>);
/// 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 <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. 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<PlayerControlMode>() && !gameMode.TrySwitchTo<FirstPersonMode>())
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)
+1
View File
@@ -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<TMP_Text>(true);
if (label) label.text = p && !string.IsNullOrEmpty(p.Title) ? p.Title : (p ? p.name : "");
@@ -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;