Files
Emberwild/Assets/GAME/Script/Editor/Database/BuildableEditorView.cs
T
2026-07-22 12:56:13 +02:00

594 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 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.
/// </summary>
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<ItemData> costPickHandler;
private bool costPickCommitsOnCloseOnly;
private GameObject generationSource;
private BuildSnapLayout generationLayout = BuildSnapLayout.None;
#endregion
#region Construction
/// <summary>
/// Builds the full editor tree for a buildable. <paramref name="onMetaChanged"/> is raised when
/// the display name or icon changes so the list can refresh that row;
/// <paramref name="onAssetChanged"/> re-renders the whole view after a structural change (prefab
/// generation, asset rename) that the live bindings alone cannot reflect.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<ClickEvent>(_ => 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<FocusOutEvent>(_ => 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;
}
/// <summary>
/// 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.
/// </summary>
private void SyncAssetName()
{
if (BuildableAssetFactory.RenameAssetToDisplayName(buildable))
onAssetChanged?.Invoke();
}
/// <summary>
/// Shows the buildable's icon in the hero preview, or a neutral placeholder when none is set.
/// </summary>
private void UpdateIconPreview(Sprite sprite)
{
iconPreview.style.backgroundImage = sprite != null ? new StyleBackground(sprite) : new StyleBackground();
}
/// <summary>
/// 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.
/// </summary>
private void OpenIconPicker()
{
EditorGUIUtility.ShowObjectPicker<Sprite>(buildable.Icon, false, string.Empty, IconPickerControlId);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Writes the picked icon onto the asset (no-op when unchanged) and refreshes the hero preview
/// and the list row.
/// </summary>
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
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string> { "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;
}
/// <summary>
/// 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.
/// </summary>
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";
}
/// <summary>
/// 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.
/// </summary>
private void GenerateMissingPrefabs()
{
bool generated = BuildableAssetFactory.GeneratePrefabs(
buildable,
generationSource,
generationLayout,
buildable.BuiltPrefab == null,
buildable.GhostPrefab == null);
if (generated) onAssetChanged?.Invoke();
}
/// <summary>
/// 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.
/// </summary>
private VisualElement BuildHealthCard()
{
VisualElement card = AshwildUI.Card("Health");
FloatField maxHealth = new FloatField("Max Health");
maxHealth.BindProperty(so.FindProperty("maxHealth"));
card.Add(maxHealth);
return card;
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
private VisualElement BuildCostCard()
{
VisualElement card = AshwildUI.Card("Cost");
costStrip = new VisualElement();
costStrip.AddToClassList("ash-equation");
card.Add(costStrip);
return card;
}
/// <summary>
/// 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.
/// </summary>
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());
}
/// <summary>
/// Builds the interactive card for the cost line at <paramref name="index"/>: a clickable icon
/// (change item), the name, a /+ quantity stepper, and a remove button.
/// </summary>
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;
}
/// <summary>
/// Builds the dashed "+" tile that appends a new cost line once an item is picked.
/// </summary>
private VisualElement BuildAddTile()
{
VisualElement tile = new VisualElement();
tile.AddToClassList("ash-rchip");
tile.AddToClassList("ash-rchip--add");
tile.tooltip = "Add a cost line";
tile.RegisterCallback<ClickEvent>(_ => OpenItemPicker(null, false, AddCost));
Label plus = new Label("+");
plus.AddToClassList("ash-rchip__plus");
tile.Add(plus);
return tile;
}
/// <summary>
/// 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.
/// </summary>
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<ClickEvent>(_ => onIconClicked());
card.Add(icon);
Label name = new Label(item != null ? item.ItemName : "Choose…");
name.AddToClassList("ash-rchip__name");
card.Add(name);
return card;
}
/// <summary>
/// Builds a /+ quantity stepper showing the current value; <paramref name="onDelta"/> receives
/// -1 or +1.
/// </summary>
private VisualElement Stepper(int quantity, Action<int> 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;
}
/// <summary>
/// Builds a large "+" operator glyph between cost cards.
/// </summary>
private static Label Operator(string glyph)
{
Label op = new Label(glyph);
op.AddToClassList("ash-equation__op");
return op;
}
#endregion
#region Cost Mutations
/// <summary>
/// Sets the item of an existing cost line and repaints.
/// </summary>
private void SetCostItem(int index, ItemData item)
{
if (index < 0 || index >= costProp.arraySize) return;
costProp.GetArrayElementAtIndex(index).FindPropertyRelative("item").objectReferenceValue = item;
so.ApplyModifiedProperties();
RefreshCost();
}
/// <summary>
/// Changes a cost line's quantity by a delta, clamped to a minimum of one, and repaints.
/// </summary>
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();
}
/// <summary>
/// Appends a new cost line (quantity 1) for the picked item and repaints; ignores a null pick
/// (e.g. the picker was cancelled).
/// </summary>
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();
}
/// <summary>
/// Removes the cost line at the given index and repaints.
/// </summary>
private void RemoveCost(int index)
{
if (index < 0 || index >= costProp.arraySize) return;
costProp.DeleteArrayElementAtIndex(index);
so.ApplyModifiedProperties();
RefreshCost();
}
#endregion
#region Item Picker
/// <summary>
/// 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).
/// </summary>
private VisualElement BuildPickerProxy()
{
IMGUIContainer proxy = new IMGUIContainer(HandleItemPickerCommands);
proxy.style.position = Position.Absolute;
proxy.style.width = 1;
proxy.style.height = 1;
return proxy;
}
/// <summary>
/// Opens Unity's item picker seeded with the current item. When <paramref name="commitLive"/>
/// 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).
/// </summary>
private void OpenItemPicker(ItemData seed, bool commitLive, Action<ItemData> onPicked)
{
costPickHandler = onPicked;
costPickCommitsOnCloseOnly = !commitLive;
EditorGUIUtility.ShowObjectPicker<ItemData>(seed, false, string.Empty, CostPickerControlId);
}
/// <summary>
/// Forwards the picker's selection to the active handler, honouring the live-vs-on-close policy.
/// </summary>
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
}
}