using System; using System.Collections.Generic; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using Ashwild.Building; using Ashwild.Inventory; namespace Ashwild.EditorTools { /// /// The custom editor for a BuildableData shown in the right pane of the Ashwild Database. Replaces /// the default ScriptableObject inspector with a hero header (large clickable icon, editable name, /// "Buildable" badge) and cards for the identity (description), the ghost/built prefabs, the resource /// cost, and the placed structure's health. The cost card reuses the recipe editor's interactive chip /// strip — one clickable item card (with a −/+ quantity stepper and a remove button) per cost line, /// joined by "+", followed by a dashed add tile — so authoring a price feels identical to authoring a /// recipe. All fields bind live to the asset; changing the name or icon notifies the list so the row /// updates without a full rebuild. /// public class BuildableEditorView { #region State public VisualElement Root { get; } private const int IconPickerControlId = 0x41534842; private const int CostPickerControlId = 0x41534843; private readonly BuildableData buildable; private readonly SerializedObject so; private readonly SerializedProperty costProp; private readonly Action onMetaChanged; private readonly Action onAssetChanged; private VisualElement iconPreview; private VisualElement costStrip; private Action costPickHandler; private bool costPickCommitsOnCloseOnly; private GameObject generationSource; private BuildSnapLayout generationLayout = BuildSnapLayout.None; #endregion #region Construction /// /// Builds the full editor tree for a buildable. is raised when /// the display name or icon changes so the list can refresh that row; /// re-renders the whole view after a structural change (prefab /// generation, asset rename) that the live bindings alone cannot reflect. /// public BuildableEditorView(BuildableData buildable, Action onMetaChanged, Action onAssetChanged) { this.buildable = buildable; this.onMetaChanged = onMetaChanged; this.onAssetChanged = onAssetChanged; so = new SerializedObject(buildable); costProp = so.FindProperty("cost"); Root = new VisualElement(); Root.Add(BuildHero()); Root.Add(BuildIdentityCard()); Root.Add(BuildPrefabsCard()); Root.Add(BuildCostCard()); Root.Add(BuildPlacementCard()); Root.Add(BuildHealthCard()); Root.Add(BuildPickerProxy()); RefreshCost(); } #endregion #region Hero /// /// Builds the hero header: a large clickable icon preview, the editable display name and the /// "Buildable" badge. Name/icon changes notify the list so its row follows immediately. /// private VisualElement BuildHero() { VisualElement hero = new VisualElement(); hero.AddToClassList("ash-hero"); iconPreview = new VisualElement(); iconPreview.AddToClassList("ash-hero__icon"); iconPreview.AddToClassList("ash-hero__icon--clickable"); iconPreview.tooltip = "Click to change the buildable icon"; iconPreview.RegisterCallback(_ => OpenIconPicker()); UpdateIconPreview(buildable.Icon); hero.Add(iconPreview); IMGUIContainer iconPickerProxy = new IMGUIContainer(HandleIconPickerCommands); iconPickerProxy.style.position = Position.Absolute; iconPickerProxy.style.width = 1; iconPickerProxy.style.height = 1; hero.Add(iconPickerProxy); VisualElement info = new VisualElement(); info.AddToClassList("ash-hero__info"); TextField nameField = new TextField { value = buildable.DisplayName }; nameField.AddToClassList("ash-hero__name"); nameField.BindProperty(so.FindProperty("displayName")); nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke()); nameField.RegisterCallback(_ => SyncAssetName()); info.Add(AshwildUI.EditableNameRow(nameField)); VisualElement typeRow = new VisualElement(); typeRow.AddToClassList("ash-hero__typerow"); typeRow.Add(AshwildUI.Badge("Buildable", AshwildUI.BuildableColor)); info.Add(typeRow); hero.Add(info); return hero; } /// /// Renames the asset file to match the display name once the designer leaves the name field, so a /// buildable titled "Wood Wall" stops living on disk as "NewBuildable 2". Deliberately fired on /// focus-out rather than on every keystroke — renaming per character would spam the AssetDatabase /// and leave a trail of half-typed file names. Re-renders the view so the header reflects the new /// asset identity. /// private void SyncAssetName() { if (BuildableAssetFactory.RenameAssetToDisplayName(buildable)) onAssetChanged?.Invoke(); } /// /// Shows the buildable's icon in the hero preview, or a neutral placeholder when none is set. /// private void UpdateIconPreview(Sprite sprite) { iconPreview.style.backgroundImage = sprite != null ? new StyleBackground(sprite) : new StyleBackground(); } /// /// Opens Unity's sprite object picker, seeded with the current icon, so the designer can change /// the buildable icon by clicking the hero preview directly. /// private void OpenIconPicker() { EditorGUIUtility.ShowObjectPicker(buildable.Icon, false, string.Empty, IconPickerControlId); } /// /// Listens (through the hidden IMGUI proxy) for the picker selecting/closing on our control id /// and applies the chosen sprite live, so the hero preview and the list row update immediately. /// private void HandleIconPickerCommands() { Event evt = Event.current; if (evt == null || evt.type != EventType.ExecuteCommand) return; if (EditorGUIUtility.GetObjectPickerControlID() != IconPickerControlId) return; if (evt.commandName != "ObjectSelectorUpdated" && evt.commandName != "ObjectSelectorClosed") return; AssignIcon(EditorGUIUtility.GetObjectPickerObject() as Sprite); } /// /// Writes the picked icon onto the asset (no-op when unchanged) and refreshes the hero preview /// and the list row. /// private void AssignIcon(Sprite sprite) { SerializedProperty iconProp = so.FindProperty("icon"); if (iconProp.objectReferenceValue == sprite) return; iconProp.objectReferenceValue = sprite; so.ApplyModifiedProperties(); UpdateIconPreview(sprite); onMetaChanged?.Invoke(); } #endregion #region Identity & Prefab Cards /// /// Identity card: the description shown on the menu card, plus the icon field (kept in sync /// with the hero preview and the list row when edited here). /// private VisualElement BuildIdentityCard() { VisualElement card = AshwildUI.Card("Identity"); TextField description = new TextField("Description") { multiline = true }; description.AddToClassList("ash-description"); description.BindProperty(so.FindProperty("description")); card.Add(description); ObjectField icon = new ObjectField("Icon") { objectType = typeof(Sprite), allowSceneObjects = false }; icon.BindProperty(so.FindProperty("icon")); icon.RegisterValueChangedCallback(evt => { UpdateIconPreview(evt.newValue as Sprite); onMetaChanged?.Invoke(); }); card.Add(icon); return card; } /// /// Prefabs card: the semi-transparent ghost spawned while positioning and the real structure /// spawned once placement is confirmed. When either slot is still empty the card also offers the /// one-click generator below the fields, so the common path (drop a mesh in, press Generate) never /// leaves the window. The generator's visibility is resolved when the view is built rather than /// bound to the two fields: BindProperty raises a change event on its initial bind, so re-rendering /// from those callbacks would loop the view rebuild endlessly. Generating re-renders explicitly, /// and a hand-assigned prefab is picked up the next time the buildable is selected. /// private VisualElement BuildPrefabsCard() { VisualElement card = AshwildUI.Card("Prefabs"); ObjectField ghost = new ObjectField("Ghost Prefab") { objectType = typeof(GameObject), allowSceneObjects = false }; ghost.BindProperty(so.FindProperty("ghostPrefab")); card.Add(ghost); ObjectField built = new ObjectField("Built Prefab") { objectType = typeof(GameObject), allowSceneObjects = false }; built.BindProperty(so.FindProperty("builtPrefab")); card.Add(built); if (buildable.GhostPrefab == null || buildable.BuiltPrefab == null) card.Add(BuildGeneratorBlock()); return card; } /// /// The generator shown while a prefab slot is empty: pick a source model or prefab, tick which /// socket sets the piece should offer, and press the button to author the missing prefab(s) — /// collider, gameplay components, networking and snap sockets included. Only the empty slots are /// generated, so regenerating a ghost never clobbers a built prefab that has already been hand-tuned. /// /// The layout is a mask rather than a single choice because one piece usually offers several kinds /// of connection: a floor both chains to other floors and hosts walls. Choosing a source pre-ticks /// the set inferred from the mesh proportions, which the designer is free to change. /// private VisualElement BuildGeneratorBlock() { VisualElement block = new VisualElement(); block.AddToClassList("ash-generator"); Label heading = new Label("Generate from a source model"); heading.AddToClassList("ash-generator__title"); block.Add(heading); MaskField layoutField = null; ObjectField sourceField = new ObjectField("Source Mesh / Prefab") { objectType = typeof(GameObject), allowSceneObjects = false, tooltip = "The authored model (FBX) or prefab to wrap. It is nested as a child, never flattened, so you can keep editing it." }; sourceField.RegisterValueChangedCallback(evt => { generationSource = evt.newValue as GameObject; generationLayout = BuildableAssetFactory.InferLayout(generationSource); layoutField?.SetValueWithoutNotify((int)generationLayout); }); block.Add(sourceField); layoutField = new MaskField( "Snap Layout", new List { "Floor Edges", "Wall Mounts", "Wall Body", "Roof Mount", "Roof Body", "Pillar Caps" }, (int)generationLayout) { tooltip = "Which sockets to place — combinable. A floor slab usually wants Floor Edges (chain to other slabs) AND Wall Mounts (walls stand on its edges); a wall piece wants Wall Body." }; layoutField.RegisterValueChangedCallback(evt => generationLayout = (BuildSnapLayout)evt.newValue); block.Add(layoutField); Button generate = new Button(GenerateMissingPrefabs) { text = DescribeGeneration() }; generate.AddToClassList("ash-btn"); generate.AddToClassList("ash-btn--primary"); block.Add(generate); Label hint = new Label("Sockets are placed from the mesh bounds: floor edges face outward, wall mounts face up, a wall's foot faces down, and the roof mount is a single socket centred on the top face. Treat the layout as a starting point — nudge them in the prefab afterwards."); hint.AddToClassList("ash-generator__hint"); block.Add(hint); return block; } /// /// Labels the generate button with exactly what it will create, so the designer can tell at a /// glance whether pressing it touches one slot or both. /// private string DescribeGeneration() { bool needsBuilt = buildable.BuiltPrefab == null; bool needsGhost = buildable.GhostPrefab == null; if (needsBuilt && needsGhost) return "Generate Built + Ghost Prefabs"; return needsBuilt ? "Generate Built Prefab" : "Generate Ghost Prefab"; } /// /// Runs the generation for whichever slots are empty and re-renders the view so the new prefabs /// appear in their fields and the generator collapses away. /// private void GenerateMissingPrefabs() { bool generated = BuildableAssetFactory.GeneratePrefabs( buildable, generationSource, generationLayout, buildable.BuiltPrefab == null, buildable.GhostPrefab == null); if (generated) onAssetChanged?.Invoke(); } /// /// Health card: the hit points the placed structure starts with, edited as a plain float field. /// An empty cost makes the build free; likewise this drives the proportional demolition refund. /// private VisualElement BuildHealthCard() { VisualElement card = AshwildUI.Card("Health"); FloatField maxHealth = new FloatField("Max Health"); maxHealth.BindProperty(so.FindProperty("maxHealth")); card.Add(maxHealth); return card; } /// /// Placement card: what this structure may rest on. Generating the prefabs sets it from the snap /// layout, so this is the override for a piece the layout cannot classify — or for a buildable /// authored before the rule existed, which defaults to the permissive Ground Or Snap. /// private VisualElement BuildPlacementCard() { VisualElement card = AshwildUI.Card("Placement"); EnumField support = new EnumField("Support") { tooltip = "Ground Or Snap: free placement anywhere the aim lands, or connected. Snap Only: valid solely while connected to a matching socket — use it for walls, ceilings and roofs so they cannot be dropped in mid-air." }; support.BindProperty(so.FindProperty("support")); card.Add(support); return card; } #endregion #region Cost Card /// /// Cost card: an interactive chip strip mirroring the recipe editor. Each cost line is a clickable /// item card with a −/+ quantity stepper and a remove button, the lines are joined by "+", and a /// dashed add tile appends a new line. An empty strip means a free build. /// private VisualElement BuildCostCard() { VisualElement card = AshwildUI.Card("Cost"); costStrip = new VisualElement(); costStrip.AddToClassList("ash-equation"); card.Add(costStrip); return card; } /// /// Repaints the whole cost strip: one interactive card per cost line (joined by "+"), then the /// dashed add tile. Shows only the add tile when the build is free. /// private void RefreshCost() { costStrip.Clear(); int count = costProp.arraySize; for (int i = 0; i < count; i++) { if (i > 0) costStrip.Add(Operator("+")); costStrip.Add(BuildCostCardChip(i)); } if (count > 0) costStrip.Add(Operator("+")); costStrip.Add(BuildAddTile()); } /// /// Builds the interactive card for the cost line at : a clickable icon /// (change item), the name, a −/+ quantity stepper, and a remove button. /// private VisualElement BuildCostCardChip(int index) { SerializedProperty element = costProp.GetArrayElementAtIndex(index); ItemData item = element.FindPropertyRelative("item").objectReferenceValue as ItemData; int quantity = element.FindPropertyRelative("quantity").intValue; VisualElement card = MakeCard(item, () => OpenItemPicker(item, true, picked => SetCostItem(index, picked))); card.Add(Stepper(quantity, delta => AdjustCostQuantity(index, delta))); Button remove = new Button(() => RemoveCost(index)) { text = "✕" }; remove.AddToClassList("ash-rchip__remove"); card.Add(remove); return card; } /// /// Builds the dashed "+" tile that appends a new cost line once an item is picked. /// private VisualElement BuildAddTile() { VisualElement tile = new VisualElement(); tile.AddToClassList("ash-rchip"); tile.AddToClassList("ash-rchip--add"); tile.tooltip = "Add a cost line"; tile.RegisterCallback(_ => OpenItemPicker(null, false, AddCost)); Label plus = new Label("+"); plus.AddToClassList("ash-rchip__plus"); tile.Add(plus); return tile; } /// /// Builds the shared card body (icon + name) for an item, with the icon wired to open a picker. /// The icon shows the item's sprite, or a neutral placeholder when unset. /// private VisualElement MakeCard(ItemData item, Action onIconClicked) { VisualElement card = new VisualElement(); card.AddToClassList("ash-rchip"); VisualElement icon = new VisualElement(); icon.AddToClassList("ash-rchip__icon"); icon.tooltip = "Click to choose an item"; if (item != null && item.Icon != null) icon.style.backgroundImage = new StyleBackground(item.Icon); icon.RegisterCallback(_ => onIconClicked()); card.Add(icon); Label name = new Label(item != null ? item.ItemName : "Choose…"); name.AddToClassList("ash-rchip__name"); card.Add(name); return card; } /// /// Builds a −/+ quantity stepper showing the current value; receives /// -1 or +1. /// private VisualElement Stepper(int quantity, Action onDelta) { VisualElement stepper = new VisualElement(); stepper.AddToClassList("ash-rchip__stepper"); Button minus = new Button(() => onDelta(-1)) { text = "−" }; minus.AddToClassList("ash-rchip__step"); stepper.Add(minus); Label value = new Label(quantity.ToString()); value.AddToClassList("ash-rchip__qty"); stepper.Add(value); Button plus = new Button(() => onDelta(1)) { text = "+" }; plus.AddToClassList("ash-rchip__step"); stepper.Add(plus); return stepper; } /// /// Builds a large "+" operator glyph between cost cards. /// private static Label Operator(string glyph) { Label op = new Label(glyph); op.AddToClassList("ash-equation__op"); return op; } #endregion #region Cost Mutations /// /// Sets the item of an existing cost line and repaints. /// private void SetCostItem(int index, ItemData item) { if (index < 0 || index >= costProp.arraySize) return; costProp.GetArrayElementAtIndex(index).FindPropertyRelative("item").objectReferenceValue = item; so.ApplyModifiedProperties(); RefreshCost(); } /// /// Changes a cost line's quantity by a delta, clamped to a minimum of one, and repaints. /// private void AdjustCostQuantity(int index, int delta) { if (index < 0 || index >= costProp.arraySize) return; SerializedProperty quantity = costProp.GetArrayElementAtIndex(index).FindPropertyRelative("quantity"); quantity.intValue = Mathf.Max(1, quantity.intValue + delta); so.ApplyModifiedProperties(); RefreshCost(); } /// /// Appends a new cost line (quantity 1) for the picked item and repaints; ignores a null pick /// (e.g. the picker was cancelled). /// private void AddCost(ItemData item) { if (item == null) return; int index = costProp.arraySize; costProp.arraySize++; SerializedProperty element = costProp.GetArrayElementAtIndex(index); element.FindPropertyRelative("item").objectReferenceValue = item; element.FindPropertyRelative("quantity").intValue = 1; so.ApplyModifiedProperties(); RefreshCost(); } /// /// Removes the cost line at the given index and repaints. /// private void RemoveCost(int index) { if (index < 0 || index >= costProp.arraySize) return; costProp.DeleteArrayElementAtIndex(index); so.ApplyModifiedProperties(); RefreshCost(); } #endregion #region Item Picker /// /// Builds the hidden IMGUI proxy that relays Unity's object-picker commands to the active cost /// pick handler (the editor window is UI Toolkit, which can't receive those commands directly). /// private VisualElement BuildPickerProxy() { IMGUIContainer proxy = new IMGUIContainer(HandleItemPickerCommands); proxy.style.position = Position.Absolute; proxy.style.width = 1; proxy.style.height = 1; return proxy; } /// /// Opens Unity's item picker seeded with the current item. When /// is true the pick applies on every highlight (live preview, e.g. changing an existing line); /// otherwise it applies only when the picker closes (e.g. adding a new line — commit once). /// private void OpenItemPicker(ItemData seed, bool commitLive, Action onPicked) { costPickHandler = onPicked; costPickCommitsOnCloseOnly = !commitLive; EditorGUIUtility.ShowObjectPicker(seed, false, string.Empty, CostPickerControlId); } /// /// Forwards the picker's selection to the active handler, honouring the live-vs-on-close policy. /// private void HandleItemPickerCommands() { Event evt = Event.current; if (evt == null || evt.type != EventType.ExecuteCommand) return; if (EditorGUIUtility.GetObjectPickerControlID() != CostPickerControlId) return; bool updated = evt.commandName == "ObjectSelectorUpdated"; bool closed = evt.commandName == "ObjectSelectorClosed"; if (!updated && !closed) return; if (updated && costPickCommitsOnCloseOnly) return; costPickHandler?.Invoke(EditorGUIUtility.GetObjectPickerObject() as ItemData); } #endregion } }