From 5f5892047c6bb5bda317ea6100508523782f5bd8 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 12 Jul 2026 21:08:56 +0200 Subject: [PATCH] Add pose icon capture pipeline, icon scale, Esc/Tab opens overview in paint mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Assets/Scripts/Components/PoseIconStage.cs | 208 +++++++++++++++++- Assets/Scripts/Components/UI/OverviewMenu.cs | 10 +- Assets/Scripts/Components/UI/PoseMenu.cs | 1 + Assets/Scripts/ScriptableObject/PlayerPose.cs | 6 + 4 files changed, 214 insertions(+), 11 deletions(-) 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;