DEV Community

John
John

Posted on

Dev Log 16 - Runtime Editor

📜 Dev Log — RuntimeEditor: In‑Editor Full Loadout & Paging Tool
Date: 04 September 2025
Module: RuntimeEditor.cs
Purpose: Provide a full, live‑in‑Editor preview of the player’s gear loadout and inventory slot blocks without entering Play Mode, with manual paging for visual inspection.

Not much humour in this log, but a more serious post for my reference and any "watchers". Progress Continues...

🎯 Objective
Create an Editor‑time runtime simulation that:

Equips exactly one item per GearEquipSlotEnum from existing .asset GearItem ScriptableObjects.

Feeds those items into PlayerInventoryManager so it generates the correct storage slot blocks for any gear that provides inventory space.

Allows manual paging of the SlotBlockContainer in Edit Mode for visual inspection, bypassing the fact that Unity’s ScrollRect doesn’t respond to input outside Play Mode.

Provides Inspector buttons for quick access to preview generation and paging.

⚙️ Implementation Details
Core Features:

Asset Scanning — Uses AssetDatabase.FindAssets("t:GearItem") to locate all GearItem assets, loads them, groups by EquipSlot, and ensures each slot type gets exactly one representative item. - Changed Method to find via Display Name of gear asset, and manually added the 14 chosen reference items, after adjusting so they all had a slot block title and at least one slot block generated (even on items that will hold 0) for testing purposes.

Full Loadout Assembly — Iterates over all enum values, picks one valid item per slot, builds a List representing the complete loadout.

Inventory Population — Calls PlayerInventoryManager.PreloadGear(previewItems) and InitializeInventory(previewItems) to generate slot blocks in the UI.

Manual Paging — Stores a reference to the ScrollRect and adjusts verticalNormalizedPosition by a configurable pageScrollAmount for Page Up/Down.

Custom Inspector — Adds clickable Generate Preview, Clear Preview, Page Up, and Page Down buttons.

📈 Workflow Impact
Before:

Had to enter Play Mode, equip items manually, and navigate the UI to see a full loadout.

No way to scroll inventory in Edit Mode.

After:

One click in the Inspector instantly populates the UI with a complete, valid loadout.

Storage slot blocks appear exactly as they would in‑game.

Page Up / Down buttons allow rapid inspection without Play Mode.

💡 Future Enhancements
Looping paging.

Temporary highlighting of generated blocks.

Filtered previews by faction/rarity/tag.

Auto‑refresh on asset changes.

📜 Dev Log – RuntimeEditor & Inventory UI Iteration
Phase 1 – The First Breakpoint

Symptom: Destroy may not be called from edit mode! error.

Fix: Branch ClearSlotBlocks() to use DestroyImmediate() in Edit Mode.

Phase 2 – The “Too Many / Too Few” Problem

Symptom: Wrong number of blocks.

Fix: Priority selection → replaced with fixed 14‑item mapping by DisplayName.

Phase 3 – Toggle Control & Safety

Added RuntimeEditor Test – Active checkbox, cached originals, restored on clear.

Phase 4 – Scroll View Paging

Switched to scrollRect.verticalNormalizedPosition for proper Edit Mode scrolling.

Phase 5 – Missing Titles & Slot Grids

Routed preview generation through GenerateSlotBlocks() for full runtime‑accurate UI.

Phase 6 – Regeneration on Demand

Bypassed hasInitializedInventory guard, always rebuild on Generate click.

Phase 7 – Final Merge

Combined all fixes into safe, restore‑aware RuntimeEditor with overview, paging, fixed mapping, and Hands skipped.

📚 Lessons Learned
Editor vs Play Mode Destruction — Always branch between Destroy() and DestroyImmediate().

Deterministic Test Data — Fixed mappings make layout debugging reproducible.

Non‑Destructive Previews — Cache/hide originals instead of destroying them.

UI Component Awareness — Use verticalNormalizedPosition for ScrollRects in Edit Mode.

Reuse Runtime Logic — Call the same methods the game uses for accuracy.

Bypass Guards in Test Contexts — Skip runtime flags that block test harnesses.

Iterative Debugging Pays Off — Each fix revealed the next bottleneck.

🏁 Final Change Entry – Phase 8: Instant Hierarchy Refresh
Symptom: After clearing the preview, the hierarchy sometimes still showed preview blocks until a manual refresh or scene reload. Cause: Unity doesn’t always repaint the hierarchy immediately after DestroyImmediate() in Edit Mode. Action: Added:

csharp
EditorApplication.DirtyHierarchyWindowSorting();
EditorApplication.RepaintHierarchyWindow();
at the end of ClearPreview() to force an immediate refresh. Result: Hierarchy now updates instantly when clearing or toggling off — no more scene‑swap trick.

Before:

Clearing preview removed objects but hierarchy view lagged behind.

Could cause confusion about whether preview blocks were still present.

After:

Clearing preview triggers immediate repaint.

Visual feedback matches actual scene state instantly.

Combined with original block/data restoration, toggling off is seamless and accurate.

Here is my final version of this code,

csharp
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine.UI;
#endif
using UnityEngine;
using System.Linq;
using System.Collections.Generic;

[ExecuteAlways]
public class RuntimeEditor : MonoBehaviour
{
    [Header("RuntimeEditor Test")]
    public bool active = true;

    [Header("Preview Settings")]
    public bool previewInEditor = true;
    [Tooltip("Scroll amount for Page Up/Down (0–1 range for ScrollRect)")]
    public float pageScrollAmount = 0.2f;

    [Header("References")]
    public PlayerInventoryManager inventoryManager; // Assign in Inspector
    public ScrollRect scrollRect;                   // Assign your Slot Block Scroll View here

#if UNITY_EDITOR
    private readonly List<GameObject> originalBlocks = new List<GameObject>();
    private readonly List<GameObject> spawnedBlocks = new List<GameObject>();
    private List<GearItem> originalEquippedGear = new List<GearItem>();

    // Fixed test list mapped to your enum (Hands skipped)
    private readonly List<(GearEquipSlotEnum slot, string displayName)> testItems =
        new List<(GearEquipSlotEnum, string)>
    {
        (GearEquipSlotEnum.Backpack,   "Small Paramedic Backpack"),
        (GearEquipSlotEnum.Face,       "Fisherman's Face Cover"),
        (GearEquipSlotEnum.Head,       "Cowboy Hat"),
        (GearEquipSlotEnum.Eyewear,    "Tanker Goggles"),
        (GearEquipSlotEnum.Neck,       "Winter Neck Warmer"),
        (GearEquipSlotEnum.Torso_Inner,"Plain White Cotton Shirt"),
        (GearEquipSlotEnum.Torso_Outer,"Faded Denim Jacket"),
        (GearEquipSlotEnum.Vest,       "Duck Hunter's Vest"),
        (GearEquipSlotEnum.Wrist,      "Red Sweatband"),
        (GearEquipSlotEnum.Legs,       "Faded Denim Jeans"),
        (GearEquipSlotEnum.Feet_Inner, "NBC Inner Footliner"),
        (GearEquipSlotEnum.Feet_Outer, "Firefighter Boots"),
        (GearEquipSlotEnum.Gloves,     "Firefighter Heat Resistant Gloves"),
        (GearEquipSlotEnum.Belt,       "Firefighter Safety Belt")
    };

    private void CacheOriginalState()
    {
        originalBlocks.Clear();
        foreach (Transform child in inventoryManager.SlotBlockContainer)
            originalBlocks.Add(child.gameObject);

        originalEquippedGear = new List<GearItem>(inventoryManager.EquippedGear);
    }

    private void HideOriginalBlocks()
    {
        foreach (var block in originalBlocks)
            if (block != null) block.SetActive(false);
    }

    private void ShowOriginalBlocks()
    {
        foreach (var block in originalBlocks)
            if (block != null) block.SetActive(true);
    }

    public void GeneratePreview()
    {
        if (inventoryManager == null)
        {
            Debug.LogWarning("⚠️ Assign PlayerInventoryManager.");
            return;
        }

        // Cache originals if not already done
        if (originalBlocks.Count == 0 && originalEquippedGear.Count == 0)
            CacheOriginalState();

        // Hide originals
        HideOriginalBlocks();

        // Clear any previous preview
        ClearPreview(false); // false = don't restore originals yet

        if (!active) return;

        // Load all GearItems from Assets/GearAssets
        string[] guids = AssetDatabase.FindAssets("t:GearItem", new[] { "Assets/GearAssets" });
        var allItems = guids
            .Select(guid => AssetDatabase.LoadAssetAtPath<GearItem>(AssetDatabase.GUIDToAssetPath(guid)))
            .Where(item => item != null)
            .ToList();

        // Build preview list
        var previewItems = new List<GearItem>();
        foreach (var (slot, displayName) in testItems)
        {
            var chosen = allItems.FirstOrDefault(i => i.DisplayName == displayName);
            if (chosen != null)
                previewItems.Add(chosen);
            else
                Debug.LogWarning($"[RuntimeEditor] Missing: '{displayName}'");
        }

        // Force‑load into inventory and rebuild
        inventoryManager.EquippedGear = previewItems;
        inventoryManager.GenerateSlotBlocks(); // Direct call ensures rebuild every time

        // Track spawned blocks
        foreach (Transform child in inventoryManager.SlotBlockContainer)
            spawnedBlocks.Add(child.gameObject);

        Debug.Log($"✅ Preview generated with {spawnedBlocks.Count} blocks.");
    }

    public void ClearPreview(bool restoreOriginals = true)
    {
        // Destroy preview blocks
        foreach (var go in spawnedBlocks)
            if (go != null) DestroyImmediate(go);
        spawnedBlocks.Clear();

        if (restoreOriginals)
        {
            // Restore original data
            inventoryManager.EquippedGear = new List<GearItem>(originalEquippedGear);

            // Restore original UI
            ShowOriginalBlocks();

            // Rebuild from restored EquippedGear (handles empty default state too)
            inventoryManager.GenerateSlotBlocks();
        }

#if UNITY_EDITOR
        // Force hierarchy refresh so UI updates instantly
        EditorApplication.DirtyHierarchyWindowSorting();
        EditorApplication.RepaintHierarchyWindow();
#endif
    }

    public void PageUp()
    {
        if (scrollRect != null)
            scrollRect.verticalNormalizedPosition = Mathf.Clamp01(scrollRect.verticalNormalizedPosition + pageScrollAmount);
    }

    public void PageDown()
    {
        if (scrollRect != null)
            scrollRect.verticalNormalizedPosition = Mathf.Clamp01(scrollRect.verticalNormalizedPosition - pageScrollAmount);
    }

    public IEnumerable<string> GetGeneratedSlotNames()
    {
        foreach (var (slot, displayName) in testItems)
            yield return $"{slot} → {displayName}";
        yield return "Hands → (Skipped)";
    }
#endif

    private void OnEnable()
    {
#if UNITY_EDITOR
        if (!Application.isPlaying && previewInEditor)
            GeneratePreview();
#endif
    }

    private void Update()
    {
#if UNITY_EDITOR
        if (!Application.isPlaying)
        {
            if (active && spawnedBlocks.Count == 0)
                GeneratePreview();
            else if (!active && spawnedBlocks.Count > 0)
                ClearPreview(true);
        }
#endif
    }
}

#if UNITY_EDITOR
[CustomEditor(typeof(RuntimeEditor))]
public class RuntimeEditorInspector : Editor
{
    public override void OnInspectorGUI()
    {
        RuntimeEditor script = (RuntimeEditor)target;

        script.active = EditorGUILayout.Toggle("RuntimeEditor Test - Active", script.active);

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Preview Controls", EditorStyles.boldLabel);
        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("Generate Preview"))
            script.GeneratePreview();
        if (GUILayout.Button("Clear Preview"))
            script.ClearPreview(true);
        EditorGUILayout.EndHorizontal();

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Paging", EditorStyles.boldLabel);
        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("⬆️ Page Up")) script.PageUp();
        if (GUILayout.Button("⬇️ Page Down")) script.PageDown();
        EditorGUILayout.EndHorizontal();

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Generated Slots Overview", EditorStyles.boldLabel);
        EditorGUI.BeginDisabledGroup(true);
        foreach (var line in script.GetGeneratedSlotNames())
            EditorGUILayout.LabelField(line);
        EditorGUI.EndDisabledGroup();

        EditorGUILayout.Space();
        DrawDefaultInspector();
    }
}
#endif
Enter fullscreen mode Exit fullscreen mode

The PlayerInventoryManager it works with is also here for reference

using UnityEngine;
using System.Collections.Generic;
using TMPro;

public class PlayerInventoryManager : MonoBehaviour
{
    [Header("Equipped Gear")]
    public List<GearItem> EquippedGear = new(); // Runtime gear worn by player

    [Header("UI References")]
    public Transform SlotBlockContainer;         // Parent transform for slot blocks
    public GameObject SlotBlockPrefab;           // Prefab with title + slot grid
    public GameObject InventorySlotPrefab;       // Individual slot prefab

    private Dictionary<string, GameObject> activeSlotBlocks = new(); // Tracks instantiated blocks by itemID
    private bool hasInitializedInventory = false; // Tracks whether inventory has been populated

    void Awake()
    {
        Debug.Log($"[InventoryManager] Awake. hasInitializedInventory = {hasInitializedInventory}");
    }

    public void PreloadGear(List<GearItem> gearList)
    {
        EquippedGear = gearList;
        Debug.Log($"[InventoryManager] Preloaded {EquippedGear.Count} gear items.");
    }

    public void OnInventoryOpened()
    {
        Debug.Log("[InventoryManager] 🔥 Button pressed. OnInventoryOpened() triggered.");

        if (!hasInitializedInventory)
        {
            Debug.Log("[InventoryManager] Inventory opened for the first time. Initializing slot blocks.");
            InitializeInventory(EquippedGear);
            hasInitializedInventory = true;
        }
        else
        {
            Debug.Log("[InventoryManager] Inventory already initialized. Skipping slot block generation.");
        }
    }

    public void InitializeInventory(List<GearItem> startingGear)
    {
        EquippedGear = startingGear;

        Debug.Log($"[InventoryManager] Initializing inventory with {EquippedGear.Count} gear items.");
        foreach (var gear in EquippedGear)
        {
            Debug.Log($"[InventoryManager] Equipped: {gear.ItemID} | BlockName: {gear.SlotBlockName} | Slots: {gear.InventorySlotsProvided}");
        }

        GenerateSlotBlocks();
    }

    public void GenerateSlotBlocks()
    {
        ClearSlotBlocks();

        Debug.Log($"[InventoryManager] Generating slot blocks for {EquippedGear.Count} gear items.");

        foreach (var gear in EquippedGear)
        {
            if (gear == null || gear.InventorySlotsProvided <= 0)
            {
                Debug.LogWarning($"[InventoryManager] Skipping gear: {gear?.ItemID ?? "null"} | Slots: {gear?.InventorySlotsProvided ?? -1}");
                continue;
            }

            Debug.Log($"[InventoryManager] Creating block for: {gear.ItemID} | BlockName: {gear.SlotBlockName} | Slots: {gear.InventorySlotsProvided}");

            GameObject block = Instantiate(SlotBlockPrefab, SlotBlockContainer);
            block.name = $"SlotBlock_{gear.ItemID}";
            block.SetActive(true);
            Debug.Log($"[InventoryManager] Block instantiated: {block.name} | ActiveInHierarchy: {block.activeInHierarchy}");

            var titleText = block.GetComponentInChildren<TMP_Text>();
            if (titleText != null)
            {
                titleText.text = gear.SlotBlockName;
                Debug.Log($"[InventoryManager] Title set: {titleText.text}");
            }
            else
            {
                Debug.LogWarning($"[InventoryManager] Title text not found in block prefab.");
            }

            Transform slotGrid = block.transform.Find("SlotGrid");
            if (slotGrid == null)
            {
                Debug.LogError($"[InventoryManager] SlotGrid not found in SlotBlockPrefab.");
                continue;
            }

            for (int i = 0; i < gear.InventorySlotsProvided; i++)
            {
                GameObject slot = Instantiate(InventorySlotPrefab, slotGrid);
                slot.name = $"Slot_{gear.ItemID}_{i}";
                slot.SetActive(true);
                slot.transform.SetAsLastSibling();
                Debug.Log($"[InventoryManager] Slot created: {slot.name} | ActiveInHierarchy: {slot.activeInHierarchy}");

                RectTransform rt = slot.GetComponent<RectTransform>();
                if (rt != null)
                {
                    Debug.Log($"[InventoryManager] Slot RectTransform size: {rt.sizeDelta} | Anchors: {rt.anchorMin} to {rt.anchorMax}");
                }
                else
                {
                    Debug.LogWarning($"[InventoryManager] RectTransform missing on slot: {slot.name}");
                }

                if (i < gear.ContainedItems.Count && gear.ContainedItems[i] != null)
                {
                    var slotUI = slot.GetComponent<InventorySlotUI>();
                    if (slotUI != null)
                    {
                        slotUI.LoadItem(gear.ContainedItems[i]);
                        Debug.Log($"[InventoryManager] Item loaded into slot: {gear.ContainedItems[i].ItemID}");
                    }
                    else
                    {
                        Debug.LogWarning($"[InventoryManager] InventorySlotUI missing on slot: {slot.name}");
                    }
                }
            }

            activeSlotBlocks[gear.ItemID] = block;
        }
    }

    public void ClearSlotBlocks()
    {
        Debug.Log($"[InventoryManager] Clearing {SlotBlockContainer.childCount} slot blocks.");

#if UNITY_EDITOR
        if (!Application.isPlaying)
        {
            foreach (Transform child in SlotBlockContainer)
            {
                DestroyImmediate(child.gameObject);
            }
            activeSlotBlocks.Clear();
            return;
        }
#endif

        foreach (Transform child in SlotBlockContainer)
        {
            Destroy(child.gameObject);
        }
        activeSlotBlocks.Clear();
    }

    public void UnequipGear(GearItem gear)
    {
        if (!EquippedGear.Contains(gear)) return;

        Debug.Log($"[InventoryManager] Unequipping gear: {gear.ItemID}");

        if (activeSlotBlocks.TryGetValue(gear.ItemID, out var block))
        {
            gear.ContainedItems.Clear();
            Transform slotGrid = block.transform.Find("SlotGrid");

            foreach (Transform slot in slotGrid)
            {
                var slotUI = slot.GetComponent<InventorySlotUI>();
                if (slotUI != null && slotUI.HasItem())
                {
                    gear.ContainedItems.Add(slotUI.GetItem());
                    Debug.Log($"[InventoryManager] Item saved from slot: {slotUI.GetItem().ItemID}");
                }
            }
        }

        EquippedGear.Remove(gear);
        GenerateSlotBlocks();
    }

    public void EquipGear(GearItem gear)
    {
        if (EquippedGear.Contains(gear)) return;

        Debug.Log($"[InventoryManager] Equipping gear: {gear.ItemID}");

        EquippedGear.Add(gear);
        GenerateSlotBlocks();
    }
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

A powerful in‑Editor tool has been built to let me adjust slot block layouts in real time, with a live visual reference to every change. No more entering Play Mode, making adjustments, trying to memorise them, then returning to Edit Mode to apply them blindly. This workflow now allows me to shape a fully functional inventory slot system that is visually precise, structurally solid, and exactly aligned with my intended layout — without guesswork. It’s a huge time‑saver and a major step toward a polished, production‑ready UI. While I don’t plan to share much of the underlying code, this stands as a strong reference point for the kind of editor‑time tooling that can transform iteration speed and design accuracy.

"With the RuntimeEditor now forged into a safe, precise, and instant‑feedback tool, the guesswork is gone. Every slot, every block, every scroll is under my control in Edit Mode. This chapter closes with the inventory UI no longer a moving target, but a finished canvas I can refine at will — the grind continues, but the foundation is solid."

Top comments (0)